Skip to content

End-to-End: Build, Test, Containerize, Ship, Observe

What this lesson gives you

A production-grade URL shortener that threads together FastAPI, Redis, Prometheus, OpenTelemetry, Docker, GitHub Actions CI/CD, Kubernetes, ArgoCD GitOps, and pre-commit hooks — every concept from every previous part, assembled into one deployable service.

Estimated time: 60 min read · Part: Capstone: A Service from Commit to Production

The capstone is not a toy. It is a reference architecture: the skeleton that a production microservice at any serious engineering organisation would recognise. You could hand this repository to a senior engineer at a tier-1 firm and they would find: typed, tested Python; a multi-stage Docker build running as non-root; a CI/CD pipeline with test, lint, image scan, and deploy stages; Kubernetes manifests with a horizontal pod autoscaler; GitOps via ArgoCD; and OpenTelemetry instrumentation with structured logging. Each piece was introduced in an earlier part. This lesson assembles them into the coherent whole — and shows you how to tell its story.

The intern who got paged at 2am

A junior engineer joined a scale-up and deployed their first service to production: a link-shortening tool for internal marketing campaigns. The service worked. Three hours later, at 2:04am, the on-call engineer was paged: the load balancer was marking the pod as unhealthy and cycling it every 30 seconds. The pod logs showed no errors. The problem: the intern had not implemented a /healthz endpoint. The load balancer probed /healthz, got a 404, declared the pod dead, and replaced it in an infinite loop. The intern learned two things that night: health endpoints are not optional, and reading the platform team’s “deployment requirements” document before shipping is not bureaucracy — it is the reason the platform works. Every piece of the capstone exists because someone was paged over its absence.

Project Structure

pyproject.toml

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "urlshort"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.111",
    "uvicorn[standard]>=0.30",
    "redis[hiredis]>=5.0",
    "pydantic-settings>=2.2",
    "opentelemetry-sdk>=1.24",
    "opentelemetry-instrumentation-fastapi>=0.45b0",
    "prometheus-client>=0.20",
    "structlog>=24.1",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.2",
    "pytest-asyncio>=0.23",
    "httpx>=0.27",
    "fakeredis[aioredis]>=2.23",
    "mypy>=1.10",
    "ruff>=0.4",
    "pre-commit>=3.7",
]

[project.scripts]
urlshort = "urlshort.app:main"

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "S"]
ignore  = ["S101"]    # allow assert in tests

