The previous lesson ended with a tally: six services, three data stores, Kafka, Redis, MinIO, Kong, Keycloak, Vault, Prometheus, Loki, Tempo, Patroni, etcd. Dozens of containers, hundreds of parameters, and a docker-compose.yml that no longer fits on one screen. As long as all of that is deployed the way the monolith of 01-06 was (on Tuesdays and Thursdays, with an ssh session, a git pull and a list of steps in a document that somebody follows by hand), the platform has two problems that no resilience pattern will fix: it is not reproducible (nobody knows exactly what is on inventory-2 or why it differs from inventory-1) and it depends on one person not making a mistake on a Thursday at 18:00. This lesson is about taking that person off the critical path: first by describing the infrastructure as code (Ansible to configure machines; immutable images for the services), and then by delegating to an orchestrator, Kubernetes, the decisions that somebody makes by hand today: where each replica runs, how many are needed, what to do when one dies, and how to take a new version to production without Anna noticing. The tests that give you the confidence to automate are lesson 07-06; managed cloud services are 08-03.

Contents

  1. Why automate
  2. Infrastructure as code: configuration versus provisioning
  3. Ansible: inventory, playbooks, roles and idempotency
  4. Immutability: images, registry and per-commit tags
  5. Kubernetes: why an orchestrator and how it is built
  6. Essential objects
  7. Kilometre Zero's manifests: k8s/
  8. Deployment strategies: rolling, blue/green, canary
  9. Namespaces, RBAC, service mesh and operators
  10. GitOps and CI/CD
  11. docker-compose versus Kubernetes
  12. Common mistakes and tips
  13. Exercises and solutions
  14. Conclusion

  1. Why automate

Three reasons, and all three have already appeared in the course without being named:

  • Reproducibility. In 07-03 we said that redundancy only protects against independent failures, and that three "identical" replicas configured by hand never quite are. If inventory-2 was created by copying inventory-1 and editing "what was needed", there are differences nobody remembers, and one of them will be the cause of an incident. Automating means the description is the only source: a node can be destroyed and recreated identically.
  • Scale. With 3 replicas you can do it by hand; with 30, during Grape Harvest Week, you cannot. And "by hand" includes deciding how many are needed: autoscaling is the automation of a decision, not just of a task.
  • Human error. The DELETE of 07-03, the Saturday deployment of 07-01 with 8% errors, the certificate that expired on a Sunday: what the three have in common is that somebody did something by hand, or failed to. Automation does not eliminate mistakes (a wrong script is wrong 30 times on 30 nodes), but it makes them reviewable beforehand (code in a pull request), testable (07-06) and reversible (rollout undo).

The monolith of 01-06 was deployed inside a maintenance window; the goal here is for a deployment of orders to be such a routine event that it happens several times a day without anyone noticing, Anna included.

  1. Infrastructure as code: configuration versus provisioning

Infrastructure as code (IaC) means treating servers, networks, configuration and deployments as files in a repository: versioned, reviewed and applied by a tool. Two families that should not be confused:

Configuration management Provisioning
Question "Given this machine, what should it have installed and configured?" "Which machines, networks, disks and load balancers should exist?"
Tools Ansible, Puppet, Chef, Salt Terraform, OpenTofu, Pulumi, CloudFormation
Model Procedural-idempotent: tasks that bring the machine to the desired state Declarative: describes the end state and computes the difference
At Kilometre Zero Install and configure the Kafka, Cassandra and Patroni nodes; prepare the Kubernetes cluster nodes Create the virtual machines, the network and the load balancer at the provider (08-03)

