You have the concepts and a kind cluster with a standalone Redis. Now comes the whole platform: Aurora Libros' four services with real manifests, the probes from 06-01, the hardening from 05-03 and the image your pipeline published in 06-02. By the end, /books will return the nine titles from inside the cluster.
Contents
- Organizing
k8s/and theNamespace aurora-cache: Deployment and Service- Why
aurora-dbis aStatefulSet - The
StatefulSetwithvolumeClaimTemplates - The headless Service and the
ConfigMapwithinit.sql aurora-api: image by digest andimagePullSecrets- The three probes in the manifest
requests,limitsand the QoS classes- Pod-level and container-level
securityContext - Configuration with
ConfigMapandSecret aurora-web: Deployment, Service andIngress- TLS with cert-manager
- Kustomize: bases and per-environment overlays
- Bringing it up and verifying it
- Debugging: the states of a Pod
- Helm as an alternative
- Organizing
k8s/ and the Namespace
k8s/ and the Namespacek8s/base/{namespace,config,cache,db,api,web}.yaml + kustomization.yaml
k8s/overlays/{staging,production}/kustomization.yamlOne file per service, with all its objects together separated by ---. It is more practical than one file per object: to understand aurora-api you open a single file and see its Deployment, its Service and its configuration. The first one is the Namespace, a five-line object (apiVersion: v1, kind: Namespace, metadata.name: aurora) that creates the partition everything else will live in.
aurora-cache: Deployment and Service
aurora-cache: Deployment and Service# k8s/base/cache.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: aurora-cache }
spec:
replicas: 1
selector: { matchLabels: { app.kubernetes.io/name: aurora-cache } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-cache } }
spec:
containers:
- name: redis
image: redis:7-alpine
args: ["--maxmemory","200mb","--maxmemory-policy","allkeys-lru"]
ports: [{ containerPort: 6379, name: redis }]
resources:
requests: { memory: 64Mi, cpu: 50m }
limits: { memory: 256Mi, cpu: 500m }
livenessProbe: { tcpSocket: { port: redis }, initialDelaySeconds: 5 }
readinessProbe: { exec: { command: ["redis-cli","ping"] }, periodSeconds: 5 }
---
apiVersion: v1
kind: Service
metadata: { name: aurora-cache }
spec:
selector: { app.kubernetes.io/name: aurora-cache }
ports: [{ port: 6379, targetPort: redis }]Two patterns that will repeat across every manifest. The first: ports are named (name: redis) and then referenced by name in the Service and in the probes; if one day you change the number, you change it in a single place. The second: the app.kubernetes.io/name label is the only one that appears in the selector, and the rest stay out of it, because the selector is immutable and it is unwise to tie it to anything that will change. And notice that Redis here is a disposable cache: no volume and no persistence, because if the Pod dies the API goes back to serving with source: db until it warms up again, which is exactly what you decided in the readiness probe in 06-01.
- Why
aurora-db is a StatefulSet
aurora-db is a StatefulSet| Aspect | Deployment |
StatefulSet |
|---|---|---|
| Pod names | Random (aurora-api-7d9f-x2k) |
Stable ordinals: aurora-db-0, -1 |
| Identity when recreated | A different one | The same, with the same disk |
| Storage | Shared or ephemeral | One PVC per Pod, via volumeClaimTemplates |
| Startup and shutdown order | All at once | Sequential -0, -1; shutdown in reverse order |
| Per-Pod DNS | No | Yes, with a headless Service |
| Updates | Rolling, by ReplicaSet | By ordinal, highest to lowest |
A Deployment with a volume for PostgreSQL fails for two reasons. First, the default strategy starts the new Pod before killing the old one: for a few seconds there would be two PostgreSQL instances writing over the same files, which corrupts the data. Second, if you scaled to two replicas, both would share the same PVC —or fight over it— with no coordination whatsoever.
Warning. Running a database in Kubernetes is feasible, but in production it demands an operator (CloudNativePG, Zalando Postgres Operator, Crunchy) to handle failover, backups, replication and version upgrades, or else a managed service outside the cluster altogether. A bare
StatefulSetlike the one in this lesson is fine for learning and for development environments, but it does not cover disaster recovery. Validate this decision with the infrastructure and data officer in your organization before putting real data in it.
- The
StatefulSet with volumeClaimTemplates
StatefulSet with volumeClaimTemplates# k8s/base/db.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: aurora-db }
spec:
serviceName: aurora-db # required: the headless Service that gives per-Pod DNS
replicas: 1
selector: { matchLabels: { app.kubernetes.io/name: aurora-db } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-db } }
spec:
securityContext: { fsGroup: 999 } # the volume belongs to the postgres user's group
containers:
- name: postgres
image: postgres:16-alpine
ports: [{ containerPort: 5432, name: postgres }]
env:
- { name: POSTGRES_USER, value: aurora }
- { name: POSTGRES_DB, value: aurora_books }
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
- name: POSTGRES_PASSWORD
valueFrom: { secretKeyRef: { name: aurora-secrets, key: DB_PASSWORD } }
volumeMounts:
- { name: data, mountPath: /var/lib/postgresql/data }
- { name: init, mountPath: /docker-entrypoint-initdb.d, readOnly: true }
resources:
requests: { memory: 256Mi, cpu: 100m }
limits: { memory: 1Gi, cpu: "2" }
readinessProbe:
exec: { command: ["pg_isready","-U","aurora","-d","aurora_books"] }
periodSeconds: 5
# liveness without querying the database: only "the process is alive"
livenessProbe: { tcpSocket: { port: postgres }, initialDelaySeconds: 30 }
volumes: [{ name: init, configMap: { name: aurora-init-sql } }]
volumeClaimTemplates: # one PVC per Pod, created automatically
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 10Gi } }Putting PGDATA in a subdirectory is not a whim: many provisioners create a lost+found at the root of the volume and PostgreSQL refuses to initialize in a directory that is not empty. Using /data/pgdata avoids that failure, which is one of those that cost you a whole afternoon. As for volumeClaimTemplates, it generates a PVC named data-aurora-db-0, with one deliberate property: deleting the StatefulSet does not delete its PVCs. That is a safety net —the data survives an accidental deletion— and also a source of surprises, because recreating the StatefulSet reattaches the previous volume with everything that was inside it.
- The headless Service and the
ConfigMap with init.sql
ConfigMap with init.sql---
apiVersion: v1
kind: Service
metadata: { name: aurora-db }
spec:
clusterIP: None # headless: no virtual IP, DNS straight to each Pod
selector: { app.kubernetes.io/name: aurora-db }
ports: [{ port: 5432, targetPort: postgres }]A normal Service spreads traffic across Pods, which is exactly what you do not want with a database: every replica is different and you have to be able to address a specific one. With clusterIP: None, DNS returns the Pods' addresses directly, and each one additionally gets its stable name: aurora-db-0.aurora-db.aurora.svc.cluster.local. With read replicas, that name is what lets you point the writes at the primary.
The init.sql with the nine titles comes in as a ConfigMap generated from the file, with kubectl create configmap aurora-init-sql --from-file=init.sql=db/init.sql -n aurora. One limit worth knowing: a ConfigMap cannot exceed 1 MiB, because it lives in etcd. For an init.sql with nine books that is plenty, but a real dump will not fit; in that case you use an initContainer that downloads it from object storage.
aurora-api: image by digest and imagePullSecrets
aurora-api: image by digest and imagePullSecrets# k8s/base/api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: aurora-api
labels: { app.kubernetes.io/name: aurora-api, app.kubernetes.io/version: "2.0.0" }
spec:
replicas: 3
revisionHistoryLimit: 5
selector: { matchLabels: { app.kubernetes.io/name: aurora-api } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-api } }
spec:
imagePullSecrets: [{ name: ghcr-auroralibros }]
terminationGracePeriodSeconds: 30 # > SHUTDOWN_TIMEOUT_MS (15 s) from 06-01
containers:
- name: api
image: ghcr.io/auroralibros/aurora-api:2.0.0@sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90
imagePullPolicy: IfNotPresent
ports: [{ containerPort: 3000, name: http }]kubectl create secret docker-registry ghcr-auroralibros -n aurora \
--docker-server=ghcr.io --docker-username=auroralibros --docker-password="$GHCR_TOKEN"Pinning the digest alongside the tag is what makes the deployment genuinely immutable: the tag documents which version it is and the digest guarantees which bytes run, even if somebody has moved 2.0.0 in the registry. It is the practice any serious admission policy requires. On imagePullPolicy there is a classic trap: with the latest tag the default value is Always, and with any other one it is IfNotPresent; once the digest is pinned, IfNotPresent is correct and avoids needless downloads, because a digest always identifies the same content.
- The three probes in the manifest
startupProbe: # protects the other two while it starts
httpGet: { path: /health/started, port: http }
periodSeconds: 5
failureThreshold: 12 # up to 60 s to start
livenessProbe: # does NOT touch the DB: only "is the process answering?"
httpGet: { path: /health/live, port: http }
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe: # this one DOES check dependencies
httpGet: { path: /health/ready, port: http }
periodSeconds: 5
failureThreshold: 2
# extra margin for the kube-proxy to drain
lifecycle: { preStop: { exec: { command: ["sleep","5"] } } }| Probe | Endpoint | If it fails | Time before it acts |
|---|---|---|---|
startupProbe |
/health/started |
The container is restarted | 12 × 5 s = 60 s |
livenessProbe |
/health/live |
The container is restarted | 3 × 15 s = 45 s |
readinessProbe |
/health/ready |
It is removed from the endpoints | 2 × 5 s = 10 s |
The numbers were chosen with a logic behind them: readiness reacts fast (10 s) because its consequence is cheap and reversible —stop sending traffic—, while liveness reacts slowly (45 s) because its consequence is expensive and disruptive —kill the process. Inverting those timings is the recipe for cascading restarts under load. And the preStop with sleep 5 plays the same role here as the five-second wait you programmed in 06-01: when Kubernetes decides to terminate a Pod, it removes its endpoint and sends SIGTERM at the same time, and those two things propagate at different rates across every kube-proxy in the cluster. The sleep delays the SIGTERM just long enough for the routing tables to update first.
requests, limits and the QoS classes
requests, limits and the QoS classesFor aurora-api, requests: { memory: 128Mi, cpu: 100m } and limits: { memory: 512Mi, cpu: "1" }. The two fields do different things:
| Field | What it is for | What happens if you go over |
|---|---|---|
requests |
Scheduling: the scheduler only places the Pod where it fits | The Pod is not scheduled: Pending |
limits.memory |
A hard cgroup ceiling | OOMKilled: the process dies |
limits.cpu |
A CPU time quota | Throttling: it slows down, it does not die |
| QoS class | Condition | When the node runs out of memory |
|---|---|---|
Guaranteed |
requests == limits in every container |
Last to be evicted |
Burstable |
requests < limits |
In between |
BestEffort |
No requests and no limits |
First to be evicted |
aurora-api ends up as Burstable, which is right for an API: it reserves little so many replicas fit per node and it can climb to the limit during peaks. aurora-db, on the other hand, is a candidate for Guaranteed in production, because you do not want the kernel picking your database when something has to be evicted. The numbers come straight from the docker stats table in 06-01: a 214 MiB peak → a 512 MiB limit.
- Pod-level and container-level
securityContext
securityContext securityContext: # POD level: applies to every container
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile: { type: RuntimeDefault }
containers:
- name: api
securityContext: # CONTAINER level: wins over the Pod's
allowPrivilegeEscalation: false # equivalent to no-new-privileges
readOnlyRootFilesystem: true # equivalent to read_only
capabilities: { drop: [ALL] } # equivalent to cap_drop: [ALL]
volumeMounts: [{ name: tmp, mountPath: /tmp }]
# the tmpfs from 05-03, needed because the root is read-only
volumes: [{ name: tmp, emptyDir: { medium: Memory, sizeLimit: 32Mi } }]This is, line by line, the translation of the hardening in the compose.prod.yaml from 05-03. The only new item is runAsNonRoot: true, which deserves attention: it does not change the user, it verifies that the image does not start as root, and if it does the Pod fails to be created with CreateContainerConfigError. It is a cheap check that stops a badly built image from ever running. And fsGroup solves the classic permissions problem with volumes: Kubernetes changes the owning group of the mounted content to that GID, so an unprivileged process can write to its PVC.
- Configuration with
ConfigMap and Secret
ConfigMap and Secret# k8s/base/config.yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: aurora-config }
data:
DB_HOST: aurora-db
DB_USER: aurora
DB_NAME: aurora_books
DB_POOL_MAX: "10"
REDIS_HOST: aurora-cache
CACHE_TTL: "60"
SHUTDOWN_TIMEOUT_MS: "15000"
LOG_LEVEL: info
---
# and in the aurora-api container:
# envFrom: [{ configMapRef: { name: aurora-config } }] # every key
# env:
# - name: DB_PASSWORD
# valueFrom: { secretKeyRef: { name: aurora-secrets, key: DB_PASSWORD } }The API needs not one line of change: the same variable names you defined in config.js in 06-01 now arrive from a ConfigMap instead of from Compose, which is the payoff for having taken all the configuration out of the image. You could also mount the secret as a file and use DB_PASSWORD_FILE, which is safer for the reasons you saw in 06-03 about inheritance by subprocesses; secretKeyRef is used here for brevity, but in production the volume is preferable and your code already supports it.
One important operational detail: changing a ConfigMap does not restart the Pods. To apply the change you have to force it with kubectl rollout restart deployment/aurora-api, or let Kustomize generate the ConfigMap with a hash suffix, which is what we will do.
aurora-web: Deployment, Service and Ingress
aurora-web: Deployment, Service and Ingress# k8s/base/web.yaml (extract)
apiVersion: apps/v1
kind: Deployment
metadata: { name: aurora-web }
spec:
replicas: 2
selector: { matchLabels: { app.kubernetes.io/name: aurora-web } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-web } }
spec:
containers:
- name: nginx
image: nginx:alpine
ports: [{ containerPort: 80, name: http }]
volumeMounts: [{ name: content, mountPath: /usr/share/nginx/html, readOnly: true }]
readinessProbe: { httpGet: { path: /, port: http }, periodSeconds: 5 }
resources:
requests: { memory: 16Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 200m }
volumes: [{ name: content, configMap: { name: aurora-web-html } }]
---
apiVersion: v1
kind: Service
metadata: { name: aurora-web }
spec:
selector: { app.kubernetes.io/name: aurora-web }
ports: [{ port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: aurora
annotations: { nginx.ingress.kubernetes.io/proxy-read-timeout: "30" }
spec:
ingressClassName: nginx
rules:
- host: libros.aurora.example
http:
paths:
- { path: /books, pathType: Prefix, backend: { service: { name: aurora-api, port: { number: 3000 } } } }
- { path: /health, pathType: Prefix, backend: { service: { name: aurora-api, port: { number: 3000 } } } }
- { path: /, pathType: Prefix, backend: { service: { name: aurora-web, port: { number: 80 } } } }Notice the architectural change: in Compose, aurora-web acted as a reverse proxy towards the API. Here that job belongs to the Ingress, and Nginx is reduced to serving static files. That is the natural arrangement in Kubernetes: routing is the platform's responsibility, not your container's, and that way you can scale the front end and the API separately.
- TLS with cert-manager
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [libros.aurora.example]
secretName: aurora-tls # cert-manager creates and renews it by itselfWith cert-manager installed and a ClusterIssuer configured, those five lines are enough: the operator sees the annotation, requests the certificate from Let's Encrypt, solves the ACME challenge, stores the result in the aurora-tls Secret and renews it automatically before it expires. It is the canonical example of an operator and of what Kubernetes adds over Swarm: a recurring task somebody used to do by hand becomes one more controller. The issuer's configuration —Let's Encrypt production or staging, DNS-01 versus HTTP-01— is a platform decision worth agreeing on with your infrastructure team.
- Kustomize: bases and per-environment overlays
Kustomize is the same idea as the Compose overrides from 04-06, with the difference that here it is part of kubectl: a shared base and overlays that patch it per environment, without duplicating a single file or using templates.
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: aurora
resources: [namespace.yaml, config.yaml, cache.yaml, db.yaml, api.yaml, web.yaml]
commonLabels: { app.kubernetes.io/part-of: aurora-libros }
configMapGenerator:
- { name: aurora-init-sql, files: [init.sql=../../db/init.sql] } # the hash goes into the name
---
# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: aurora
resources: [../../base]
images:
- { name: ghcr.io/auroralibros/aurora-api, newTag: 2.0.0, digest: sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90 }
replicas: [{ name: aurora-api, count: 3 }]
patches:
- target: { kind: Deployment, name: aurora-api }
patch: |
- { op: replace, path: /spec/template/spec/containers/0/resources/limits/memory, value: 512Mi }kubectl kustomize k8s/overlays/production | head -20 # see the result without applying it
kubectl apply -k k8s/overlays/productionThe configMapGenerator solves the problem from the end of section 10: it generates the ConfigMap with a suffix derived from the hash of the content (aurora-init-sql-7f9c2d) and automatically updates the reference in the Deployments. Because the name changes, the Pod template changes, and the rolling update fires by itself when you edit the configuration. It is exactly the behavior you want and one that never happens by hand.
- Bringing it up and verifying it
kubectl apply -k k8s/overlays/production
kubectl rollout status statefulset/aurora-db -n aurora --timeout=120s
kubectl rollout status deployment/aurora-api -n aurora --timeout=120s
kubectl get all -n auroraNAME READY STATUS RESTARTS AGE
pod/aurora-api-6d4f8b7c9-2xkpq 1/1 Running 0 63s (×3)
pod/aurora-cache-5b7d9c8f4-hq3vn 1/1 Running 0 92s
pod/aurora-db-0 1/1 Running 0 92s
pod/aurora-web-79c6d8f5b-k2mtx 1/1 Running 0 63s (×2)
NAME TYPE CLUSTER-IP PORT(S)
service/aurora-api ClusterIP 10.96.201.14 3000/TCP
service/aurora-cache ClusterIP 10.96.184.22 6379/TCP
service/aurora-db ClusterIP None 5432/TCP
service/aurora-web ClusterIP 10.96.77.108 80/TCPThe aurora-db-0 with its ordinal and the CLUSTER-IP None on the headless Service confirm the StatefulSet is set up correctly.
kubectl port-forward -n aurora svc/aurora-api 8080:3000 &
curl -s localhost:8080/books | jq '{source, total: (.books|length), first: .books[0].title}'
curl -s localhost:8080/books | jq -r '.source' # second call
# { "source": "db", "total": 9, "first": "El jardín de senderos que se bifurcan" }
# cacheThe nine titles are there, and the second call returns source: cache: the cache-aside works exactly as it did on your laptop, now spread across three API Pods that talk to a Redis and a PostgreSQL through their Services. The application has not changed a single line since module 4.
- Debugging: the states of a Pod
| State | What it means | Usual cause | Diagnosis |
|---|---|---|---|
Pending |
There is no node to place it on | requests too high, taints, an unbound PVC |
kubectl describe pod → Events |
ContainerCreating |
Preparing volumes and networking | A PVC still waiting, a missing secret | describe → Events |
ImagePullBackOff |
It cannot download the image | Misspelled name, missing imagePullSecrets |
describe → Events |
CrashLoopBackOff |
It starts and dies in a loop | Configuration failure, a badly set probe | logs --previous |
OOMKilled |
It exceeded limits.memory |
A low limit or a memory leak | describe → Last State |
Error |
It exited with a non-zero code | An exception at startup | logs |
Running but 0/1 |
Alive but not ready | Readiness is not passing | describe → Events, logs |
Endless Terminating |
It will not finish dying | It does not catch SIGTERM or there is a finalizer |
describe, delete --force |
kubectl describe pod aurora-api-6d4f8b7c9-2xkpq -n aurora | tail -15 # Events
kubectl logs aurora-api-6d4f8b7c9-2xkpq -n aurora --previous # the previous attempt
kubectl get events -n aurora --sort-by=.lastTimestamp | tail -10
kubectl debug -it aurora-api-6d4f8b7c9-2xkpq -n aurora --image=nicolaka/netshoot --target=apikubectl debug solves the problem you had with the minimal images from 05-03: it attaches an ephemeral container with every tool to the already running Pod, sharing its network and its namespaces, without restarting anything and without the image needing to contain a sh. It is the netshoot from module 3, applied to the Kubernetes world.
- Helm as an alternative
| Aspect | Kustomize | Helm |
|---|---|---|
| Mechanism | Patches over plain YAML | Go templates with values |
| Integration and curve | Inside kubectl (-k); gentle |
A separate binary; steeper |
| Third-party software | Not very practical | Its strong point: helm install ingress-nginx |
| State and rollback | Via kubectl rollout |
helm rollback with its own history |
The usual combination is to use Helm for third-party software —ingress-nginx, cert-manager, Prometheus— and Kustomize for your own, which is what Aurora Libros does. Helm shines when you want to distribute your application to third parties who need to parameterize it; for an in-house application with two environments, Kustomize saves you from turning your manifests into unreadable templates.
Common Mistakes and Tips
- A database in a
Deployment. During the update two Pods coexist writing to the same disk.StatefulSetwithstop-first, and an operator in production. - Forgetting
imagePullSecrets.ImagePullBackOffon every Pod with a private registry. The secret is per namespace. PGDATAat the root of the volume. The provisioner'slost+foundprevents PostgreSQL from initializing.- Liveness pointing at
/health/ready. Cascading restarts whenever the database coughs. Liveness only looks at the process. - A short
initialDelaySecondswith nostartupProbe. The Pod restarts before it has finished starting, forever. - Editing a ConfigMap and expecting it to be applied. It restarts nothing:
rollout restartorconfigMapGenerator. - Tip:
kubectl apply -k --dry-run=serverin the pipeline validates the manifests against the real API before touching the cluster, andkubectl rollout restart deployment/...is the correct way to "restart" (notdelete pod). - Tip:
kubectl get events --sort-by=.lastTimestampis the most useful view when something fails and you do not even know which object to look at.
Exercises
Exercise 1. Deploy the whole of Aurora Libros on your kind cluster and verify it end to end: that aurora-db-0 has its PVC bound, that /books returns the nine titles and that the second call comes from the cache.
Exercise 2. Diagnose three deliberately triggered failures without looking at the manifests: a Pending caused by impossible requests, an ImagePullBackOff and a CrashLoopBackOff caused by a missing required variable. State in each case which command reveals it.
Exercise 3. Demonstrate that the StatefulSet preserves identity and data: write a record into the database, delete the aurora-db-0 Pod and check what survives and what does not.
Solutions
Solution 1.
kubectl apply -k k8s/overlays/production
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=aurora-api -n aurora --timeout=180s
kubectl get pvc -n aurora
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# data-aurora-db-0 Bound pvc-8f3c1a… 10Gi RWO standardThe name data-aurora-db-0 is the signature of volumeClaimTemplates: <template-name>-<statefulset-name>-<ordinal>. If you scaled to two replicas, data-aurora-db-1 would appear, with its own disk, which is precisely what a Deployment cannot give you.
kubectl port-forward -n aurora svc/aurora-api 8080:3000 &
curl -s localhost:8080/books | jq -r '.source, (.books|length), .books[4].title'
curl -s localhost:8080/books | jq -r '.source'
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -d aurora_books -tAc 'SELECT count(*) FROM books;'
# db / 9 / Nada (first call)
# cache (second call)
# 9 (rows in the table)The end-to-end verification is complete: the ConfigMap with init.sql ran while PostgreSQL was initializing, the nine titles are in the table, and the API serves them from the database the first time and from Redis the second. But it is worth pausing on what was not needed. You did not touch the application code, or the Dockerfile, or the variables it expects. The image the cluster runs is the same one the pipeline built in 06-02 and the same one that ran in your Compose: the contract you established in 06-01 —configuration per environment, separate probes, graceful shutdown— is what made changing platforms nothing more than changing who injects the variables.
Solution 2.
kubectl patch deploy aurora-api -n aurora --type=json \
-p='[{"op":"replace","path":"/spec/template/spec/containers/0/resources/requests/memory","value":"64Gi"}]'
kubectl get pods -n aurora | grep Pending
kubectl describe pod -n aurora -l app.kubernetes.io/name=aurora-api | grep -A3 Events
# aurora-api-84c7d9f6b-nk4pz 0/1 Pending 0 22s
# Events:
# Warning FailedScheduling no nodes available: 3 Insufficient memory
kubectl set image deploy/aurora-api api=ghcr.io/auroralibros/aurora-api:9.9.9 -n aurora
kubectl describe pod -n aurora -l app.kubernetes.io/name=aurora-api | grep -A2 'Failed'
# Warning Failed Failed to pull image "...aurora-api:9.9.9": not found
# Warning Failed Error: ErrImagePull → ImagePullBackOff
kubectl patch cm aurora-config -n aurora --type=json -p='[{"op":"remove","path":"/data/DB_HOST"}]'
kubectl rollout restart deploy/aurora-api -n aurora
kubectl logs -n aurora -l app.kubernetes.io/name=aurora-api --previous --tail=2
# {"level":"error","message":"invalid configuration",
# "detail":"Missing required variable DB_HOST (or its _FILE variant)"}| Failure | State | The command that reveals it | Where the answer is |
|---|---|---|---|
Impossible requests |
Pending |
describe pod |
Events: Insufficient memory |
| A nonexistent image | ImagePullBackOff |
describe pod |
Events: not found |
| Missing configuration | CrashLoopBackOff |
logs --previous |
The container's logs |
The diagnostic rule the table boils down to is the one that saves the most time in Kubernetes: if the container never managed to start, the answer is in the events; if it started and died, it is in the logs. In the first two cases kubectl logs returns nothing, and not because it fails, but because no process ever wrote anything. In the third, describe only says Back-off restarting failed container, which explains nothing; the --previous is essential because the current container has only just been born and the one that failed no longer exists. And look at the quality of that third message: it says exactly which variable is missing, which is the config.js from 06-01 paying off in an environment where debugging is far more awkward.
Solution 3.
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -d aurora_books -c \
"INSERT INTO books (title, author, price) VALUES ('El Aleph (2nd ed.)','J. L. Borges', 18.50);"
kubectl get pod aurora-db-0 -n aurora -o jsonpath='{.status.podIP}{"\n"}'
kubectl delete pod aurora-db-0 -n aurora
kubectl wait --for=condition=ready pod/aurora-db-0 -n aurora --timeout=120s
kubectl get pod aurora-db-0 -n aurora -o jsonpath='{.status.podIP}{"\n"}'
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -d aurora_books -tAc "SELECT count(*) FROM books;"
# 10.244.2.9
# pod "aurora-db-0" deleted
# 10.244.1.14
# 10| Property | Does it survive? | Why |
|---|---|---|
| The Pod name and its DNS | Yes | A fixed ordinal: aurora-db-0.aurora-db still points at it |
The data-aurora-db-0 PVC |
Yes | It is reattached to the new Pod |
| The data (10 rows) | Yes | It lives in the PVC, not in the Pod |
| The Pod's IP | No | It changes from 10.244.2.9 to 10.244.1.14 |
| The node it runs on | Not guaranteed | It can be rescheduled |
The contrast with exercise 2 in 06-04 is the point of the lesson. There, deleting a Pod belonging to a Deployment brought back a different Pod with a different name; here aurora-db-0 comes back, the same name, the same DNS and, above all, the same disk, with the tenth book inside it. That the IP changes and nothing breaks demonstrates why the database is always referenced by its DNS name and never by address: aurora-db in the ConfigMap still resolves, so the three API replicas reconnected on their own —with the retry-with-backoff from 04-04— without you touching a thing.
Now the warning this exercise does not demonstrate and that is worth remembering: you have survived the death of a Pod, not that of a node or a disk. With ReadWriteOnce, if the node hosting the PVC becomes unreachable, the Pod can sit in Pending until the volume is released. That, along with the absence of automatic failover, is exactly what a PostgreSQL operator brings and what has to be solved before putting customer data here.
Conclusion
The whole of Aurora Libros lives in Kubernetes. You have written the manifests for the four services with judgment: aurora-cache as a disposable Deployment with named ports, aurora-db as a StatefulSet —with the reason made explicit: two PostgreSQL instances over the same files corrupt the data— with its volumeClaimTemplates, its headless Service, its ConfigMap with the nine titles and PGDATA in a subdirectory to dodge the lost+found. And with the warning in place: this is fine for learning, but production calls for an operator or a managed database, validated with your infrastructure team.
The aurora-api Deployment gathers up everything in the module: the pipeline's image pinned by digest with its imagePullSecrets, the three probes from 06-01 pointing at /health/started, /health/live and /health/ready with deliberately asymmetric timings —fast readiness because it is cheap, slow liveness because it kills—, the preStop that buys the seconds the kube-proxy needs, the requests and limits taken from real measurements with their QoS class, and the securityContext that translates the hardening from 05-03 line by line. Configuration comes in through ConfigMap and Secret without touching a line of the application, which is the payoff for having taken it out of the image. aurora-web is reduced to a static server and the routing moves to the Ingress, with cert-manager renewing TLS on its own.
Everything is applied with kubectl apply -k and Kustomize, with one base and per-environment overlays, and the configMapGenerator that fires the rolling update whenever the configuration changes. You have verified that /books returns the nine titles with source: db and cache on the second call, you can read the table of Pod states with the rule that saves the most time —if it never started, look at the events; if it started and died, look at the logs with --previous—, you have kubectl debug for inspecting images with no shell, and you know when to use Helm and when to use Kustomize.
In the next lesson, Scaling and Load Balancing, the platform learns how to grow. You will see why the API scales by replicating and the database does not, how kube-proxy and the Ingress really balance traffic, and you will set up a HorizontalPodAutoscaler over aurora-api that creates replicas only while you generate real load against /books, measuring latency before and after to find out where the bottleneck actually is.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