[tool.mypy]
strict = true
plugins = ["pydantic.mypy"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths    = ["tests"]

12-Factor Configuration with Pydantic Settings

src/urlshort/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    """All configuration comes from environment variables.
    Pydantic validates types and provides defaults.
    No configuration is hard-coded in source.
    """
    model_config = SettingsConfigDict(
        env_prefix="URLSHORT_",   # URLSHORT_REDIS_URL, URLSHORT_DEBUG, etc.
        env_file=".env",
        env_file_encoding="utf-8",
    )

    # Application
    app_name:    str  = "urlshort"
    environment: str  = "production"    # production | staging | development
    debug:       bool = False
    log_level:   str  = "INFO"
    code_length: int  = 7               # length of generated short codes

    # Redis
    redis_url:      str = "redis://localhost:6379/0"
    redis_max_conn: int = 20
    url_ttl_days:   int = 30            # 0 = never expire

    # Observability
    otlp_endpoint: str = ""            # empty = no OTLP export
    metrics_port:  int = 9090

    # Rate limiting
    rate_limit_per_minute: int = 60

    @property
    def url_ttl_seconds(self) -> int:
        return self.url_ttl_days * 86_400

@lru_cache
def get_settings() -> Settings:
    """Cached singleton so env is read once.
    Tests override by calling get_settings.cache_clear() before patching env.
    """
    return Settings()

FastAPI Application

src/urlshort/app.py
import secrets, time
from contextlib import asynccontextmanager
from typing import AsyncGenerator

import structlog
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, HttpUrl

from .config import Settings, get_settings
from .metrics import instrument_app, REQUEST_COUNT, REQUEST_LATENCY
from .storage import StorageBackend, get_storage

log = structlog.get_logger()

# ── Request / response models ─────────────────────────────────────────────
class ShortenRequest(BaseModel):
    url: HttpUrl
    custom_code: str | None = None

class ShortenResponse(BaseModel):
    code:      str
    short_url: str
    long_url:  str

# ── Application factory ───────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    log.info("startup", environment=get_settings().environment)
    yield
    log.info("shutdown")

def create_app(settings: Settings | None = None) -> FastAPI:
    cfg = settings or get_settings()
    app = FastAPI(
        title="URL Shortener",
        version="1.0.0",
        docs_url="/api/v1/docs",
        openapi_url="/api/v1/openapi.json",
        lifespan=lifespan,
    )
    instrument_app(app)
    app.include_router(router, prefix="/api/v1")
    return app

# ── Router ────────────────────────────────────────────────────────────────
from fastapi import APIRouter
router = APIRouter()

@router.post("/shorten", response_model=ShortenResponse, status_code=201)
async def shorten(
    body: ShortenRequest,
    request: Request,
    storage: StorageBackend = Depends(get_storage),
    cfg: Settings = Depends(get_settings),
) -> ShortenResponse:
    long_url = str(body.url)
    code = body.custom_code or secrets.token_urlsafe(cfg.code_length)[:cfg.code_length]

    existing = await storage.get(code)
    if existing and existing != long_url:
        raise HTTPException(status_code=409, detail="Code already taken")

    await storage.set(code, long_url, ttl=cfg.url_ttl_seconds or None)
    base = str(request.base_url).rstrip("/")
    log.info("shortened", code=code, long_url=long_url)
    return ShortenResponse(code=code, short_url=f"{base}/r/{code}", long_url=long_url)

@router.get("/r/{code}")
async def resolve(
    code: str,
    storage: StorageBackend = Depends(get_storage),
) -> RedirectResponse:
    long_url = await storage.get(code)
    if not long_url:
        raise HTTPException(status_code=404, detail="Code not found")
    await storage.increment_clicks(code)
    return RedirectResponse(url=long_url, status_code=302)

@router.get("/healthz")
async def healthz(storage: StorageBackend = Depends(get_storage)) -> dict:
    """Liveness + readiness probe. Returns 503 if Redis is unreachable."""
    healthy = await storage.ping()
    if not healthy:
        raise HTTPException(status_code=503, detail="Storage unavailable")
    return {"status": "ok", "storage": "redis"}

@router.get("/analytics/top")
async def top_urls(
    n: int = 10,
    storage: StorageBackend = Depends(get_storage),
) -> list[dict]:
    """Return the top-n most-clicked short codes."""
    return await storage.top_clicked(n)

app = create_app()

def main() -> None:
    import uvicorn
    uvicorn.run("urlshort.app:app", host="0.0.0.0", port=8000, reload=False)

Redis-Backed Storage Layer

src/urlshort/storage.py
from typing import Any
import redis.asyncio as aioredis
from .config import get_settings

class StorageBackend:
    """Thin async wrapper around Redis.

    Key schema:
      url:{code}       STRING  the long URL
      clicks:{code}    STRING  integer click counter (INCR)
      clicks:zset      ZSET    (code -> click count) for top-N queries
    """

    def __init__(self, client: aioredis.Redis) -> None:
        self._r = client

    async def ping(self) -> bool:
        try:
            return bool(await self._r.ping())
        except Exception:
            return False

    async def get(self, code: str) -> str | None:
        val: Any = await self._r.get(f"url:{code}")
        return val.decode() if val else None

    async def set(self, code: str, long_url: str, ttl: int | None = None) -> None:
        key = f"url:{code}"
        if ttl:
            await self._r.setex(key, ttl, long_url)
        else:
            await self._r.set(key, long_url)

    async def increment_clicks(self, code: str) -> None:
        pipe = self._r.pipeline()
        pipe.incr(f"clicks:{code}")
        pipe.zincrby("clicks:zset", 1, code)
        await pipe.execute()

    async def get_clicks(self, code: str) -> int:
        val: Any = await self._r.get(f"clicks:{code}")
        return int(val) if val else 0

    async def top_clicked(self, n: int = 10) -> list[dict]:
        """Returns top-n codes by click count using the sorted set."""
        results: list[tuple[bytes, float]] = await self._r.zrevrange(
            "clicks:zset", 0, n - 1, withscores=True
        )
        return [
            {"code": code.decode(), "clicks": int(score)}
            for code, score in results
        ]

# ── Dependency for FastAPI ────────────────────────────────────────────────
_pool: aioredis.Redis | None = None

async def get_storage() -> StorageBackend:
    global _pool
    if _pool is None:
        cfg = get_settings()
        _pool = aioredis.from_url(
            cfg.redis_url,
            max_connections=cfg.redis_max_conn,
            decode_responses=False,
        )
    return StorageBackend(_pool)

Prometheus Metrics Middleware

src/urlshort/metrics.py
import time
from prometheus_client import Counter, Histogram, make_asgi_app
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

REQUEST_COUNT = Counter(
    "urlshort_requests_total",
    "Total HTTP requests",
    ["method", "endpoint", "status"],
)

REQUEST_LATENCY = Histogram(
    "urlshort_request_duration_seconds",
    "HTTP request latency",
    ["method", "endpoint"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5),
)

REDIRECTS_TOTAL = Counter(
    "urlshort_redirects_total",
    "Total successful URL redirects",
    ["code"],
)

class PrometheusMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next: Any) -> Response:
        start = time.perf_counter()
        response = await call_next(request)
        duration = time.perf_counter() - start

        # Normalise path to avoid cardinality explosion on /r/{code}
        path = request.url.path
        if path.startswith("/r/"):
            path = "/r/{code}"

        REQUEST_COUNT.labels(
            method=request.method, endpoint=path, status=response.status_code
        ).inc()
        REQUEST_LATENCY.labels(
            method=request.method, endpoint=path
        ).observe(duration)

        return response