Ansible is the one this lesson covers, because it configures what already exists (Kilometre Zero's physical or virtual machines) and because its central concept, task idempotency, is the same one that governs the retries of 02-05 and 07-04. Terraform comes in 08-03, once the infrastructure becomes a cloud API.

  1. Ansible: inventory, playbooks, roles and idempotency

Ansible needs no agent: it connects over SSH to every machine in the inventory, runs modules (small Python programs that know how to install packages, write files, manage services) and reports whether anything changed. A playbook is a YAML file saying which tasks to apply to which group of machines; a role is a packaged, reusable playbook (tasks, templates, default variables, handlers).

# km0/ansible/inventory.ini
[kafka]
kafka-1 ansible_host=10.10.1.11 kafka_id=1 zone=bcn
kafka-2 ansible_host=10.10.2.11 kafka_id=2 zone=vlc
kafka-3 ansible_host=10.10.3.11 kafka_id=3 zone=gir

[cassandra]
cass-1 ansible_host=10.10.1.21 zone=bcn
cass-2 ansible_host=10.10.2.21 zone=vlc
cass-3 ansible_host=10.10.3.21 zone=gir

[patroni]
inv-bcn ansible_host=10.10.1.31
inv-vlc ansible_host=10.10.2.31
inv-gir ansible_host=10.10.3.31

[k8s_control]
k8s-cp-1 ansible_host=10.10.1.41
[k8s_workers]
k8s-w-[1:6] ansible_host=10.10.1.5[1:6]

[all:vars]
ansible_user=km0ops
ansible_ssh_private_key_file=~/.ssh/km0ops

The Kafka playbook delegates to a role and passes in the cluster variables:

# km0/ansible/kafka.yml
- name: Configure Kilometre Zero's Kafka brokers
  hosts: kafka
  become: true                     # sudo to install packages and touch /etc
  serial: 1                        # ONE broker at a time: never two brokers restarting together (ISR, 07-03)
  vars:
    kafka_version: "3.8.0"
    kafka_cluster_id: "km0-kafka-prod"
    kafka_controller_quorum: "1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093"
    kafka_default_replication_factor: 3
    kafka_min_insync_replicas: 2
    kafka_log_retention_hours: 168
  roles:
    - kafka

And the role, with carefully idempotent tasks:

# km0/ansible/roles/kafka/tasks/main.yml
- name: System user for Kafka
  ansible.builtin.user:
    name: kafka
    system: true
    shell: /usr/sbin/nologin
  # 'user' module: if the user exists with these attributes, it does nothing (changed=false)

- name: Java 17
  ansible.builtin.apt:
    name: openjdk-17-jre-headless
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Download and unpack Kafka {{ kafka_version }}
  ansible.builtin.unarchive:
    src: "https://archive.apache.org/dist/kafka/{{ kafka_version }}/kafka_2.13-{{ kafka_version }}.tgz"
    dest: /opt
    remote_src: true
    creates: "/opt/kafka_2.13-{{ kafka_version }}"     # if it already exists, no download: idempotent
    owner: kafka
    group: kafka

- name: Link /opt/kafka to the active version
  ansible.builtin.file:
    src: "/opt/kafka_2.13-{{ kafka_version }}"
    dest: /opt/kafka
    state: link

- name: Data directory on the dedicated disk
  ansible.builtin.file:
    path: /var/lib/kafka
    state: directory
    owner: kafka
    group: kafka
    mode: "0750"

- name: Broker configuration (KRaft)
  ansible.builtin.template:
    src: server.properties.j2
    dest: /opt/kafka/config/kraft/server.properties
    owner: kafka
    group: kafka
    mode: "0640"
  notify: restart kafka            # the handler only fires if the file CHANGED

- name: Broker certificate and key from Vault (06-04)
  community.hashi_vault.vault_pki_generate_certificate:
    engine_mount_point: pki_int
    role_name: kafka-broker
    common_name: "{{ inventory_hostname }}.km0.internal"
    ttl: 720h
  register: cert
  changed_when: false               # generating a cert is not "changing the machine"; the file below is
  no_log: true                      # never print the private key in the output

- name: Write the broker keystore
  ansible.builtin.copy:
    content: "{{ cert.data.data.certificate }}\n{{ cert.data.data.private_key }}"
    dest: /etc/kafka/broker.pem
    owner: kafka
    mode: "0600"
  no_log: true
  notify: restart kafka

- name: Format the KRaft storage (first time only)
  ansible.builtin.command:
    cmd: /opt/kafka/bin/kafka-storage.sh format -t {{ kafka_cluster_id }} -c /opt/kafka/config/kraft/server.properties
    creates: /var/lib/kafka/meta.properties     # if it exists, it is already formatted: NEVER repeated
  become_user: kafka

- name: systemd unit
  ansible.builtin.template:
    src: kafka.service.j2
    dest: /etc/systemd/system/kafka.service
  notify: restart kafka

- name: Kafka enabled and started
  ansible.builtin.systemd:
    name: kafka
    state: started
    enabled: true
    daemon_reload: true

- name: Wait for the broker to accept connections before moving to the next one
  ansible.builtin.wait_for:
    port: 9092
    host: "{{ ansible_host }}"
    timeout: 120
# km0/ansible/roles/kafka/handlers/main.yml
- name: restart kafka
  ansible.builtin.systemd:
    name: kafka
    state: restarted
{# km0/ansible/roles/kafka/templates/server.properties.j2 #}
process.roles=broker,controller
node.id={{ kafka_id }}
controller.quorum.voters={{ kafka_controller_quorum }}
listeners=SSL://:9092,CONTROLLER://:9093
broker.rack={{ zone }}
log.dirs=/var/lib/kafka
default.replication.factor={{ kafka_default_replication_factor }}
min.insync.replicas={{ kafka_min_insync_replicas }}
unclean.leader.election.enable=false
log.retention.hours={{ kafka_log_retention_hours }}
ssl.keystore.type=PEM
ssl.keystore.location=/etc/kafka/broker.pem
ssl.truststore.location=/etc/kafka/ca.pem
ssl.client.auth=required

Idempotency is in every detail: creates: avoids repeated downloads and, above all, avoids re-formatting a broker that holds data (a kafka-storage.sh format on a production broker destroys it); template only notifies the handler if the content changed, so running the playbook ten times in a row restarts Kafka zero times; serial: 1 with a wait_for at the end makes a configuration rollout walk the brokers one at a time, honouring the ISRs of 07-03. Running ansible-playbook -i inventory.ini kafka.yml --check --diff shows what would change without changing anything: the prior review that the Tuesday ssh never had. The same structure (a cassandra role with nodetool drain before restarting, a patroni role with patronictl switchover if the node is the leader) applies to the rest of the stateful machines.

  1. Immutability: images, registry and per-commit tags

Ansible configures machines that change. For the services under km0/ we take the opposite path: immutable infrastructure. A service is not "updated": a new container image is built with everything inside (interpreter, pinned dependencies, code, default configuration), pushed to a registry, and deployed by replacing the old containers with new ones. Nobody ever sshes into a container to fix something; if something is wrong, another image is built.

# km0/services/orders/Dockerfile
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
RUN useradd --system --uid 10001 km0

FROM base AS deps
COPY services/orders/requirements.lock .          # exact versions (06-05: pinned dependencies)
RUN pip install --no-cache-dir -r requirements.lock

FROM deps AS final
COPY services/common /app/services/common
COPY services/orders /app/services/orders
COPY contracts /app/contracts
USER km0                                           # never root inside the container
EXPOSE 8000
ENTRYPOINT ["uvicorn", "services.orders.app:app", "--host", "0.0.0.0", "--port", "8000"]

The image tag is the key to reproducibility: registry.km0.internal/km0/orders:1.14.2-3f9a1c7 (semantic version + short commit hash). Never latest: latest is a moving pointer, and "which version is in production" must have an exact answer. The same image travels through tests, staging and production; what changes between environments is the injected configuration (environment variables, ConfigMap, Secret), not the image. That is the "build once, deploy many" principle.

  1. Kubernetes: why an orchestrator and how it is built

With immutable images, what remains is deciding where each container runs, how many there are, what happens when one dies or a node goes down, how they find each other and how they are replaced by the new version. In docker-compose a person decides all of that, on a single node. An orchestrator decides it continuously, across a cluster:

Need What the person did What Kubernetes does
Scheduling "orders-3 on node 2, which has room" The scheduler places each Pod according to resources, affinities and zones
Autoscaling "Harvest week: I'll go up to 6 replicas on Friday" HPA adjusts replicas based on CPU or a Prometheus metric (Kafka lag)
Self-healing TargetDown alert, ssh, docker restart Restarts on liveness; recreates Pods on another node if the node dies
Progressive rollout Maintenance window Rolling update with readiness; canary; rollout undo
Discovery and load balancing Edit prometheus.yml and Kong's config with every IP Service with a stable DNS name and balancing across ready Pods
Configuration and secrets Files copied by hand ConfigMap, Secret, Vault integration
flowchart TB
    subgraph cp["Control plane"]
        API[kube-apiserver<br/>the only front door]
        ETCD[(etcd<br/>desired and current state)]
        SCH[kube-scheduler<br/>picks a node for each Pod]
        CM[controller-manager<br/>loops: Deployment, ReplicaSet, HPA...]
        API <--> ETCD
        SCH --> API
        CM --> API
    end
    subgraph w1["Worker node 1 (zone bcn)"]
        K1[kubelet] --> P1[Pod orders-7d9f-abc<br/>+ Envoy sidecar]
        K1 --> P2[Pod catalog-5c1e-xyz]
        KP1[kube-proxy]
    end
    subgraph w2["Worker node 2 (zone vlc)"]
        K2[kubelet] --> P3[Pod orders-7d9f-def]
        K2 --> P4[Pod inventory-0<br/>StatefulSet]
        KP2[kube-proxy]
    end
    API --> K1
    API --> K2
    OPS[kubectl apply -f k8s/<br/>Argo CD] --> API
    PROM[Prometheus<br/>service discovery] --> API

The idea that explains everything is the reconciliation loop: the user declares the desired state in the API ("3 replicas of orders:1.14.2"), etcd stores it (the same etcd as in 03-03: Kubernetes is, at bottom, a set of controllers on top of a consensus store), and every controller continuously compares the desired state with the actual one and acts to bring them closer: if there are 2 Pods and 3 are wanted, it creates one; if node 1 disappears, its Pods are recreated elsewhere. There is no "deploy command"; there is "change the desired state" and wait for the controllers to converge. It is the same philosophy as Ansible's idempotency, but running non-stop.

  1. Essential objects

Object What it is At Kilometre Zero
Pod The smallest unit: one or more containers sharing network and storage; ephemeral, with its own IP An orders container + the mesh's Envoy sidecar
ReplicaSet Keeps N identical Pods alive Created by the Deployment; never written by hand
Deployment Manages ReplicaSets for stateless services: replicas, Pod template, update strategy, history for undo orders, catalog, payments, delivery, analytics, Kong
StatefulSet Pods with a stable identity (inventory-0, -1, -2), their own persistent disk and ordered start-up Cassandra, Kafka, PostgreSQL (although in production operators are preferred, section 9)
Service Stable DNS name and IP that balance towards the ready Pods matching a selector orders.km0.svc.cluster.local:8000
Ingress HTTP/HTTPS entry rule from outside the cluster towards Services Exposes Kong; Kong does the rest (06-05)
ConfigMap Non-secret configuration (files, variables) prometheus.yml, KM0_TRACES_RATIO
Secret Sensitive data, base64-encoded (and encrypted in etcd if so configured) Vault credentials (everything else is issued dynamically by Vault, 06-04)
Job / CronJob A Pod that runs to completion; CronJob schedules it nightly_reconciliation.py (07-03), base_backup.sh, restore test
HPA Horizontal Pod Autoscaler: adjusts a Deployment's replicas based on metrics orders by CPU; analytics by Kafka lag
PersistentVolumeClaim A request for disk that outlives the Pod Each Cassandra node's data
Namespace Logical partition of the cluster: names, quotas, permissions km0-prod, km0-staging, observability

  1. Kilometre Zero's manifests: k8s/

k8s/orders-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders
  namespace: km0-prod
  labels: {app: orders, team: commerce}
spec:
  replicas: 3
  revisionHistoryLimit: 10              # how many old ReplicaSets to keep for `rollout undo`
  selector:
    matchLabels: {app: orders}
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                       # at most 1 extra Pod during the update (4 in total)
      maxUnavailable: 0                 # never fewer than 3 ready: capacity does not drop
  template:
    metadata:
      labels: {app: orders, version: "1.14.2"}
      annotations:
        prometheus.io/scrape: "true"    # Prometheus (07-01) discovers the Pod through this annotation
        prometheus.io/port: "8000"
        prometheus.io/path: /metrics
    spec:
      serviceAccountName: orders        # the Pod's identity: for RBAC and for authenticating to Vault
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
      topologySpreadConstraints:        # spread the replicas across zones (07-03: failure domains)
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector: {matchLabels: {app: orders}}
      containers:
        - name: orders
          image: registry.km0.internal/km0/orders:1.14.2-3f9a1c7
          ports: [{containerPort: 8000, name: http}]
          env:
            - name: KM0_SERVICE
              value: orders
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: otel-collector.observability.svc:4317
          envFrom:
            - configMapRef: {name: orders-config}       # KM0_LOG_LEVEL, KM0_TRACES_RATIO...
            - secretRef: {name: orders-vault}           # VAULT_ROLE_ID / VAULT_SECRET_ID (AppRole, 06-04)
          resources:
            requests: {cpu: "250m", memory: "256Mi"}    # what the scheduler reserves: the basis for the HPA
            limits: {cpu: "1", memory: "512Mi"}         # ceiling: above it, CPU throttling / OOMKilled
          startupProbe:                                 # 07-03: while starting up, do not apply liveness
            httpGet: {path: /health/live, port: http}
            failureThreshold: 30
            periodSeconds: 2                            # up to 60 s to load certificates from Vault
          livenessProbe:
            httpGet: {path: /health/live, port: http}
            periodSeconds: 10
            failureThreshold: 3                         # 30 s without answering: restart
          readinessProbe:
            httpGet: {path: /health/ready, port: http}
            periodSeconds: 5
            failureThreshold: 2                         # 10 s with PostgreSQL/Kafka down: out of the Service
            successThreshold: 1
          lifecycle:
            preStop:
              exec: {command: ["sleep", "5"]}           # give the Service time to stop sending traffic
      terminationGracePeriodSeconds: 30                 # SIGTERM, then 30 s to finish in-flight requests

Every block answers something seen earlier: the probes are those of 07-03 with their parameters; requests feeds the scheduler and the HPA; limits is a per-Pod resource bulkhead (07-04); maxUnavailable: 0 guarantees that a rollout never reduces capacity; topologySpreadConstraints spreads across zones; preStop + terminationGracePeriodSeconds are the graceful shutdown (a 400 ms request is not cut off halfway). And envFrom: secretRef gives the Pod only the bare minimum to introduce itself to Vault, which is what hands out dynamic credentials and certificates.

k8s/orders-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: orders
  namespace: km0-prod
spec:
  selector: {app: orders}           # any Pod with this label and readiness OK receives traffic
  ports:
    - name: http
      port: 8000
      targetPort: http
  type: ClusterIP                   # only reachable inside the cluster: Kong is the only entry point

Kong (inside the cluster) routes /api/v1/orders to http://orders.km0-prod.svc:8000. When a Pod fails readiness, it disappears from the Service's endpoints within seconds; when a new version is rolled out, the new Pods only join once they are ready. Nobody edits an IP.

k8s/orders-hpa.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: orders
  namespace: km0-prod
spec:
  scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: orders}
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: {type: Utilization, averageUtilization: 60}   # 60% of the CPU `requests`
    - type: External                                            # Prometheus metric via prometheus-adapter
      external:
        metric:
          name: kafka_consumergroup_lag_sum
          selector: {matchLabels: {consumergroup: orders-saga, topic: orders.events}}
        target: {type: AverageValue, averageValue: "2000"}     # 2,000 messages of lag per replica
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies: [{type: Pods, value: 3, periodSeconds: 60}]    # at most +3 Pods per minute
    scaleDown:
      stabilizationWindowSeconds: 300                          # wait 5 min before scaling down: avoid flapping
      policies: [{type: Percent, value: 25, periodSeconds: 60}]

The HPA takes the maximum of what the metrics ask for: if CPU says 5 replicas and lag says 8, it scales to 8. The external metric comes from the kafka_exporter of 07-01 through prometheus-adapter, which publishes it in the Kubernetes metrics API. The stabilisation windows are the equivalent of the alerts' for:: scale up fast, scale down slowly.

k8s/inventory-statefulset.yaml (abridged)

apiVersion: apps/v1
kind: StatefulSet
metadata: {name: inventory-db, namespace: km0-prod}
spec:
  serviceName: inventory-db-headless       # per-Pod DNS: inventory-db-0.inventory-db-headless
  replicas: 3
  selector: {matchLabels: {app: inventory-db}}
  template:
    metadata: {labels: {app: inventory-db}}
    spec:
      containers:
        - name: postgres
          image: ghcr.io/zalando/spilo-16:3.3-p1       # PostgreSQL + Patroni (07-03)
          env:
            - name: SCOPE
              value: km0-inventory
            - name: KUBERNETES_USE_CONFIGMAPS          # Patroni uses the Kubernetes API as its consensus store
              value: "true"
          volumeMounts: [{name: data, mountPath: /home/postgres/pgdata}]
  volumeClaimTemplates:                     # each Pod gets ITS OWN disk; it survives restarts and rescheduling
    - metadata: {name: data}
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: replicated-ssd
        resources: {requests: {storage: 200Gi}}

A StatefulSet provides what a database needs and a Deployment does not: stable names (inventory-db-0 is always the same one, with the same disk), ordered start-up and shutdown, and one volume per replica. In production, however, the usual practice is to delegate to an operator (section 9).

k8s/ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: km0-edge
  namespace: km0-prod
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod   # public ACME certificate, renewed automatically
spec:
  ingressClassName: nginx
  tls:
    - hosts: [api.km0.example]
      secretName: api-km0-tls
  rules:
    - host: api.km0.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: {name: kong-proxy, port: {number: 443}}

The Ingress only carries traffic as far as Kong; everything from 06-05 (JWT, rate limiting, OpenAPI, X-Request-Id) still happens in Kong. With cert-manager, the certificate that used to expire on a Sunday now renews itself, and the CertificateExpiringSoon alert of 07-01 keeps watch that it does.

Operating with kubectl

# Try it locally: a single-node cluster in Docker (kind) or a VM (minikube)
kind create cluster --name km0 --config k8s/local/kind.yaml
kubectl config use-context kind-km0

# Apply the desired state (idempotent: repeating it changes nothing if it is already so)
kubectl apply -f k8s/ -n km0-prod
kubectl get pods -n km0-prod -w                   # -w: watch the Pods change in real time
# NAME                       READY   STATUS    RESTARTS   AGE
# orders-7d9f6c4b8-abcde     2/2     Running   0          40s
# orders-7d9f6c4b8-fghij     2/2     Running   0          40s
# orders-7d9f6c4b8-klmno     1/2     Running   0          12s     <- sidecar ready, app still in startupProbe

# Rolling out a new version = change the image in the manifest and apply (or, to try it out, inline)
kubectl set image deployment/orders orders=registry.km0.internal/km0/orders:1.15.0-8b2d4e1 -n km0-prod
kubectl rollout status deployment/orders -n km0-prod
# Waiting for deployment "orders" rollout to finish: 1 out of 3 new replicas have been updated...
# deployment "orders" successfully rolled out

# The Saturday of 07-01 (8% errors after deploying): roll back in seconds
kubectl rollout undo deployment/orders -n km0-prod
kubectl rollout history deployment/orders -n km0-prod

kubectl describe pod orders-7d9f6c4b8-klmno -n km0-prod     # events: why it will not start, failed probes
kubectl logs -f deployment/orders -c orders -n km0-prod     # (in production, Loki; this is for local use)
kubectl scale deployment/orders --replicas=6 -n km0-prod    # manual; the HPA will override it

  1. Deployment strategies: rolling, blue/green, canary

Strategy How Extra capacity Exposure risk Rollback When
Rolling update Replace Pods one at a time (or maxSurge at a time), waiting for readiness maxSurge All traffic sees the new version progressively; if the bug is subtle, it reaches 100% rollout undo (seconds, but the damage is done) Default for small changes with good tests
Blue/green Two complete environments; the Service points at one; the selector is switched in one go 100% (two copies) Zero until the switch; 100% afterwards Switch the selector back (instant) Big changes that must be atomic (schema + code)
Canary New version with a fraction of the traffic (1%, 10%, 50%); the SLIs (07-01) are observed; advance or abort Small Only the canary fraction Set the weight to 0 Changes of unknown risk; the norm for critical services

A canary with plain Kubernetes is done with two Deployments behind the same Service; the split is proportional to the number of Pods (1 canary out of 10 = 10%):

# k8s/orders-canary.yaml: parallel Deployment with the new version
apiVersion: apps/v1
kind: Deployment
metadata: {name: orders-canary, namespace: km0-prod}
spec:
  replicas: 1                                # 1 out of 4 Pods labelled app=orders => ~25% of the traffic
  selector: {matchLabels: {app: orders, track: canary}}
  template:
    metadata:
      labels: {app: orders, track: canary, version: "1.15.0"}   # app=orders: the Service includes it
    spec:
      # ... identical to the stable Deployment except for the image
      containers:
        - name: orders
          image: registry.km0.internal/km0/orders:1.15.0-8b2d4e1

For fine-grained weights (1%) independent of the number of Pods, you use the capabilities of the mesh or the ingress (Istio VirtualService with weight: 1/99; Kong with weighted upstreams) and tools such as Argo Rollouts or Flagger that automate the analysis: they query Prometheus for the error rate and the p99 of the canary version (the version label on the metrics of 07-01) and advance or revert on their own according to the SLO. On the Saturday of 07-01, with a 10% canary analysed automatically, the 8% error rate would have affected less than 1% of orders for two minutes.

  1. Namespaces, RBAC, service mesh and operators

Namespaces separate environments and teams within the same cluster: km0-prod, km0-staging, observability, mesh. Each with a ResourceQuota (total CPU and memory) and a LimitRange (defaults for Pods without resources), and with Kubernetes RBAC (distinct from the application RBAC of 06-01, same model): who may do what on which objects.

# k8s/rbac-operators.yaml: Jordan and Martha can see everything and restart Deployments in prod, not delete Secrets
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: operator, namespace: km0-prod}
rules:
  - apiGroups: ["", "apps"]
    resources: [pods, pods/log, deployments, replicasets, services, configmaps]
    verbs: [get, list, watch]
  - apiGroups: ["apps"]
    resources: [deployments]
    verbs: [patch]                     # rollout restart / undo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: operators, namespace: km0-prod}
