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
- Why automate
- Infrastructure as code: configuration versus provisioning
- Ansible: inventory, playbooks, roles and idempotency
- Immutability: images, registry and per-commit tags
- Kubernetes: why an orchestrator and how it is built
- Essential objects
- Kilometre Zero's manifests:
k8s/ - Deployment strategies: rolling, blue/green, canary
- Namespaces, RBAC, service mesh and operators
- GitOps and CI/CD
- docker-compose versus Kubernetes
- Common mistakes and tips
- Exercises and solutions
- Conclusion
- 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-2was created by copyinginventory-1and 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
DELETEof 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.
- 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.
- 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/km0opsThe 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:
- kafkaAnd 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=requiredIdempotency 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.
- 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.
- 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.
- 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 |
- Kilometre Zero's manifests:
k8s/
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 requestsEvery 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 pointKong (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
- 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-8b2d4e1For 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.
- 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.
- 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.
- 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:withoutcreates:that formats a broker on every run. Use declarative modules; forcommand/shell, alwayscreates:/removes:orchanged_when:; test with--check --diff. - Restarting all the stateful nodes at once.
serial: 1and 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 arollout undomay "go back" to the same image. Version + commit hash, always.- Deployments without
resources. Withoutrequeststhe scheduler places blindly and the CPU-based HPA does not work; withoutlimitsa 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 applyby 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
Secretis 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
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
