Lesson 03 Multi-Service Builds
Theme:

Solutions — Multi-Service Builds

Lesson 03 · Cloud Infrastructure & DevOps

Solution to Task 1 — Load-balancer with two API replicas

The compose snippet for the three-service front end:

services:
  api1:
    build: ./api
    image: url-api:dev
    env_file: ./.env
    environment:
      REPLICA_NAME: api1
    depends_on:
      postgres: { condition: service_healthy }
  api2:
    build: ./api
    image: url-api:dev
    env_file: ./.env
    environment:
      REPLICA_NAME: api2
    depends_on:
      postgres: { condition: service_healthy }
  lb:
    image: caddy:2-alpine
    ports: ["8000:8000"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on: [api1, api2]

The Caddyfile at cloud-devops/sample-app/Caddyfile:

:8000 {
  reverse_proxy api1:8000 api2:8000
}

The app.py change to surface which replica served the request:

import os
REPLICA = os.environ.get("REPLICA_NAME", "unknown")

@app.get("/health")
def health() -> dict:
    print(f"health served by {REPLICA}")
    return {"status": "ok", "replica": REPLICA}
Common mistake: setting ports: ["8000:8000"] on both api1 and api2. Port conflicts — only one process can bind a host port. Only the load balancer exposes the host port; api1 and api2 are reachable from the lb on the internal network without any port mapping.

Solution to Task 2 — API healthcheck gating the worker

Addition to the api’s Dockerfile (after installing packages and before the CMD):

# Need curl for the healthcheck (slim doesn't ship it):
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

HEALTHCHECK --interval=5s --timeout=3s --retries=5 \
  CMD curl -f http://localhost:8000/health || exit 1

And the compose change to gate the worker:

worker:
  build: ./worker
  env_file: ./.env
  depends_on:
    api:
      condition: service_healthy
    postgres:
      condition: service_healthy
Smaller-image alternative: instead of installing curl, use the Python runtime that is already in the image:
HEALTHCHECK --interval=5s --timeout=3s --retries=5 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
Saves ~4 MB and avoids the apt install layer.
Compose healthcheck vs Dockerfile HEALTHCHECK: you can define the healthcheck in either place. If both are set, the compose definition wins. Convention: put it in the Dockerfile for portable, image-level defaults; use the compose definition to tune intervals for a specific environment without rebuilding the image.

Solution to Task 3 — Dev/prod compose split

docker-compose.yml (prod-like base — no bind-mount, no --reload):

services:
  api:
    build: ./api
    image: url-api:dev
    ports: ["8000:8000"]
    env_file: ./.env
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_started }

  worker:
    build: ./worker
    image: url-worker:dev
    env_file: ./.env
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_started }

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: urls
      POSTGRES_USER: urls
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U urls -d urls"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

docker-compose.override.yml (dev-only, auto-merged):

services:
  api:
    volumes:
      - ./api:/app
    command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Run commands:

# Dev — Compose auto-merges override.yml:
docker compose up -d

# Prod-like — only reads the base file:
docker compose -f docker-compose.yml up -d

# Inspect merged config in dev:
docker compose config | grep -A5 "command\|volumes"
Common mistake: putting prod tweaks in docker-compose.override.yml and dev tweaks in docker-compose.yml. The override convention is the opposite: the base file is the prod default, the override is the dev overlay. Compose auto-merges override.yml for the developer’s convenience — so if you get this backwards, developers silently run a production config and the “dev” config requires an explicit -f flag every time.

The bigger lesson: by Module 4 you stop maintaining a separate prod compose file and use Helm with per-environment values.yaml instead. The override pattern is the “minimum viable” version of that idea — good for local development, not scalable beyond a handful of services.

You have now containerised a real two-service Python app and wired it into a full local stack. Module 2 starts shipping it: GitHub Actions builds the images on every commit, scans them for CVEs, signs them, and pushes to a registry.