def instrument_app(app: FastAPI) -> None:
    app.add_middleware(PrometheusMiddleware)
    # Expose /metrics for Prometheus scraping
    metrics_app = make_asgi_app()
    app.mount("/metrics", metrics_app)

Integration Tests with pytest-asyncio

tests/conftest.py
import pytest
import fakeredis.aioredis as fakeredis
from httpx import ASGITransport, AsyncClient
from urlshort.app import create_app
from urlshort.config import Settings
from urlshort.storage import StorageBackend

@pytest.fixture
def test_settings() -> Settings:
    return Settings(
        environment="testing",
        redis_url="redis://localhost:6379/15",  # overridden by fake
        url_ttl_days=0,
        debug=True,
    )

@pytest.fixture
async def fake_redis() -> fakeredis.FakeRedis:
    r = fakeredis.FakeRedis()
    yield r
    await r.flushall()
    await r.aclose()

@pytest.fixture
async def client(test_settings: Settings, fake_redis: fakeredis.FakeRedis) -> AsyncClient:
    app = create_app(settings=test_settings)
    storage = StorageBackend(fake_redis)
    app.dependency_overrides[get_storage] = lambda: storage
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        yield ac
tests/test_api.py
import pytest
from httpx import AsyncClient

pytestmark = pytest.mark.asyncio

async def test_shorten_then_resolve(client: AsyncClient) -> None:
    r = await client.post("/api/v1/shorten", json={"url": "https://example.com/long-path"})
    assert r.status_code == 201
    body = r.json()
    assert "code" in body
    assert body["long_url"] == "https://example.com/long-path"

    # Resolve should redirect
    r2 = await client.get(f"/api/v1/r/{body['code']}", follow_redirects=False)
    assert r2.status_code == 302
    assert r2.headers["location"] == "https://example.com/long-path"

async def test_unknown_code_is_404(client: AsyncClient) -> None:
    r = await client.get("/api/v1/r/notexist")
    assert r.status_code == 404

async def test_custom_code(client: AsyncClient) -> None:
    r = await client.post("/api/v1/shorten", json={
        "url": "https://example.com", "custom_code": "mycode"
    })
    assert r.status_code == 201
    assert r.json()["code"] == "mycode"