subjects:
  - {kind: Group, name: km0-operators, apiGroup: rbac.authorization.k8s.io}   # group from Keycloak's OIDC (06-03)
roleRef: {kind: Role, name: operator, apiGroup: rbac.authorization.k8s.io}

Pods have an identity too (ServiceAccount), and with it they authenticate to Vault (06-04) and to the API if they need to; the principle is the same least privilege.

Service mesh. In 07-04 we saw Envoy as a sidecar with timeouts, retries and outlier detection; in 06-04, mTLS between services with Vault certificates managed by each service. A service mesh (Istio, Linkerd) injects that sidecar automatically into every Pod in the namespace and configures it from Kubernetes objects: mandatory mTLS with automatic certificate rotation (PeerAuthentication: STRICT), service-to-service authorization policies, per-route timeouts and retries (VirtualService, DestinationRule with outlierDetection), and uniform RED telemetry for Prometheus without instrumenting anything. It is the way to get 06-04 and 07-04 without code for services that are not Python (or so as not to depend on every team doing it right), in exchange for 1-2 ms of latency, memory per sidecar and one more piece to operate. The architectural decision of when it is worth it is taken up again in 08-01.

Operators. An operator is a controller that knows one specific system: it knows how to fail over PostgreSQL, run a nodetool drain before restarting a Cassandra node, or reassign Kafka partitions when a broker is added. It is installed in the cluster and spoken to through its own objects (kind: Kafka, kind: PostgresCluster). For Kilometre Zero: Strimzi for Kafka, K8ssandra for Cassandra, CloudNativePG or the Zalando operator (Patroni) for PostgreSQL. With them, the StatefulSet of section 7 is replaced by a ten-line declaration and the operator takes care of what 07-03 did by hand (and of the backups to MinIO). Here it is enough to know that they exist and that they are the recommended way to run stateful systems on Kubernetes.

  1. GitOps and CI/CD

