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¶
[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¶
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¶
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¶
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¶
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¶
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
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¶
# ── 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¶
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¶
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¶
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.”
"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
/healthzendpoint 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
/healthinstead - The pod’s
readinessProbeis failing, which Kubernetes mistakes for a liveness failure - The
failureThresholdorperiodSecondson the liveness probe is so tight that the pod is being killed before it finishes starting up; increaseinitialDelaySecondsor 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:
- 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.
- A scheduled aggregation task (using
asyncio.create_taskin the lifespan) that runs every 60 seconds, reads the top-10 from Redis, and writes a snapshot row to aanalytics_snapshotstable with a timestamp. - Tests for the background task using
pytest-asyncioand a mocked asyncpg connection pool.