async def test_duplicate_custom_code_conflict(client: AsyncClient) -> None:
    await client.post("/api/v1/shorten", json={
        "url": "https://first.com", "custom_code": "clash"
    })
    r = await client.post("/api/v1/shorten", json={
        "url": "https://second.com", "custom_code": "clash"
    })
    assert r.status_code == 409

async def test_healthz(client: AsyncClient) -> None:
    r = await client.get("/api/v1/healthz")
    assert r.status_code == 200
    assert r.json()["status"] == "ok"

async def test_click_counter_and_analytics(client: AsyncClient) -> None:
    r = await client.post("/api/v1/shorten", json={"url": "https://popular.com"})
    code = r.json()["code"]

    # Click three times
    for _ in range(3):
        await client.get(f"/api/v1/r/{code}", follow_redirects=False)

    top = await client.get("/api/v1/analytics/top?n=1")
    assert top.status_code == 200
    data = top.json()
    assert data[0]["code"] == code
    assert data[0]["clicks"] == 3

Multi-Stage Dockerfile

Dockerfile
# ── Stage 1: builder ─────────────────────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build

# Install build tools in a single layer to keep it cacheable
RUN pip install --no-cache-dir hatchling

COPY pyproject.toml .
COPY src/ src/

# Build wheel
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels .

# ── Stage 2: runtime ──────────────────────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app

# Security: create a non-root user before copying anything
RUN addgroup --system app && adduser --system --ingroup app app

# Install runtime deps from pre-built wheels (no compiler needed)
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels

# Copy only what the app needs at runtime
COPY --from=builder /build/src ./src

# Drop to non-root
USER app

EXPOSE 8000

# Health check at the container level (separate from K8s probes)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/healthz')"