With manifests in k8s/, the last piece is who applies them and when. GitOps is the answer: the Git repository is the single source of truth for the desired state; nobody runs kubectl apply by hand in production; an agent in the cluster (Argo CD or Flux) watches the repository and continuously reconciles the cluster with whatever is on the main branch. Deploying is merging a pull request that changes the image tag; rolling back is reverting the commit; auditing who deployed what is git log. And a manual change in the cluster ("drift") is detected and undone.

flowchart LR
    DEV[Developer<br/>push to a branch] --> CI
    subgraph CI["CI (GitHub Actions / GitLab CI)"]
        T[pytest + contracts<br/>07-06] --> B[docker build<br/>orders:1.15.0-8b2d4e1]
        B --> S[image scan<br/>and cosign signature]
        S --> PUSH[push to the registry]
    end
    PUSH --> PR[Automatic PR in the deployment repo:<br/>k8s/orders-deployment.yaml<br/>image: ...:1.15.0-8b2d4e1]
    PR --> REV[Review and merge]
    REV --> ARGO[Argo CD detects the change<br/>and syncs km0-prod]
    ARGO --> ROLL[Argo Rollouts: 10% canary<br/>analysis with Prometheus]
    ROLL -- SLO OK --> FULL[100%]
    ROLL -- error rate > SLO --> ABORT[automatic rollback<br/>+ alert to #km0-operations]
# .github/workflows/orders.yml (excerpt)
name: orders
on:
  push:
    paths: ["services/orders/**", "services/common/**", "contracts/**"]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r services/orders/requirements.lock -r requirements-test.txt
      - run: pytest tests/unit tests/integration -q         # Testcontainers brings up PostgreSQL and Kafka (07-06)
      - run: python tests/contract/verify.py orders         # consumer-provider contracts (07-06)
  build:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - id: meta
        run: echo "tag=$(cat services/orders/VERSION)-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
      - run: docker build -f services/orders/Dockerfile -t registry.km0.internal/km0/orders:${{ steps.meta.outputs.tag }} .
      - run: trivy image --exit-code 1 --severity CRITICAL registry.km0.internal/km0/orders:${{ steps.meta.outputs.tag }}
      - run: docker push registry.km0.internal/km0/orders:${{ steps.meta.outputs.tag }}
      - run: cosign sign --key env://COSIGN_KEY registry.km0.internal/km0/orders:${{ steps.meta.outputs.tag }}
  promote:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: {repository: km0/deployment, token: "${{ secrets.DEPLOYMENT_TOKEN }}"}
      - run: |
          sed -i "s|km0/orders:.*|km0/orders:${{ needs.build.outputs.tag }}|" k8s/orders-deployment.yaml
          git commit -am "orders ${{ needs.build.outputs.tag }}" && git push origin HEAD:refs/heads/orders-${{ needs.build.outputs.tag }}
          gh pr create --fill --base main       # a person approves the merge (or it is automated for staging)

Two repositories: the code one (which produces images) and the deployment one (k8s/, which Argo CD watches). That way, "which version is in production" is one line in a YAML file with Git history, and the pipeline can promote to staging automatically and to prod with an approval. Image signing (cosign) and an admission policy that only allows signed images close the loop with 06-05: nothing runs in production that has not gone through the pipeline.

  1. docker-compose versus Kubernetes

Aspect docker-compose Kubernetes
Scope One node A cluster of N nodes
Desired state Applied once (up) Continuously reconciled
Self-healing restart: always (local restart) Restart, rescheduling on another node, replacement
Scaling Manual --scale, one node HPA by metrics, across nodes
Deployment Stop and start (downtime) Rolling, canary, blue/green with no downtime
Discovery Compose DNS (by service name) Service + DNS + readiness-based balancing
Configuration/secrets .env, files, secrets: ConfigMap, Secret, RBAC, Vault
Learning curve and operating cost Minutes; almost nil Weeks; a platform to operate (or pay for as managed, 08-03)
When Local development; demos; this course's lab; small single-node production that tolerates outages Production with more than one node, scaling, frequent deployments, several teams

The practical rule: compose for developing (the docker-compose.yml in km0/ is still how the platform is brought up on a laptop), Kubernetes for production from the moment you need more than one node or zero-downtime deployments. Jumping to Kubernetes with a team of two and one service is paying the complexity without collecting the benefit; staying on compose with six services, three zones and daily deployments is Tuesdays and Thursdays under another name.

Common Mistakes and Tips

  • Ansible tasks that are not idempotent. A command: without creates: that formats a broker on every run. Use declarative modules; for command/shell, always creates:/removes: or changed_when:; test with --check --diff.
  • Restarting all the stateful nodes at once. serial: 1 and a health wait between nodes; for Kafka and Cassandra, it is the difference between maintenance and an incident.
  • image: orders:latest. Nobody knows what is running in production and a rollout undo may "go back" to the same image. Version + commit hash, always.
  • Deployments without resources. Without requests the scheduler places blindly and the CPU-based HPA does not work; without limits a Pod with a memory leak takes the node down with it. Both, measured with the metrics of 07-01.
  • Liveness probes that check dependencies. Already in 07-03: cascading restarts. Minimal liveness, readiness with dependencies, generous startup.
  • A high maxUnavailable "to deploy faster". It reduces capacity during the rollout, exactly when the new version may be slower. maxUnavailable: 0, maxSurge: 1.
  • kubectl apply by hand in production. History is lost and drift appears. GitOps: everything goes through a PR; Argo CD/Flux apply it.
  • Scaling down as fast as scaling up. The HPA flaps and every scale-down cuts capacity in a spike that comes back. A long stabilisation window on scaleDown.
  • Secrets in ConfigMaps or in the repository. A Kubernetes Secret is just base64; the truly sensitive material is delivered by Vault at runtime (06-04); in Git, never.
  • Kubernetes for its own sake. A cluster for one service with two people is a burden with no benefit. Compose until the reasons (nodes, scaling, zero-downtime deployments) are real.

Exercises

Exercise 1. Jordan runs ansible-playbook -i inventory.ini kafka.yml a second time without having changed anything, and sees changed=2 on kafka-2: the tasks "Broker certificate and key from Vault" and "Write the broker keystore" show as changed, and Kafka has restarted on kafka-2. (a) What is going on, and why did it not happen on kafka-1 or kafka-3? Hint: look at changed_when and at what copy does when the content differs. (b) Is this an idempotency problem in the role or in the certificate design of 06-04? Propose a change to the role that avoids restarting the broker on every run while still renewing the certificate when it is due. (c) What would have happened without serial: 1 and with all three brokers in the same situation?

Exercise 2. During Grape Harvest Week, orders scales up to 12 replicas (the HPA's maximum) and still has a p99 of 700 ms. Martha looks: the Pods' CPU is at 35%, the lag of orders-saga is low, km0_bulkhead_in_use{dependency="inventory"} is at 24 on every Pod and km0_circuit_state is 0. (a) Why is the HPA not helping, and what does a full bulkhead with low CPU indicate? (b) What should be scaled, and which Kubernetes object governs that component? Is an HPA any use there? (c) Design an HPA metric for orders that reflects real saturation better than CPU, using something from 07-01.

Exercise 3. orders 1.15.0 is about to be deployed; it changes the format of the order.confirmed event on orders.events by adding a mandatory field that analytics 2.3 does not understand yet. (a) Why is a rolling update of orders dangerous here even if the probes are fine, and which strategy from section 8 mitigates it only in part? (b) Describe a step-by-step deployment plan (with the corresponding commands or GitOps PRs) that never breaks analytics, building on the schema compatibility of 02-05. (c) Which automatic check in the CI pipeline would have blocked the PR before reaching this situation? (It is developed in 07-06; it is enough to name it and say which job it would go in.)

Solutions

Exercise 1.

(a) The Vault task generates a new certificate on every run (with changed_when: false it is not flagged as a change, but the content registered in cert is different every time); the copy task compares the new content with the existing file, sees that it differs (another certificate, another key) and writes it: changed=true and notify: restart kafka. On kafka-1 and kafka-3 it did not happen because... it did happen, or it would have: if it was not seen, it is because the playbook with serial: 1 was interrupted or because on those nodes the previous run failed before writing the keystore; with a role like this, every broker would be restarted on every run. (b) It is an idempotency defect in the role, not in the certificate design: the short-lived certificates of 06-04 are correct, but the role must generate one only when the current one is about to expire: add a prior task that reads the expiry date of the existing certificate (community.crypto.x509_certificate_info) and run the generation and the copy with when: current_cert.expired or (current_cert.not_after | to_datetime - now()) < 7 days; or, better, take the renewal out of the playbook and delegate it to the Vault agent on each node (Vault Agent with templates, which renews and reloads without restarting the broker through dynamic kafka-configs). (c) All three brokers restarting at the same time leaves every partition without ISRs: with min.insync.replicas=2 and acks=all, orders.events rejects writes (NotEnoughReplicas) during the restart, the orders outbox piles up and KafkaLagDelivery and the alerts of 07-01 fire; without unclean.leader.election.enable=false there could even be message loss. serial: 1 with wait_for turns that into three successive restarts with no loss of availability.

Exercise 2.

(a) The HPA scales orders by CPU, and orders is not CPU-bound: it is waiting. km0_bulkhead_in_use at 24 (the maximum) with CPU at 35% and the circuit closed means that every call to inventory succeeds but is slow: the 24 permits are held by requests waiting on inventory, and the next ones are rejected (BulkheadFull, fallback to "pending confirmation") or wait for a thread. Adding orders replicas multiplies the bulkheads (12 × 24 = 288 concurrent calls) and makes the load on inventory worse. (b) inventory (or its database) is what needs scaling: inventory is a Deployment (stateless, the database is separate) and does support an HPA, with a saturation metric of its own (requests in flight or the p99 of ReserveStock); if the bottleneck is km0_inventory (lock contention on crianza-wine, as in the trace of 07-02), adding inventory replicas does not help: the PostgreSQL StatefulSet/operator does not scale horizontally for writes, and the solution is a design one (04-01: partition the stock by market, or batch the reservations). (c) A Prometheus metric via prometheus-adapter: km0_requests_in_flight{service="orders"} averaged per Pod with a target of, say, 20 (with 32 threads, an average of 20 in flight indicates an incipient queue); or latency directly: histogram_quantile(0.99, ...) of POST /orders with a target of 0.4 s. The first is better for the HPA because it responds almost linearly to the number of replicas; the second is more useful as an alert. In both cases, with a slow scaleDown.

Exercise 3.

(a) The rolling update replaces orders Pods without downtime, but as soon as the first new Pod publishes an order.confirmed in the new format, analytics 2.3 fails to deserialise it: the orders probes are perfect; the damage is in another service, via Kafka, and shows up as messages in the analytics DLQ (02-05) and lag. A canary reduces the fraction of events in the new format, but a single message is enough to break the consumer (or send it to the DLQ): it mitigates the volume, not the problem. (b) Forward and backward compatibility: 1) a PR in analytics (2.4) that tolerates the new field (ignores it if it does not understand it, uses it if present), deployed first (kubectl rollout status deployment/analytics), with the schema registered as compatible; 2) deploy orders 1.15.0 with a 10% canary (a PR in the deployment repo with orders-canary, or Argo Rollouts), watching the analytics DLQ and lag and the orders error rate; 3) promote to 100%; 4) only when no producer emits the old format any more (and the old messages have aged out of the topic's retention, 7 days), a PR in analytics that makes the field mandatory. Making the field optional in the schema rather than mandatory also removes step 4. At no point is there a consumer that does not understand what is on the topic. (c) A contract test between the orders producer and the analytics consumer on the order.confirmed schema (checking the schema's compatibility against the registered ones, and the consumer-provider contract): it would go in the test job of the orders pipeline (python tests/contract/verify.py orders), and it would have failed on detecting a new mandatory field that a registered consumer does not accept. It is developed in 07-06.

