Skip to content

Microservices

This guide deploys a small catalog service and a checkout service. Checkout calls catalog over the cluster network; browsers only need the checkout URL. Each service has its own source directory, satusky.toml, deployment, health check, and lifecycle.

One satusky.toml describes one application. It cannot describe a multi-service composition, so put each service in its own directory and deploy each directory separately.

Current normal 1ctl deploys also always request a default DNS HTTPRoute. There is no private-only or no-public-route deploy option. The catalog service in this guide is still called through its private Service DNS name, and its generated public URL is neither used nor advertised. Treat checkout as the user-facing entry point. Do not manually delete a route that the platform owns; use a private-only deployment option when the platform provides one.

The topology is:

browser
|
v
checkout public URL
|
v
checkout Service -- private HTTP --> catalog Service
catalog.<namespace>.svc.cluster.local:8001
services/
catalog/
app.py
Dockerfile
satusky.toml
checkout/
app.py
Dockerfile
satusky.toml

The names below must be unique in your namespace. Replace catalog and checkout if those names are already in use.

Create services/catalog/app.py:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
body = {"status": "ok", "service": "catalog"}
elif self.path == "/item":
body = {"item": "guide-widget", "source": "catalog"}
else:
self.send_error(404)
return
encoded = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
HTTPServer(("0.0.0.0", 8001), Handler).serve_forever()

Create services/catalog/Dockerfile:

FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8001
CMD ["python", "app.py"]

Create services/catalog/satusky.toml:

[app]
name = "catalog"
port = 8001
cpu_request = "100m"
cpu_limit = "250m"
[checks]
health_path = "/health"

The checks section is the current health-check configuration. Keep the catalog port private to the cluster by using its Service DNS name from callers, not its generated public domain.

Checkout has a separate health endpoint. Its health does not depend on catalog; the request that needs catalog returns 503 when catalog is unavailable.

Create services/checkout/app.py:

import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import urlopen
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
status = 200
if self.path == "/health":
body = {"status": "ok", "service": "checkout"}
elif self.path == "/order-preview":
try:
with urlopen(os.environ["CATALOG_URL"] + "/item", timeout=5) as response:
body = {"checkout": "ok", "catalog": json.load(response)}
except OSError:
status = 503
body = {"checkout": "degraded", "catalog": "unavailable"}
else:
self.send_error(404)
return
encoded = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

Create services/checkout/Dockerfile:

FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]

Create services/checkout/satusky.toml:

[app]
name = "checkout"
port = 8000
cpu_request = "100m"
cpu_limit = "250m"
[checks]
health_path = "/health"

Deploy catalog first so its Kubernetes Service exists:

cd services/catalog
1ctl deploy --wait --memory 64Mi

Get the namespace from the deployment, rather than guessing it:

NAMESPACE=$(1ctl -o json app get catalog | jq -r '.namespace')

The stable private address is based on the Service name and namespace:

CATALOG_URL=http://catalog.$NAMESPACE.svc.cluster.local:8001

Deploy checkout with that address. This value is an internal endpoint, not a secret:

cd ../checkout
1ctl deploy --wait --memory 64Mi --env CATALOG_URL="$CATALOG_URL"

Use the individual app status commands to check each release independently:

1ctl app status catalog
1ctl app status checkout

Both deployments should report Running. A failure in catalog must not make the checkout workload unhealthy.

Confirm that both ClusterIP Services and their endpoints exist:

kubectl -n "$NAMESPACE" get service catalog checkout
kubectl -n "$NAMESPACE" get endpointslice -l 'kubernetes.io/service-name in (catalog,checkout)'

Forward only checkout to your machine and make the request there. The nested catalog response proves that checkout resolved and called the private catalog Service; the catalog public URL is not involved.

kubectl -n "$NAMESPACE" port-forward service/checkout 18000:8000

In another terminal:

curl -fsS http://127.0.0.1:18000/health
# {"status": "ok", "service": "checkout"}
curl -fsS http://127.0.0.1:18000/order-preview
# {"checkout": "ok", "catalog": {"item": "guide-widget", "source": "catalog"}}

After the checkout hostname’s DNS condition is verified, it is the only public URL this example should expose to users:

CHECKOUT_URL=$(1ctl -o json app get checkout | jq -r '.domain')
curl -fsS "$CHECKOUT_URL/health"

pending, nxdomain, wrong_target, and error do not make checkout’s public route or application readiness successful. That does not change the private Service-to-Service path verified above. Current 1ctl behavior creates default routes for both services, so do not claim that catalog has no externally generated URL.

In a non-production namespace, you can temporarily make only the catalog Service endpointless. This test changes the selector on your catalog Service and restores its exact original selector at the end.

kubectl -n "$NAMESPACE" patch service catalog --type merge -p '{"spec":{"selector":{"app":"catalog-unavailable"}}}'

Wait until catalog has no endpoints:

kubectl -n "$NAMESPACE" get endpointslice -l kubernetes.io/service-name=catalog

With the checkout port-forward still running, its health remains independent while the catalog-dependent endpoint becomes degraded:

curl -fsS http://127.0.0.1:18000/health
# {"status": "ok", "service": "checkout"}
for attempt in $(seq 1 30); do
STATUS=$(curl -sS -o /tmp/checkout-degraded.json -w '%{http_code}' \
http://127.0.0.1:18000/order-preview)
test "$STATUS" = 503 && break
sleep 1
done
test "$STATUS" = 503
cat /tmp/checkout-degraded.json
# HTTP/1.0 503 Service Unavailable
# {"checkout": "degraded", "catalog": "unavailable"}

Restore the selector immediately:

kubectl -n "$NAMESPACE" patch service catalog --type merge -p '{"spec":{"selector":{"app":"catalog"}}}'

Then poll until the restored endpoint returns catalog data:

for attempt in $(seq 1 30); do
RESPONSE=$(curl -fsS http://127.0.0.1:18000/order-preview 2>/dev/null || true)
printf '%s' "$RESPONSE" | grep -q '"source": "catalog"' && break
sleep 1
done
printf '%s\n' "$RESPONSE"

This is failure isolation: checkout stays runnable and reports a bounded dependency failure instead of pretending its dependency is healthy. EndpointSlice and kube-proxy updates are asynchronous, so an immediate request can briefly use the previous routing state.

Check the two workloads independently:

kubectl -n "$NAMESPACE" get deployment,pod,service
kubectl -n "$NAMESPACE" logs deployment/catalog --tail=50
kubectl -n "$NAMESPACE" logs deployment/checkout --tail=50

If checkout returns 503, verify the exact service address in its environment and that catalog has a ready EndpointSlice:

kubectl -n "$NAMESPACE" get endpointslice -l kubernetes.io/service-name=catalog -o wide
kubectl -n "$NAMESPACE" describe service catalog

Do not replace the private address with a generated public domain. That changes an in-cluster dependency into a DNS and Gateway dependency and removes the service-discovery property this guide demonstrates.

Delete the dependent service first, then its dependency:

1ctl app delete checkout --yes
1ctl app delete catalog --yes

Verify only these exact resources are gone:

kubectl -n "$NAMESPACE" get deployment checkout catalog
kubectl -n "$NAMESPACE" get service checkout catalog
kubectl -n "$NAMESPACE" get httproute checkout-route catalog-route

Each command should return NotFound after deletion. Do not use broad namespace deletion commands: other applications may share the namespace.