CMD ["python", "-m", "uvicorn", "urlshort.app:app", \
     "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

GitHub Actions CI/CD Pipeline

.github/workflows/ci.yml
name: CI/CD

on:
  push:
    branches: [main, "feature/**"]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE: ${{ github.repository }}/urlshort

jobs:
  # ── Job 1: test + lint ──────────────────────────────────────────────────
  test:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - name: Install deps
        run: pip install -e ".[dev]"
      - name: Lint (ruff)
        run: ruff check . && ruff format --check .
      - name: Type-check (mypy)
        run: mypy src/
      - name: Test
        run: pytest --tb=short -q
        env:
          URLSHORT_REDIS_URL: redis://localhost:6379/15
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        if: always()

  # ── Job 2: build & scan image ───────────────────────────────────────────
  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image-digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        id: push
        with:
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
          cache-from: type=gha
          cache-to:   type=gha,mode=max
      - name: Scan image (Trivy)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
          severity: CRITICAL,HIGH
          exit-code: 1

  # ── Job 3: deploy (cloud via kubectl, on-prem via self-hosted runner) ───
  deploy:
    needs: build
    runs-on: [self-hosted, production]   # self-hosted runner on-prem
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Patch image tag in kustomization
        run: |
          cd k8s
          kustomize edit set image urlshort=${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
      - name: Push updated manifests (ArgoCD picks up via GitOps)
        run: |
          git config user.name  "ci-bot"
          git config user.email "ci@example.com"
          git add k8s/
          git commit -m "ci: deploy ${{ github.sha }}" || echo "Nothing to commit"
          git push

Cloud + on-premises hybrid deployment

The deploy job runs on a self-hosted runner registered on the on-premises network. This allows the same GitHub Actions workflow to deploy to both a cloud Kubernetes cluster (using a kubeconfig secret) and an on-prem cluster (reached via the self-hosted runner’s network access) without exposing the internal network to the internet. The ArgoCD GitOps model — where the pipeline pushes manifests to Git and ArgoCD pulls them into the cluster — works identically in both environments.

Pre-commit Hooks

.pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict
      - id: detect-private-key

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.7
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: ["pydantic>=2", "pydantic-settings>=2"]
        args: [--strict]

Kubernetes Manifests

k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: urlshort
  labels:
    app: urlshort
spec:
  replicas: 2
  selector:
    matchLabels:
      app: urlshort
  template:
    metadata:
      labels:
        app: urlshort
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port:   "9090"
        prometheus.io/path:   "/metrics"
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000
      containers:
        - name: urlshort
          image: ghcr.io/example/urlshort:latest   # patched by CI
          ports:
            - containerPort: 8000
              name: http
          env:
            - name: URLSHORT_REDIS_URL
              valueFrom:
                secretKeyRef:
                  name: urlshort-secrets
                  key: redis-url
            - name: URLSHORT_ENVIRONMENT
              value: production
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          readinessProbe:
            httpGet:
              path: /api/v1/healthz
              port: 8000
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /api/v1/healthz
              port: 8000
            initialDelaySeconds: 15
            periodSeconds: 30
            failureThreshold: 5
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: urlshort-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: urlshort
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75

Architecture and Git Workflow Diagrams

flowchart LR
    User -->|HTTPS| LB[Load Balancer<br/>/ Ingress]
    LB --> FastAPI["FastAPI Pods<br/>(2–10, HPA)"]
    FastAPI --> Redis[(Redis<br/>URL store<br/>click counters)]
    FastAPI -.->|OTLP traces| Collector[OTel Collector]
    FastAPI -.->|/metrics| Prometheus
    Prometheus --> Grafana
    Collector --> Jaeger
    FastAPI -.->|structured JSON| Loki
    Loki --> Grafana
gitGraph
   commit id: "initial scaffold"
   branch feature/click-counter
   commit id: "add INCR to storage"
   commit id: "add /analytics/top endpoint"
   commit id: "tests for click counter"
   checkout main
   merge feature/click-counter id: "PR merge"
   commit id: "ci: deploy abc1234" tag: "production"

SLO Definition and Alerting

Define SLOs before you write a line of code

An SLO (Service Level Objective) is a target for a service’s reliability, expressed in terms the business understands. “p99 latency under 200ms for the redirect endpoint” is an SLO. “Error rate below 0.1% over a 28-day window” is an SLO. Without written SLOs, on-call becomes guesswork — engineers do not know whether a 1% error rate is a crisis or expected. With SLOs, the alert threshold is a math problem: alert when your error budget is burning faster than your replenishment rate.

SLI (what we measure) SLO (the target) Alert condition
p99 redirect latency < 200ms, 99.9% of requests Alert if p99 > 200ms for > 5 minutes
Error rate (5xx / total) < 0.1% over 28-day window Alert if 1-hour error rate > 1% (fast-burn)
Healthz availability > 99.9% of probes succeed Alert if healthz returns non-200 for 2 consecutive probes
Redis availability > 99.95% ping success Alert immediately on Redis connection failure

The cardinality explosion in Prometheus labels

If you use the raw request path as a Prometheus label (e.g., endpoint="/r/abc1234"), every unique short code becomes a unique time series. With millions of codes, your Prometheus instance will OOM. The PrometheusMiddleware in this capstone normalises /r/{code} to a single label value for exactly this reason. Always design Prometheus labels with a bounded cardinality in mind: methods (GET/POST), normalised paths, and HTTP status codes are safe. User IDs, request IDs, and opaque short codes are not.

How to Tell This Story in Interviews

Consulting lens: the capstone as a reference architecture pitch

“This is the exact pattern we would apply to your ingestion service. The application layer is FastAPI — typed, tested, with a clean 12-factor config model. The storage layer is Redis, which handles your sub-millisecond latency requirement, with a Postgres async side-write for durability. The deployment model is GitOps: every change goes through a pull request, CI runs tests and a container security scan before merge, and ArgoCD synchronises the cluster to the Git state automatically. Observability is OpenTelemetry — vendor-neutral traces that export to whatever backend you already run. Every piece is battle-tested and none of it is proprietary. We can fork this scaffold and have your service running in staging within the first sprint.”

interview_narrative.txt
"Walk me through a project you’re proud of."

NARRATIVE ARC (90 seconds):

"I built a URL shortening service end-to-end as a capstone project.
The goal was to simulate the full lifecycle of a production microservice.

I started with a FastAPI application, typed with Pydantic, and backed
by Redis for sub-millisecond URL lookups and a sorted set for click
analytics. I used Pydantic Settings for 12-factor configuration — no
hard-coded values anywhere; everything comes from environment variables
validated at startup.

I wrote integration tests using pytest-asyncio and fakeredis, so the
tests run without a real Redis instance and complete in under two seconds.
Pre-commit hooks run ruff and mypy on every commit.

The Dockerfile uses a multi-stage build: a builder stage compiles the
wheel, and a slim runtime stage installs only the wheel — no compiler
toolchain in the final image. The container runs as a non-root user
and exposes a /healthz endpoint that the Kubernetes liveness and
readiness probes use.

CI runs on GitHub Actions: test → lint → build → Trivy security scan
→ push to the container registry. Deployment uses GitOps: CI patches
the image tag in the Kubernetes manifests and commits to main; ArgoCD
watches the repository and applies changes automatically. We get
immutable deployments with full audit history in Git.

For observability: OpenTelemetry traces via FastAPIInstrumentor,
Prometheus metrics for latency and error rate with normalised path
labels to avoid cardinality explosions, and structured JSON logging
with structlog.

The service handles roughly 5,000 requests per second on two pods
before the HPA scales up. The SLO targets are p99 redirect latency
under 200ms and an error rate under 0.1% over a 28-day window."

Key takeaways

  • Pydantic Settings is the idiomatic 12-factor config layer for Python — validate environment variables at startup, fail fast on misconfiguration.
  • Multi-stage Docker builds keep the production image small and free of build tooling — always run as a non-root user.
  • GitOps (ArgoCD) separates the CI concern (build & test) from the CD concern (apply to cluster) — Git becomes the single source of truth for cluster state.
  • Prometheus labels must have bounded cardinality — normalise dynamic path segments before using them as labels.
  • Every liveness and readiness probe must point to a /healthz endpoint that checks real dependencies (Redis ping) — a shallow 200 OK hides deployment bugs.
  • SLOs define what “working” means before anything breaks — p99 latency and error rate are the two minimum SLIs for any HTTP service.
  • The capstone narrative arc — config → storage → tests → containers → CI/CD → Kubernetes → observability — is the story tier-1 interviewers want to hear when they ask “walk me through a project.”
Knowledge check

InterviewA Kubernetes liveness probe is hitting /healthz and the endpoint returns 200 OK, but the pod is still being restarted every 30 seconds. What is the most likely cause?

  • The liveness probe path is wrong — it should point to /health instead
  • The pod’s readinessProbe is failing, which Kubernetes mistakes for a liveness failure
  • The failureThreshold or periodSeconds on the liveness probe is so tight that the pod is being killed before it finishes starting up; increase initialDelaySeconds or the failure threshold
  • Kubernetes automatically restarts pods every 30 seconds regardless of probe results
Answer

The failureThreshold or periodSeconds on the liveness probe is so tight that the pod is being killed before it finishes starting up; increase initialDelaySeconds or the failure threshold

When a liveness probe succeeds but restarts are still happening, the first thing to check is probe timing. If initialDelaySeconds is too short, Kubernetes starts probing before the application is ready to accept connections, gets a connection-refused error (not a 200), counts it as a failure, and kills the pod. The restart looks like a liveness failure even though the endpoint is correctly implemented. Fix: increase initialDelaySeconds to longer than your application startup time, or use a startupProbe with a larger failureThreshold to give the container time to start before the liveness probe becomes active. A readiness failure does not cause pod restarts — it only removes the pod from the service load balancer until it recovers.

Exercise

Exercise 16.1 · Click Counter + Analytics + Background Flush The click counter and /analytics/top endpoint are already wired into the capstone. Extend the service with:

  1. A background task that fires after each redirect and logs a structured analytics record (code, timestamp, referrer, user-agent) to a Postgres table using asyncpg — without blocking the 302 redirect response.
  2. A scheduled aggregation task (using asyncio.create_task in the lifespan) that runs every 60 seconds, reads the top-10 from Redis, and writes a snapshot row to a analytics_snapshots table with a timestamp.
  3. Tests for the background task using pytest-asyncio and a mocked asyncpg connection pool.
Show solution sketch