Conclusion

Automating means taking the person off the critical path and leaving in their place a description that can be reviewed, tested and applied as many times as needed. For the stateful machines, Ansible: an inventory, playbooks and roles whose tasks are idempotent (creates:, template with handlers, --check --diff), and which walk the Kafka brokers one at a time, honouring the ISRs. For the services, immutable images tagged by version and commit, built once and deployed to every environment with injected configuration. And on top of them, Kubernetes: a consensus store with controllers that endlessly reconcile the desired state with the actual one; Deployments with probes, resources, zone spreading and graceful shutdown; Services that balance only towards ready Pods; an HPA driven by CPU and by Kafka lag; StatefulSets or operators for Cassandra, Kafka and PostgreSQL; an Ingress towards Kong; rolling, blue/green and canary with automatic analysis against the SLOs; namespaces and RBAC; the service mesh that provides mTLS and resilience policies without code; and GitOps with a pipeline in which deploying is merging and rolling back is reverting a commit. Compose stays on the laptop.

But this whole machine rests on an assumption that has not yet been examined: that the version 1.15.0 the pipeline builds, signs and deploys works. The pipeline runs pytest and verifies contracts, and the canary watches the SLOs, but which test proves that the saga compensates correctly when inventory dies halfway through? How do you know that the circuit breaker of 07-04 opens in time, or that Patroni fails over in under 30 seconds, before it happens in production on a Saturday? Testing a distributed system is harder than testing a program: non-determinism, partial failures and time mean that unit tests are not enough. The last lesson of the module covers testing distributed systems (integration with real dependencies, contracts, load tests simulating Grape Harvest Week) and chaos engineering: deliberately provoking the failures of 07-03 and 07-04, with a hypothesis and a controlled blast radius, to check that the platform responds as designed.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved