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.
What the current platform supports
Section titled “What the current platform supports”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 | vcheckout public URL | vcheckout Service -- private HTTP --> catalog Service catalog.<namespace>.svc.cluster.local:8001Project layout
Section titled “Project layout”services/ catalog/ app.py Dockerfile satusky.toml checkout/ app.py Dockerfile satusky.tomlThe names below must be unique in your namespace. Replace catalog and checkout if those names are already in use.
Create the catalog service
Section titled “Create the catalog service”Create services/catalog/app.py:
import jsonfrom 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-slimWORKDIR /appCOPY app.py .EXPOSE 8001CMD ["python", "app.py"]Create services/catalog/satusky.toml:
[app]name = "catalog"port = 8001cpu_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.
Create the checkout service
Section titled “Create the checkout service”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 jsonimport osfrom http.server import BaseHTTPRequestHandler, HTTPServerfrom 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-slimWORKDIR /appCOPY app.py .EXPOSE 8000CMD ["python", "app.py"]Create services/checkout/satusky.toml:
[app]name = "checkout"port = 8000cpu_request = "100m"cpu_limit = "250m"
[checks]health_path = "/health"Deploy in dependency order
Section titled “Deploy in dependency order”Deploy catalog first so its Kubernetes Service exists:
cd services/catalog1ctl deploy --wait --memory 64MiGet 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:8001Deploy checkout with that address. This value is an internal endpoint, not a secret:
cd ../checkout1ctl deploy --wait --memory 64Mi --env CATALOG_URL="$CATALOG_URL"Use the individual app status commands to check each release independently:
1ctl app status catalog1ctl app status checkoutBoth deployments should report Running. A failure in catalog must not make the checkout workload unhealthy.
Prove the private call
Section titled “Prove the private call”Confirm that both ClusterIP Services and their endpoints exist:
kubectl -n "$NAMESPACE" get service catalog checkoutkubectl -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:8000In 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.
Observe failure isolation
Section titled “Observe failure isolation”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=catalogWith 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 1done
test "$STATUS" = 503cat /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 1done
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.
Troubleshoot services separately
Section titled “Troubleshoot services separately”Check the two workloads independently:
kubectl -n "$NAMESPACE" get deployment,pod,servicekubectl -n "$NAMESPACE" logs deployment/catalog --tail=50kubectl -n "$NAMESPACE" logs deployment/checkout --tail=50If 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 widekubectl -n "$NAMESPACE" describe service catalogDo 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.
Clean up
Section titled “Clean up”Delete the dependent service first, then its dependency:
1ctl app delete checkout --yes1ctl app delete catalog --yesVerify only these exact resources are gone:
kubectl -n "$NAMESPACE" get deployment checkout catalogkubectl -n "$NAMESPACE" get service checkout catalogkubectl -n "$NAMESPACE" get httproute checkout-route catalog-routeEach command should return NotFound after deletion. Do not use broad namespace deletion commands: other applications may share the namespace.