M15 — Microservices & Infrastructure
When microservices make sense:
- Team topology: Conway's Law — your system architecture mirrors your communication structure. If you have 5 independent teams, a monolith creates coordination overhead; separate services let teams deploy independently.
- Independent scaling: one component (e.g., image processing) needs 10× more resources than others — split it to scale independently
- Technology heterogeneity: ML model serving needs Python, low-latency trading needs C — different services, different stacks
- Fault isolation: a crash in recommendations shouldn't crash checkout
- Network latency and reliability in every inter-service call
- Distributed tracing, log aggregation, and health monitoring for N services
- Data consistency without distributed transactions (Saga, Outbox)
- Deployment pipeline for each service
In an e-commerce domain, "Customer" means something different to the Billing context (credit card, payment history) vs the Shipping context (address, preferred carrier). Each bounded context defines its own model of "Customer" — and each maps to a microservice boundary. Crossing context boundaries requires an explicit translation (anti-corruption layer).
- Each module has a public API (headers/interfaces) — no reaching into internals
- Modules do not share database tables across boundaries
- Cross-module calls are synchronous function calls — trivially refactorable to HTTP/gRPC later
- Modules can be extracted one at a time (Strangler Fig)
- Identify a bounded context to extract (e.g., Notifications)
- Build the new service alongside the monolith
- Route specific endpoints (
/notify/*) through the API Gateway to the new service - Monolith still handles everything else — both coexist
- Once new service is stable, remove the monolith's notification module
- Repeat for next module
| Module | Topic | Key Concepts |
|---|---|---|
| M15 (this) | Microservices & Infrastructure | Architecture decisions, API Gateway, circuit breaker, Docker, K8s, CI/CD |
| M16 | Service Mesh & Advanced Infra | Istio/Envoy, mTLS, traffic shaping, Helm, Terraform IaC |
| Dimension | Synchronous (REST/gRPC) | Asynchronous (Events/Queues) |
|---|---|---|
| Coupling | Temporal: caller blocks until callee responds | Loose: caller fires and continues |
| Latency | Fast for simple request/reply | Adds queuing delay (ms–seconds) |
| Failure propagation | Downstream failure cascades upstream | Broker buffers; caller unaffected by consumer down |
| Consistency | Immediate | Eventual |
| Observability | Easy: request trace follows call chain | Harder: events fan out; need correlation IDs |
| Best for | Queries, user-facing reads, RPC | Side effects (email, analytics, downstream processing) |
- Versioned endpoints:
/v1/orders— never break existing consumers - Idempotency keys on POST:
Idempotency-Key: {uuid}header - Pagination: cursor-based over offset (stable under inserts)
- Timeout headers:
Request-Timeout: 5000— avoid indefinite waits - Structured error responses:
{"error":"NOT_FOUND","message":"..."} - Health endpoints:
/health/live(process alive),/health/ready(dependencies healthy)
- Binary (Protobuf): smaller payload vs JSON, faster serialization
- Typed contracts:
.protofile is the source of truth — no schema drift - Streaming: server-side, client-side, and bidirectional streaming
- HTTP/2: multiplexed connections, header compression
- Code generation: auto-generated client/server stubs in any language
| Responsibility | How |
|---|---|
| Routing | Path-based: /orders/* → Order Service, /users/* → User Service |
| Auth offload | Validate JWT at gateway; forward X-User-Id header to services — services trust the header |
| Rate limiting | Token bucket per client IP or API key; return 429 Too Many Requests |
| SSL termination | HTTPS at gateway; plain HTTP on internal network (mTLS for higher security) |
| Request aggregation (BFF) | Backend For Frontend: gateway calls 3 services and merges response — saves mobile client from 3 round trips |
| Canary routing | Route 5% of traffic to new service version by header/cookie — gradual rollout |
| Observability | Add X-Request-Id header; log request/response at entry point |
- Service registers itself with registry on startup
- Client queries registry → gets list of healthy instances → client-side load balances (round-robin, etc.)
- More control but client must implement discovery logic
- Client sends request to load balancer
- LB queries registry and forwards to healthy instance
- Client is simple; LB handles all discovery
orders.default.svc.cluster.local). kube-proxy maintains iptables rules that load-balance across healthy Pods. Client just talks to the DNS name — no discovery library needed.
/* Each service: propagate to outgoing calls AND log every event */ log_info(“correlation_id=%s action=order_placed order_id=%s”, corr_id, order_id);
/* Each event published to Kafka: embed correlation_id in headers */ rd_kafka_headers_add(headers, “correlation_id”, strlen(“correlation_id”), corr_id, strlen(corr_id));
The four primary resilience patterns:
| Pattern | Problem Solved |
|---|---|
| Timeout | Don't wait forever — bound the worst case latency |
| Retry | Transient failures (network blip) often self-resolve — retry with backoff |
| Circuit Breaker | Stop calling a failing service — give it time to recover, fail fast to callers |
| Bulkhead | Isolate resource pools — one slow service can't exhaust all threads |
| Parameter | What It Controls | Guidance |
|---|---|---|
failure_threshold | N failures to trip OPEN | 5–10 over a rolling window (not total) |
failure_rate_threshold | % failure rate to trip (more robust than count) | 50% failure rate over last 20 requests |
open_timeout | How long to stay OPEN before probing | 30s–60s, or exponential backoff |
half_open_max_calls | Max probe calls in HALF-OPEN | 1–3 probes; don't flood recovering service |
slow_call_threshold | Calls slower than N ms count as failures | Set to 2× normal p99 latency |
| Fallback | What to return in OPEN state | Cached response, degraded response, or structured error |
In microservices: instead of one shared thread pool for all downstream calls, create separate thread pools per dependency:
/* Separate pools: payment can be slow without blocking inventory calls */ bulkhead_pool_t payment_pool = { .name=“payment”, .max_queue=50 }; bulkhead_pool_t inventory_pool = { .name=“inventory”, .max_queue=200 };
503 Service Unavailable for payment calls only. Inventory calls proceed normally — the bulkhead contains the failure.Solution: exponential backoff + random jitter:
/* Exponential: base_ms * 2^attempt, capped at 30s */ int cap = base_ms * (1 << attempt); if (cap > 30000) cap = 30000;
/* Full jitter: random in [0, cap] — spreads retries / int delay = rand() % (cap + 1); usleep(delay * 1000); } return -1; / all attempts failed */ }
/* Usage: retry up to 5 times, starting at 100ms base delay */ retry_with_backoff(call_payment_service, &ctx, 5, 100);
WORKDIR /build
# Install only build dependencies
RUN apt-get update && apt-get install -y —no-install-recommends
librdkafka-dev
libssl-dev
libpq-dev
cmake
&& rm -rf /var/lib/apt/lists/*
# Copy source COPY . .
# Compile — statically link where possible for portable binary
RUN cmake -DCMAKE_BUILD_TYPE=Release -B build .
&& cmake —build build —target order_service -j$(nproc)
############################################################
# Stage 2: Runtime — minimal image, just the binary
FROM debian:bookworm-slim
# Install only runtime libraries (no compilers, headers, or build tools)
RUN apt-get update && apt-get install -y —no-install-recommends
librdkafka1
libssl3
libpq5
ca-certificates
&& rm -rf /var/lib/apt/lists/*
# Security: run as non-root RUN useradd -r -s /bin/false appuser USER appuser
WORKDIR /app
# Copy only the compiled binary from the builder stage COPY —from=builder /build/build/order_service /app/order_service
# ENTRYPOINT: exec form — PID 1 gets signals properly (SIGTERM for graceful shutdown) ENTRYPOINT [“/app/order_service”]
# Default arguments (overridable at runtime) CMD [“—port=8080”]
- Pin image versions:
debian:bookworm-slimnotdebian:latest— reproducible builds - Non-root user:
useradd -r+USER appuser— container escape with root = host root - No secrets in image: use environment variables or secrets mounts, never
ARG PASSWORD(visible in layers) - COPY specific files:
COPY src/ /build/src/notCOPY . .— avoids copying.git, local configs - Read-only root filesystem:
--read-onlyflag — forces explicit volume mounts for writable paths - Health check:
HEALTHCHECK CMD curl -f http://localhost:8080/health/live || exit 1
- Running as root (default if no USER set)
- Using
latesttag — non-deterministic; breaks reproducibility - Building in a single stage — final image carries GCC, headers, build tools
- Putting secrets in environment variables that get logged
- Using CMD instead of ENTRYPOINT —
docker stopdoesn't send SIGTERM to PID 1 apt-get updatewithout&& apt-get installin same RUN — stale layer cache- Not adding a
.dockerignore— copiesnode_modules/,.git/, build artifacts
.git inclusion) slow down every build. A good .dockerignore is as important as the Dockerfile itself.terminationGracePeriodSeconds.
| Form | Shell | PID 1 | Gets SIGTERM? |
|---|---|---|---|
ENTRYPOINT ["/app/service"] (exec) | No | Your binary | ✅ Yes |
ENTRYPOINT /app/service (shell) | /bin/sh -c | sh | ❌ No (sh is PID 1) |
CMD ["/app/service"] (exec) | No | Your binary | ✅ Yes (if no ENTRYPOINT) |
ENTRYPOINT ["/app/service"]. In your C process, register a SIGTERM handler that drains connections and exits cleanly.| Object | Purpose | Key Fields |
|---|---|---|
| Pod | Smallest deployable unit: one or more containers sharing network/storage | spec.containers[].image, resources, env |
| Deployment | Declares desired state: N replicas of a Pod template; manages rolling updates and rollback | spec.replicas, spec.strategy, spec.template |
| Service | Stable DNS name + ClusterIP that load-balances across matching Pods (by label selector) | spec.selector, spec.ports, spec.type |
| Ingress | HTTP/S routing rules: hostname/path → Service; TLS termination | spec.rules[].host, spec.tls |
| ConfigMap | Non-sensitive configuration: mounted as env vars or files | data key-value pairs |
| Secret | Sensitive data (passwords, tokens): base64-encoded, encrypted at rest | data (base64), type |
| HPA | Horizontal Pod Autoscaler: scales replicas based on CPU/memory/custom metrics | spec.minReplicas, spec.maxReplicas, spec.metrics |
| Probe | Failure Action | Purpose |
|---|---|---|
| Liveness | Restart the container | Is the process alive? (Detects deadlocks, infinite loops) |
| Readiness | Remove from Service endpoints (stops traffic) | Is the process ready to serve? (DB connected, cache warm) |
| Startup | Restart if not ready within window | Slow-starting apps — disables liveness until startup complete |
- Update image:
kubectl set image deployment/order-service order-service=registry.example.com/order-service:1.4.3 - Monitor rollout:
kubectl rollout status deployment/order-service - Rollback to previous:
kubectl rollout undo deployment/order-service - Rollback to specific revision:
kubectl rollout undo deployment/order-service --to-revision=2
minAvailable: 2 — cluster autoscaler and rolling updates respect this; never takes down so many pods that fewer than 2 are available.- 1Lint & Static Analysis — clang-tidy, cppcheck, clang-format check. Fail fast: bad code never reaches tests. (~30s)
- 2Unit Tests — fast, isolated tests with mocked dependencies. Target: >80% coverage on core business logic. (~2m)
- 3Integration Tests — spin up Postgres, Kafka, Redis via Docker Compose; test real service behavior against real dependencies. (~5m)
- 4Security Scan — Trivy scans for CVEs in base image and dependencies; Semgrep for security antipatterns in code. Block on HIGH/CRITICAL CVEs.
- 5Build OCI Image — multi-stage Docker build. Tag with git SHA:
registry/service:abc1234. SHA tags are immutable — never use:latestin production. - 6Push to Registry — push to container registry. Sign image with cosign for supply chain security.
- 7Deploy to Staging —
kubectl set imageor Helm upgrade. Run smoke tests against staging URL. - 8Deploy to Production — manual approval gate (or auto on green staging). Blue-green or canary rollout. Monitor error rate + latency for 10 minutes.
- Deploy new version to green environment
- Run smoke tests on green (not receiving production traffic)
- Switch load balancer to point to green (instant cutover)
- Blue environment kept running for instant rollback
- After confidence period, decommission blue
Cons: requires 2× infrastructure during transition
- Deploy new version alongside old; route 5% of traffic to it
- Monitor error rate, latency, business metrics (conversion rate)
- If healthy after 10m: increase to 20% → 50% → 100%
- If issues: instant rollback by routing 100% back to old version
Cons: two versions run simultaneously — must be API-compatible
jobs: build-test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test POSTGRES_DB: testdb options: >- —health-cmd pg_isready —health-interval 10s steps: - uses: actions/checkout@v4 - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y libpq-dev librdkafka-dev clang-tidy cppcheck - name: Configure run: cmake -B build -DCMAKE_BUILD_TYPE=Debug -DENABLE_TESTS=ON - name: Build run: cmake —build build -j{{ github.sha }} . docker push registry.example.com/order-service:{{ github.sha }} kubectl rollout status deployment/order-service —timeout=5m
| # | Factor | Rule | C Implementation |
|---|---|---|---|
| 1 | Codebase | One codebase per service, tracked in version control | One git repo per service; main deploys to production |
| 2 | Dependencies | Declare and isolate all dependencies explicitly | CMakeLists.txt pins exact library versions; no implicit system libraries |
| 3 | Config | Config in environment, not in code | getenv("DATABASE_URL") — never hardcode DSN/passwords |
| 4 | Backing Services | Treat DB, cache, broker as attached resources | URL from env — swap Postgres for RDS without code change |
| 6 | Processes | Execute app as one or more stateless processes | No in-process session state; sessions in Redis |
| 7 | Port Binding | Export services via port binding, not app server injection | Service binds $PORT itself; Kubernetes routes to it |
| 8 | Concurrency | Scale out via the process model | Multiple replicas (K8s replicas: N), not threads-per-monolith |
| 9 | Disposability | Maximize robustness with fast startup and graceful shutdown | Handle SIGTERM: drain connections, flush buffers, exit 0 |
| 11 | Logs | Treat logs as event streams — write to stdout | fprintf(stdout, "...") — never write to files inside container |
| 12 | Admin Processes | Run admin/management tasks as one-off processes | DB migrations as a separate Job (Kubernetes Job), not in service startup |
/* Do this: */ const char *db_url = getenv(“DATABASE_URL”); if (!db_url) { fprintf(stderr, “DATABASE_URL not set\n”); exit(1); }
static void handle_sigterm(int sig) { (void)sig; shutting_down = 1; }
int main() { signal(SIGTERM, handle_sigterm); signal(SIGINT, handle_sigterm);
while (!shutting_down) { /* serve requests */ }
/* Graceful shutdown: drain connections / drain_active_connections(); rd_kafka_flush(rk, 10000); / flush pending events */ PQfinish(pg_conn); fprintf(stdout, “Shutdown complete\n”); return 0; }
/* Usage */ LOG_INFO(“order_placed order_id=%s user_id=%s amount=%.2f”, order_id, user_id, amount);
timestamp, level, service, correlation_id, and the event. This makes logs searchable and correlatable across services in your log aggregator.typedef enum { CB_CLOSED, CB_OPEN, CB_HALF_OPEN } cb_state_t;
typedef struct { _Atomic(int) state; /* cb_state_t / _Atomic(int) failure_count; _Atomic(long) open_since_ms; / epoch ms when opened */ int failure_threshold; long open_timeout_ms; } circuit_breaker_t;
static inline long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL; }
static inline void cb_init(circuit_breaker_t *cb, int threshold, long timeout_ms) { atomic_store(&cb->state, CB_CLOSED); atomic_store(&cb->failure_count, 0); atomic_store(&cb->open_since_ms, 0); cb->failure_threshold = threshold; cb->open_timeout_ms = timeout_ms; }
/* Returns true if the call should be allowed through */ static inline bool cb_allow(circuit_breaker_t *cb) { int state = atomic_load(&cb->state);
if (state == CB_CLOSED) return true;
if (state == CB_OPEN) { long elapsed = now_ms() - atomic_load(&cb->open_since_ms); if (elapsed >= cb->open_timeout_ms) { /* Transition to HALF_OPEN to probe recovery / int expected = CB_OPEN; if (atomic_compare_exchange_strong(&cb->state, &expected, CB_HALF_OPEN)) { return true; / this thread gets the probe request / } } return false; / still open */ }
/* HALF_OPEN: allow one probe at a time */ return true; }
static inline void cb_on_success(circuit_breaker_t cb) { int state = atomic_load(&cb->state); if (state == CB_HALF_OPEN) { / Recovery confirmed: close the breaker / atomic_store(&cb->failure_count, 0); atomic_store(&cb->state, CB_CLOSED); } if (state == CB_CLOSED) { / Reset failure count on success */ atomic_store(&cb->failure_count, 0); } }
static inline void cb_on_failure(circuit_breaker_t cb) { int state = atomic_load(&cb->state); if (state == CB_HALF_OPEN) { / Probe failed: reopen the breaker */ atomic_store(&cb->open_since_ms, now_ms()); atomic_store(&cb->state, CB_OPEN); return; } int count = atomic_fetch_add(&cb->failure_count, 1) + 1; if (count >= cb->failure_threshold) { int expected = CB_CLOSED; if (atomic_compare_exchange_strong(&cb->state, &expected, CB_OPEN)) { atomic_store(&cb->open_since_ms, now_ms()); fprintf(stderr, “[CB] Circuit OPENED after %d failures\n”, count); } } }
/* Usage */ int call_payment_service(void ctx) { return 0; } / placeholder */
circuit_breaker_t payment_cb;
int charge_customer(const char order_id, double amount) { if (!cb_allow(&payment_cb)) { fprintf(stderr, “[CB] OPEN: payment service unavailable\n”); return -1; / fail fast */ }
int result = call_payment_service(NULL); if (result == 0) cb_on_success(&payment_cb); else cb_on_failure(&payment_cb);
return result; }
static volatile int ready = 0; /* set to 1 once DB connected etc. */ static void *health_thread(void *arg) { (void)arg; int srv = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = { .sin_family = AF_INET, .sin_addr.s_addr = INADDR_ANY, .sin_port = htons(8081) }; bind(srv, (struct sockaddr *)&addr, sizeof(addr)); listen(srv, 16);
while (1) { int conn = accept(srv, NULL, NULL); if (conn < 0) continue;
char buf[256]; ssize_t n = recv(conn, buf, sizeof(buf) - 1, 0); buf[n > 0 ? n : 0] = ‘\0’;
const char *resp; if (strstr(buf, “GET /health/live”)) { resp = “HTTP/1.1 200 OK\r\nContent-Length:2\r\n\r\nOK”; } else if (strstr(buf, “GET /health/ready”)) { resp = ready ? “HTTP/1.1 200 OK\r\nContent-Length:5\r\n\r\nREADY” : “HTTP/1.1 503 Service Unavailable\r\nContent-Length:12\r\n\r\nNOT_READY_YET”; } else { resp = “HTTP/1.1 404 Not Found\r\nContent-Length:0\r\n\r\n”; }
send(conn, resp, strlen(resp), <span class="cn">0</span>);
close(conn);
}
return NULL; }
void start_health_server(void) { pthread_t t; pthread_create(&t, NULL, health_thread, NULL); pthread_detach(t); }
void set_ready(int r) { ready = r; }
/health/live probe succeeds immediately. Set ready=1 only after all dependencies (DB, Kafka) are connected — this keeps the pod out of the Service load balancer until it's actually ready.FROM gcc:13. Build it: docker build -t service:single-stage .. Check size: docker image ls service:single-stage.docker build -t service:multi-stage .. Compare sizes.docker run --rm service:multi-stage. Verify the binary executes correctly in the slim image.docker history service:multi-stage — verify no build tools (gcc, make) appear in any layer of the final image.docker run --user $(id -u) service:multi-stage — verify non-root execution. Check process inside container: docker exec <id> id.minikube image load service:multi-stage.kubectl get pods -w.ready=1 by 10 seconds. Watch the pod stay NotReady during startup./health/live endpoint to return 503 after receiving 5 requests. Observe Kubernetes restart the pod.kubectl rollout status deployment/order-service.kubectl rollout undo deployment/order-service. Verify the previous image is running./orders/*, /users/*, and /notifications/* all in one process./notifications/*./notifications/* to the new service, all other paths to the monolith./orders/123 hit the monolith. Requests to /notifications/send hit the new service. Both return correct responses.- Explain Conway's Law and how it drives service boundaries
- Describe the Strangler Fig migration pattern step by step
- Compare sync REST/gRPC vs async events for inter-service communication
- List 5 responsibilities of an API Gateway
- Explain client-side vs server-side service discovery
- Draw the circuit breaker state machine (CLOSED/OPEN/HALF-OPEN)
- Implement retry with exponential backoff + jitter
- Explain the bulkhead pattern and when to apply it
- Set correct timeout values relative to circuit breaker thresholds
- Write a multi-stage Dockerfile for a C binary
- Explain why non-root + exec-form ENTRYPOINT matters
- Write a Deployment with liveness + readiness probes
- Explain the difference between liveness and readiness probes
- Perform a rolling update and rollback with kubectl
- List the 8 stages of a production CI/CD pipeline
- Explain blue-green vs canary deployment trade-offs
- Apply 12-Factor principles: config in env, logs to stdout, graceful shutdown
- Write structured JSON logging and graceful SIGTERM handling in C