Across ten modules we have got to know the pieces of Kubernetes one at a time: Pods, Deployments, Services, ConfigMaps, Ingress, probes, HPA, network policies, RBAC, Helm, Argo CD. Each one made sense on its own, but none of them is enough by itself to put an application into production. This module changes the approach: instead of studying one piece, we solve a complete scenario end to end.
We start with the most common and, on the face of it, simplest case: taking a stateless web application to production. At Rutas Norte S.L. that means two components, web-store (nginx serving the ticket-sales SPA) and bookings-api (the Node.js REST API that checks availability and confirms bookings). Neither stores data locally, so there are no volumes to manage and no failover to coordinate. And even so, the set of objects needed for that deployment to stand up to a production review runs to thirteen pieces per component.
This lesson is the manifest reference for the whole course. The files that appear here are the ones taken as given in the lessons that follow: when the pipeline in 11-03 updates a digest, it will be the digest of these Deployments; when 11-04 turns bookings-api into a Rollout, it will start from this manifest; when 11-06 writes the runbook for "API latency has gone through the roof", it will refer to these probes and this HPA.
Contents
- What must be sorted out before the first manifest
- The complete set of objects in a production component
- Complete manifests for
bookings-api - Complete manifests for
web-store - Order of application and why it matters
- Layer-by-layer verification, from the inside out
- The "production-ready" checklist
- The characteristic failure of each layer
- What must be sorted out before the first manifest
A common mistake is to treat Kubernetes as the place where application problems get fixed. It is not. Kubernetes amplifies what the application already does: if the application starts slowly, scaling will be slow; if it does not shut down cleanly, every deployment will drop requests; if it keeps sessions in local memory, the replicas will get in each other's way.
Before you write the first line of YAML, these seven points must be resolved in the code and in the image, not in the cluster.
1.1. A reproducible image identified by digest
The image has to be built the same way every time from the same commit, and deployed by digest reference, not by a moving tag.
| Reference | Example | Fit for production? |
|---|---|---|
| Moving tag | bookings-api:latest |
No. Two pods of the same Deployment can end up running different code |
| Semantic tag | bookings-api:2.7.0 |
Only if the registry is immutable; a tag can be rewritten |
| Digest | bookings-api@sha256:3f9c... |
Yes. It is the only cryptographically stable reference |
We already saw in 08-05 how to build with multi-stage and sign with Cosign. The prerequisite here is organisational: the process that deploys must know the digest, and that is guaranteed by the pipeline we will see in 11-03.
Why it matters: without a digest, a RollingUpdate that replaces pods over twenty minutes can pick up two different artefacts if someone rewrites the tag halfway through. It is a failure that is almost impossible to diagnose afterwards.
1.2. Configuration fully externalised
No value that changes between rutas-norte-dev, rutas-norte-pre and rutas-norte-pro can live inside the image. The same image, byte for byte, has to be able to run in all three environments.
- What changes and is not secret → ConfigMap (internal URLs, timeouts, log level).
- What changes and is secret → Secret, populated by External Secrets Operator from the corporate store (10-05).
- What does not change → can stay in the image.
Why it matters: if the image carries configuration inside it, what is tested in pre is not what is deployed to pro, and the whole chain of trust we built in 08-05 stops meaning anything.
1.3. Processes with no local state
Sessions, temporary files that must survive, shared in-memory counters: none of that can live in the pod. At Rutas Norte, the user session is signed with a JWT and the shopping basket is kept in redis-cache.
Why it matters: the HPA creates and destroys replicas continuously over the May bank-holiday weekend. If state lives in the pod, the user loses their basket every time autoscaling reduces the replica count.
1.4. Clean shutdown on SIGTERM
When Kubernetes removes a pod, it sends SIGTERM to the main process and waits terminationGracePeriodSeconds. The application must:
- Stop accepting new requests.
- Finish the ones already in flight.
- Close connections to the database and to Redis.
- Exit with code 0.
// Excerpt from the bookings-api startup (server.js)
const server = app.listen(8080);
let shuttingDown = false;
// /ready returns 503 as soon as shutdown begins: this way the Endpoint
// is withdrawn BEFORE we stop accepting connections.
app.get('/ready', (req, res) => {
if (shuttingDown) return res.status(503).json({ status: 'shutting-down' });
return res.status(200).json({ status: 'ready' });
});
process.on('SIGTERM', async () => {
shuttingDown = true;
// Give kube-proxy and the Ingress time to update their tables.
await new Promise((r) => setTimeout(r, 5000));
server.close(async () => {
await pool.end(); // connections to bookings-postgres
await redis.quit();
process.exit(0);
});
});That five-second pause looks like a dirty trick, and in a way it is, but it answers something real: the withdrawal of the Endpoint and the sending of SIGTERM happen in parallel, not in sequence. Without the pause, the pod stops accepting connections while the load balancer is still sending them, and the user sees 502 errors on every deployment.
1.5. Structured logs to stdout
As we saw in 07-05, one JSON object per line to stdout, with no log files and no rotation of its own. Minimum fields at Rutas Norte: ts, level, message, trace_id, path, status, duration_ms.
1.6. Distinct health endpoints
Two endpoints with different semantics, not just one:
| Endpoint | Question it answers | What it checks in bookings-api |
|---|---|---|
/health |
Is the process alive? | Only that the event loop responds |
/ready |
Can it serve traffic right now? | Connection to bookings-postgres and redis-cache, and that it is not shutting down |
Why it matters: if /health checks the database, a PostgreSQL outage causes the kubelet to restart every API pod in a loop, turning a degradation into a total outage.
1.7. Exposed metrics
bookings-api exposes /metrics in Prometheus format, with at least the request counter by route and status code, and the latency histogram. That is what will feed the SLO in 11-06 and the canary analysis in 11-04.
- The complete set of objects in a production component
This is the table worth keeping to hand when reviewing any new deployment. Each row is an object, what it contributes, and exactly what happens if it is missing.
| Object | What it contributes | What happens if it is missing |
|---|---|---|
| Namespace | Isolation boundary, quotas and policies per environment | Everything lands in default, with no quota and no policy; impossible to separate environments |
| ServiceAccount | The pod's identity to the API and to the cloud | The default one is used, with undetermined permissions and no traceability in the audit log |
| ConfigMap | Non-sensitive configuration, versioned in Git | Values baked into the image or into the Deployment; changing one forces a rebuild |
| Secret (via External Secrets) | Credentials synchronised from the corporate store | Secrets applied by hand in the cluster, with no rotation and no record of who put them there |
| Deployment | Replicas, controlled updates, securityContext, probes, resources, topology spread |
Without it there are no managed pods: a bare pod is neither recreated nor updated |
| Service | Stable DNS name and internal load balancing | You have to discover pod IPs by hand; every restart breaks the connection |
| Ingress with TLS | Entry from the internet with a certificate | The service is not reachable from outside, or it is via an unencrypted NodePort |
| HPA | Replica count matched to real load | The May bank-holiday weekend saturates the fixed replicas, or you overpay all year round |
| PodDisruptionBudget | A minimum number of replicas during maintenance and drains | A node drain can leave the service at zero replicas |
| NetworkPolicy | Only the authorised conversations | Any compromised pod in the cluster can reach the API and the database |
| ServiceMonitor | Prometheus discovers and scrapes the metrics | The component appears neither in Grafana nor in alerts: it is blind |
topologySpreadConstraints (in the Deployment) |
Replicas spread across zones and nodes | All three replicas can end up on the same node; that node goes down and so does the service |
| Probes (in the Deployment) | Traffic only to healthy pods and restarts for hung ones | Traffic is routed to pods that are still starting; hung ones never recover |
Thirteen rows. The temptation when deploying something new is to do the first five and leave the rest "for when there is time". Experience says that the ones left out are exactly the ones you need on the day of the incident.
graph TB
subgraph ns["Namespace rutas-norte-pro"]
subgraph seg["Security and identity"]
SA[ServiceAccount]
NP[NetworkPolicy]
end
subgraph cfg["Configuration"]
CM[ConfigMap]
SEC[Secret via ESO]
end
subgraph carga["Workload"]
DEP[Deployment<br/>probes + resources + spread]
HPA[HPA]
PDB[PodDisruptionBudget]
end
subgraph red["Exposure"]
SVC[Service]
ING[Ingress + TLS]
end
SM[ServiceMonitor]
end
SA --> DEP
CM --> DEP
SEC --> DEP
DEP --> SVC
SVC --> ING
HPA -.scales.-> DEP
PDB -.protects.-> DEP
NP -.filters.-> DEP
SM -.scrapes.-> SVC
- Complete manifests for
bookings-api
bookings-apiThe following files live in k8s/base/bookings-api/ in the manifest repository, and the Kustomize overlays (10-04) adjust replicas, resources and domain per environment. Here they are shown already resolved for rutas-norte-pro.
3.1. ServiceAccount and configuration
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-api
namespace: rutas-norte-pro
annotations:
# Federated identity on EKS (10-06): allows reading from the invoices bucket
# without any long-lived access key.
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/rutasnorte-pro-bookings-api
# The pod does not need to talk to the Kubernetes API: we do not mount the token.
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: bookings-api-config
namespace: rutas-norte-pro
data:
NODE_ENV: "production"
LOG_LEVEL: "info"
# Internal DNS: short name because we are in the same namespace.
REDIS_URL: "redis://redis-cache:6379"
# The CloudNativePG connection pooler; we will see it in 11-02.
PG_HOST: "bookings-postgres-pooler-rw"
PG_PORT: "5432"
PG_DATABASE: "bookings"
PG_POOL_MAX: "20"
PAYMENTS_URL: "https://pagos.proveedorexterno.example/v2"
PAYMENTS_TIMEOUT_MS: "4000"
BOOKING_TTL_SECONDS: "900"
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: bookings-api-secrets
namespace: rutas-norte-pro
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-rutasnorte
kind: ClusterSecretStore
target:
name: bookings-api-secrets # name of the Secret that will be created
creationPolicy: Owner
data:
- secretKey: PG_USER
remoteRef: { key: pro/bookings-api/db, property: user }
- secretKey: PG_PASSWORD
remoteRef: { key: pro/bookings-api/db, property: password }
- secretKey: JWT_SIGNING_KEY
remoteRef: { key: pro/bookings-api/jwt, property: key }
- secretKey: PAYMENTS_API_KEY
remoteRef: { key: pro/bookings-api/payments, property: api_key }Two details that are often overlooked. The first, automountServiceAccountToken: false: bookings-api does not call the Kubernetes API, so mounting the token merely adds attack surface (08-01). The second, refreshInterval: 1h: the ExternalSecret re-reads from the store every hour, so a credential rotation propagates on its own; what it does not do by itself is restart the pods, which is why further down we use a checksum in the annotations.
3.2. Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: bookings-api
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/component: backend
rutasnorte.example/team: development
spec:
# replicas is NOT set here: the HPA governs it and Argo CD ignores it
# with ignoreDifferences (10-05). If we set it, every sync would
# return the replica count to the value in the file.
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0 # we never drop below the current number of healthy replicas
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
template:
metadata:
labels:
app.kubernetes.io/name: bookings-api
app.kubernetes.io/part-of: rutas-norte
rutasnorte.example/team: development
annotations:
# Changes when the ConfigMap changes: forces a rollout on configuration changes.
rutasnorte.example/config-checksum: "sha256-8f1d2a"
spec:
serviceAccountName: bookings-api
automountServiceAccountToken: false
terminationGracePeriodSeconds: 45 # > the 5 s wait + in-flight requests
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway # we prefer service over perfect symmetry
labelSelector:
matchLabels:
app.kubernetes.io/name: bookings-api
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: bookings-api
containers:
- name: api
# Digest, not tag: written by the pipeline in 11-03.
image: registry.rutasnorte.example/rutasnorte/bookings-api@sha256:3f9c1b7e5a04c2d8f61b93ae7c05d2419e8f6ab3c4d5e6f708192a3b4c5d6e7f
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
- name: metrics
containerPort: 9090
envFrom:
- configMapRef:
name: bookings-api-config
- secretRef:
name: bookings-api-secrets
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
memory: 512Mi # no CPU limit: avoids throttling (09-06)
startupProbe:
# Protects startup: up to 60 s (12 x 5 s) before the pod is given up for dead.
httpGet: { path: /health, port: http }
periodSeconds: 5
failureThreshold: 12
livenessProbe:
httpGet: { path: /health, port: http }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet: { path: /ready, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2 # leaves the load balancer quickly
successThreshold: 1
lifecycle:
preStop:
exec:
# Extra safety net on top of the SIGTERM handling in section 1.4.
command: ["/bin/sh", "-c", "sleep 5"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}Notes on the less obvious decisions:
maxUnavailable: 0withmaxSurge: 25%: during the deployment there is never less capacity than there was before. It costs 25 % in temporary resources; inproit is worth it.- No
limits.cpu: as we reasoned in 09-06, a CPU limit causes throttling with latency spikes in the 95th percentile even when the node is idle. Withrequestsset properly and a ResourceQuota on the namespace (03-04), the risk is bounded. readOnlyRootFilesystem: trueforces theemptyDiron/tmp. It is a five-minute annoyance that cuts off half a dozen attack techniques at the root (08-02).whenUnsatisfiable: ScheduleAnyway: withDoNotSchedule, over the May bank-holiday weekend the HPA would ask for replicas that cannot be placed if a zone is full. We prefer badly spread replicas to non-existent ones.
3.3. Service, Ingress, HPA, PDB, NetworkPolicy and ServiceMonitor
apiVersion: v1
kind: Service
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: bookings-api
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: bookings-api
ports:
- name: http
port: 80
targetPort: http
- name: metrics
port: 9090
targetPort: metrics
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bookings-api
namespace: rutas-norte-pro
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
nginx.ingress.kubernetes.io/proxy-body-size: "1m"
nginx.ingress.kubernetes.io/limit-rps: "200"
spec:
ingressClassName: nginx
tls:
- hosts: ["api.rutasnorte.example"]
secretName: api-rutasnorte-tls # filled in by cert-manager (04-05)
rules:
- host: api.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bookings-api
port:
name: http
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: bookings-api
minReplicas: 4
maxReplicas: 40 # sized for the May bank-holiday weekend
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 65 }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # scale up fast
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300 # scale down slowly
policies:
- type: Percent
value: 25
periodSeconds: 60
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
minAvailable: 75%
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: bookings-api
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
ports:
- { protocol: TCP, port: 8080 }
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitoring }
ports:
- { protocol: TCP, port: 9090 }
egress:
- to:
- podSelector:
matchLabels: { cnpg.io/cluster: bookings-postgres }
ports: [{ protocol: TCP, port: 5432 }]
- to:
- podSelector:
matchLabels: { app.kubernetes.io/name: redis-cache }
ports: [{ protocol: TCP, port: 6379 }]
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector:
matchLabels: { k8s-app: kube-dns }
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
ports: [{ protocol: TCP, port: 443 }] # pagos.proveedorexterno.example
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
release: kube-prometheus-stack # the label Prometheus selects on
spec:
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
endpoints:
- port: metrics
path: /metrics
interval: 30sThe egress rule towards 0.0.0.0/0 with private exclusions deserves a comment: as we saw in 04-06, NetworkPolicies do not understand DNS names, so you cannot write "allow egress to pagos.proveedorexterno.example". What you can do is allow 443 towards the internet excluding the private ranges, so that a compromised pod cannot use that rule to move laterally inside the corporate network.
- Complete manifests for
web-store
web-storeweb-store is simpler: nginx serving static files. It has no secrets, does not talk to the database and its probes are trivial. But it needs exactly the same discipline.
apiVersion: v1
kind: ConfigMap
metadata:
name: web-store-nginx
namespace: rutas-norte-pro
data:
default.conf: |
server {
listen 8080;
root /usr/share/nginx/html;
# SPA: any unknown path returns index.html
location / { try_files $uri $uri/ /index.html; }
location = /health { access_log off; return 200 "ok\n"; }
location ~* \.(js|css|woff2|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: web-store
app.kubernetes.io/part-of: rutas-norte
rutasnorte.example/team: development
spec:
revisionHistoryLimit: 5
strategy:
rollingUpdate: { maxSurge: 25%, maxUnavailable: 0 }
selector:
matchLabels: { app.kubernetes.io/name: web-store }
template:
metadata:
labels:
app.kubernetes.io/name: web-store
app.kubernetes.io/part-of: rutas-norte
spec:
serviceAccountName: web-store
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 10002
seccompProfile: { type: RuntimeDefault }
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app.kubernetes.io/name: web-store }
containers:
- name: nginx
image: registry.rutasnorte.example/rutasnorte/web-store@sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
ports: [{ name: http, containerPort: 8080 }]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
readinessProbe:
httpGet: { path: /health, port: http }
periodSeconds: 5
livenessProbe:
httpGet: { path: /health, port: http }
periodSeconds: 15
lifecycle:
preStop: { exec: { command: ["/bin/sh", "-c", "sleep 5"] } }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: conf, mountPath: /etc/nginx/conf.d }
- { name: cache, mountPath: /var/cache/nginx }
- { name: run, mountPath: /var/run }
volumes:
- name: conf
configMap: { name: web-store-nginx }
- { name: cache, emptyDir: {} }
- { name: run, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
selector: { app.kubernetes.io/name: web-store }
ports: [{ name: http, port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-store
namespace: rutas-norte-pro
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts: ["www.rutasnorte.example"]
secretName: www-rutasnorte-tls
rules:
- host: www.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: web-store, port: { name: http } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: web-store }
minReplicas: 3
maxReplicas: 15
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
minAvailable: 2
selector:
matchLabels: { app.kubernetes.io/name: web-store }Note that readOnlyRootFilesystem: true with nginx forces you to mount emptyDir on /var/cache/nginx and /var/run, and that the listen is on 8080 rather than 80: an unprivileged process cannot open ports below 1024.
- Order of application and why it matters
With Argo CD (10-05) the order is resolved by sync waves (argocd.argoproj.io/sync-wave), but it is worth understanding the logic, because it is the same one that applies when deploying by hand in dev or when debugging.
| Wave | Objects | Reason |
|---|---|---|
| -2 | Namespace, ResourceQuota, LimitRange | Nothing exists outside a namespace |
| -1 | ServiceAccount, RBAC, NetworkPolicy | The identity and the rules must exist before the workload |
| 0 | ConfigMap, ExternalSecret | The Deployment fails to start if the ConfigMap is not there |
| 1 | Deployment, Service | The workload and its stable name |
| 2 | Ingress, HPA, PDB, ServiceMonitor | They depend on the Service and Deployment existing |
Kubernetes is eventually consistent: if you apply the Deployment before the ConfigMap, the pods go into CreateContainerConfigError and recover on their own when the ConfigMap appears. The order is not a strict technical obligation, it is a way of keeping the deployment from generating spurious alerts and minutes of confusion.
There are two exceptions where the order really is mandatory: CRDs before their custom resources (applying a ServiceMonitor without the Prometheus operator installed gives a hard error), and the NetworkPolicy before the pod in environments with deny-all, because otherwise the pod starts, its readiness probe against the database fails and it goes into unnecessary restarts.
- Layer-by-layer verification, from the inside out
Deployed is not the same as working. This sequence of eight checks goes from the innermost layer to the outermost, and each step only makes sense if the previous one passed. It is the routine whoever deploys should run, and the one that structures the "a deployment has gone wrong" runbook in 11-06.
graph LR A[1 Pod starts] --> B[2 Container ready] B --> C[3 Endpoints populated] C --> D[4 DNS resolves] D --> E[5 Service responds<br/>from the cluster] E --> F[6 Ingress responds<br/>from outside] F --> G[7 Certificate valid] G --> H[8 Metrics scraped] H --> I[9 Alerts silent]
Layer 1: the pod starts
kubectl -n rutas-norte-pro rollout status deploy/bookings-api --timeout=5m
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=bookings-api -o wideNAME READY STATUS RESTARTS AGE NODE
bookings-api-7d4b8c9f5d-2xk9p 1/1 Running 0 62s ip-10-0-2-41
bookings-api-7d4b8c9f5d-8mqrt 1/1 Running 0 58s ip-10-0-3-17
bookings-api-7d4b8c9f5d-p4vzn 1/1 Running 0 55s ip-10-0-1-93
bookings-api-7d4b8c9f5d-w6hxl 1/1 Running 0 51s ip-10-0-2-88If it fails: kubectl describe pod and look at the events. ImagePullBackOff points to the registry or the digest; CreateContainerConfigError, to a ConfigMap or Secret that does not exist; Pending, to insufficient resources or an impossible topologySpreadConstraint.
Layer 2: the container is ready
READY 1/1 already says so, but it is worth telling "started" apart from "ready":
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=bookings-api \
-o custom-columns='POD:.metadata.name,READY:.status.conditions[?(@.type=="Ready")].status,RESTARTS:.status.containerStatuses[0].restartCount'If a pod is Running but 0/1, the readiness probe is failing: almost always bookings-api cannot connect to bookings-postgres or to redis-cache. You check with kubectl logs and resolve it by looking at the NetworkPolicy or the credentials.
Layer 3: the Endpoints are populated
This is the step most people skip and the one that most often explains a 503.
kubectl -n rutas-norte-pro get endpointslice -l kubernetes.io/service-name=bookings-api -o yaml | grep -A3 addressesEmpty Endpoints with healthy pods means a misaligned selector: the labels in the Service's spec.selector do not match those in template.metadata.labels. It is a silent failure: no object reports an error, traffic simply never arrives.
Layer 4: DNS resolves
kubectl -n rutas-norte-pro run debugger --rm -it --restart=Never \
--image=registry.rutasnorte.example/tools/netdebug:1.4 -- \
nslookup bookings-api.rutas-norte-pro.svc.cluster.localIf it does not resolve: CoreDNS is down, or a NetworkPolicy does not allow egress on port 53 towards kube-system (the most common failure after rolling out deny-all).
Layer 5: the Service responds from inside the cluster
kubectl -n rutas-norte-pro run debugger --rm -it --restart=Never \
--image=registry.rutasnorte.example/tools/netdebug:1.4 -- \
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \
http://bookings-api/healthIf the pod responds but the Service does not, suspect the targetPort or the ingress NetworkPolicy.
Layer 6: the Ingress responds from outside
curl -s -o /dev/null -w 'http=%{http_code} tls=%{ssl_verify_result} t=%{time_total}s\n' \
https://api.rutasnorte.example/healthIf nginx returns 404, check ingressClassName and the host. If it returns 503, the Ingress points to a Service with no Endpoints: you skipped layer 3.
Layer 7: the certificate is valid
kubectl -n rutas-norte-pro get certificate api-rutasnorte-tls
echo | openssl s_client -connect api.rutasnorte.example:443 -servername api.rutasnorte.example 2>/dev/null \
| openssl x509 -noout -issuer -datesNAME READY SECRET AGE
api-rutasnorte-tls True api-rutasnorte-tls 184d
issuer=C=US, O=Let's Encrypt, CN=R11
notBefore=Apr 18 09:12:44 2026 GMT
notAfter=Jul 17 09:12:43 2026 GMTA Certificate stuck at False for more than a few minutes is usually the ACME challenge failing: look at the Order and the Challenge (04-05).
Layer 8: Prometheus scrapes the metrics
kubectl -n monitoring port-forward svc/prometheus-operated 9090:9090 &
curl -sG 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=up{job="bookings-api"}' | jq '.data.result[] | {pod: .metric.pod, value: .value[1]}'{"pod":"bookings-api-7d4b8c9f5d-2xk9p","value":"1"}
{"pod":"bookings-api-7d4b8c9f5d-8mqrt","value":"1"}A missing target is almost always the ServiceMonitor's release: label, which has to match the Prometheus serviceMonitorSelector.
Layer 9: the alerts are silent
curl -s http://alertmanager.rutas-norte.example/api/v2/alerts \
| jq '[.[] | select(.labels.service=="bookings-api")] | length'Zero active alerts fifteen minutes after the deployment is the real criterion for "it went well". Before then, the rule windows (for: 10m) have not yet closed.
- The "production-ready" checklist
This table is pasted verbatim into the description of the pull request that promotes to rutas-norte-pro. Each line is ticked by a person, not by a script.
| # | Check | How it is verified | OK |
|---|---|---|---|
| 1 | Image by digest, signed and scanned with no criticals | cosign verify + Trivy report in the pipeline |
☐ |
| 2 | Configuration outside the image | ConfigMap diff between pre and pro |
☐ |
| 3 | No local state in the pod | Code review; session in JWT and basket in Redis | ☐ |
| 4 | Clean shutdown verified | Load test during a rollout restart: 0 5xx errors |
☐ |
| 5 | Distinct probes, including a startupProbe |
Manifest reviewed | ☐ |
| 6 | requests and limits set, with no CPU limit |
Manifest reviewed; VPA recommendation consulted | ☐ |
| 7 | securityContext non-root, no escalation, read-only root |
Kyverno in enforce mode guarantees it (08-03) | ☐ |
| 8 | topologySpreadConstraints by zone and node |
Manifest reviewed | ☐ |
| 9 | PDB consistent with the HPA's minReplicas |
minAvailable < minReplicas |
☐ |
| 10 | HPA with min and max sized for the expected peak |
May bank-holiday calculation documented | ☐ |
| 11 | Explicit ingress and egress NetworkPolicies | kubectl describe netpol |
☐ |
| 12 | ServiceMonitor active and Grafana dashboard in place | up{job=...} == 1 and a link to the dashboard |
☐ |
| 13 | Alerts with a runbook link in their annotations | PrometheusRule rule reviewed | ☐ |
| 14 | Ingress with a valid TLS certificate and automatic renewal | Certificate in Ready |
☐ |
| 15 | Layer verification 1-9 run after deploying | Output pasted into the pull request | ☐ |
Row 9 is the one that prevents the most incidents and the one most often forgotten: if the HPA can go down to 2 replicas and the PDB demands minAvailable: 3, a node drain blocks indefinitely and cluster maintenance is left hanging.
- The characteristic failure of each layer
Each layer fails in a recognisable way. Knowing the characteristic symptom saves half the diagnosis time; the in-depth procedure is in 07-06.
| Layer | Symptom | Most likely cause | First command |
|---|---|---|---|
| Pod | ImagePullBackOff |
Non-existent digest or registry credential | kubectl describe pod |
| Pod | Prolonged Pending |
No resources, or topologySpread with DoNotSchedule |
kubectl describe pod (scheduler events) |
| Pod | CrashLoopBackOff |
Fails on startup, or a livenessProbe with too tight a threshold |
kubectl logs --previous |
| Container | Running but 0/1 |
Dependency unreachable from /ready |
kubectl logs + test the dependency |
| Endpoints | Empty list with healthy pods | Misaligned Service selector | kubectl get endpointslice |
| DNS | NXDOMAIN from a pod |
CoreDNS, or egress on 53 blocked | nslookup from a debug pod |
| Service | Connects but times out | Wrong targetPort or ingress NetworkPolicy |
curl the Service from inside the cluster |
| Ingress | nginx 404 | Wrong ingressClassName or host |
kubectl describe ing |
| Ingress | 503 | Service with no ready Endpoints | go back to layer 3 |
| TLS | Browser warning | Certificate not Ready, ACME challenge failed |
kubectl describe certificate |
| Metrics | Target missing in Prometheus | The ServiceMonitor's release: label |
up{job="..."} |
| Alerts | An alert that will not clear | Badly written rule or unrealistic threshold | Run the expression in Prometheus |
Common Mistakes and Tips
- Deploying without
preStoporSIGTERMhandling and blaming the Ingress for the 502s. It is the most frequent mistake when moving fromdevto production. The definitive test: run a sustained load with k6 and issuekubectl rollout restarthalfway through. If a single 5xx appears, the clean shutdown is not right. - Putting the
livenessProbeon an endpoint that checks dependencies. It turns a database degradation into a mass restart of the entire API. The liveness probe must only look inwards. - Setting
replicasin the Deployment when you have an HPA. Every Argo CD sync returns the replica count to the value in the file, undoing the scaling. It is fixed withignoreDifferences(10-05) and removing the field. - A PDB with
minAvailableequal to the replica count. It blocks every drain. Use percentages and check consistency with the HPA'sminReplicas. - Forgetting the
release:label on the ServiceMonitor. The component deploys perfectly and stays invisible to monitoring. Nobody notices until the first incident. - Tip: automate the checklist wherever you can, but keep the human review. Kyverno can enforce rows 6, 7 and 11; nobody can enforce row 10 by policy, because it requires having thought about the May bank-holiday weekend.
- Tip: keep the layer-verification output in the pull request. When something fails three weeks later, knowing that on deployment day the certificate was valid and the metrics were being scraped narrows the search enormously.
Exercises
Exercise 1: spotting missing objects
A colleague has deployed a new component, promotions-web, in rutas-norte-pro with these objects: Deployment (3 fixed replicas, no probes), Service and Ingress. List which objects are missing from the thirteen in the table in section 2 and describe, for each one, the concrete scenario in which its absence will cause an incident at Rutas Norte.
Exercise 2: layer verification of a real failure
After deploying bookings-api in rutas-norte-pre, this is the situation:
$ kubectl -n rutas-norte-pre get pods -l app.kubernetes.io/name=bookings-api
NAME READY STATUS RESTARTS AGE
bookings-api-6c9f7d8b4c-hh2ks 0/1 Running 0 4m
bookings-api-6c9f7d8b4c-tq5rl 0/1 Running 0 4m
$ curl -s -o /dev/null -w '%{http_code}\n' https://api-pre.rutasnorte.example/health
503State which of the nine layers the failure is in, which three commands you would run and in what order, and what the two most likely causes are.
Exercise 3: consistency between HPA and PDB
notifications-worker has an HPA governed by KEDA with minReplicaCount: 1 and maxReplicaCount: 20, and a PDB with minAvailable: 2. The platform team needs to drain a node in order to upgrade the cluster and kubectl drain hangs. Explain why and propose the fix, justifying the value you choose.
Solutions
Solution 1. Ten objects are missing:
| Missing | Incident scenario |
|---|---|
| Its own ServiceAccount | Uses the default one; the audit log cannot attribute actions and it inherits unintended permissions |
| ConfigMap | Configuration goes in the image: promoting from pre to pro requires a rebuild, breaking digest traceability |
| Secret via ESO | Credentials applied by hand, with no rotation |
| Probes | Traffic is routed to pods that are still starting: 502s on every deployment |
securityContext |
Kyverno in enforce mode (08-03) will reject the pod; if it is in audit mode, it runs as root |
topologySpreadConstraints |
All 3 replicas can land on the same node; that node is lost and so is the service |
| HPA | The 3 fixed replicas saturate over the May bank-holiday weekend |
| PDB | A drain can take all 3 replicas at once |
| NetworkPolicy | With deny-all in the namespace it will not even start; without it, it is exposed to lateral movement |
| ServiceMonitor | No metrics and no alerts: the component is invisible during the first incident |
Solution 2. The failure is in layer 2 (container not ready), and the 503 in layer 6 is a consequence: with no ready pods, there are no Endpoints. Commands, in order:
kubectl -n rutas-norte-pre logs -l app.kubernetes.io/name=bookings-api --tail=50
kubectl -n rutas-norte-pre describe pod -l app.kubernetes.io/name=bookings-api | grep -A10 Events
kubectl -n rutas-norte-pre get endpointslice -l kubernetes.io/service-name=bookings-apiMost likely causes: (a) /ready fails because there is no connection to bookings-postgres (the ExternalSecret credential not synchronised in pre, or a NetworkPolicy missing the egress rule to 5432); (b) the application starts but takes longer than the startupProbe allows, although in that case you would normally see restarts, which here are 0, which reinforces hypothesis (a).
Solution 3. With minReplicaCount: 1, KEDA can leave the worker at a single replica during quiet hours. The PDB requires 2 to remain available, a condition that is impossible with 1 replica in total, so drain cannot evict the pod and waits indefinitely. Fix: use minAvailable: 1 or, better, maxUnavailable: 1, which is expressed in terms of how many replicas can be lost and works correctly across the whole range from 1 to 20. A complementary alternative: raise minReplicaCount to 2 if the business requires notifications never to be left without capacity, accepting the cost of the permanent replica.
Conclusion
We have walked, from start to finish, the path of a stateless web application into production: the seven requirements the application must meet before you touch the YAML, the thirteen objects that make up a complete component and what you lose with each one that is missing, the full manifests for bookings-api and web-store that serve as the reference for the rest of the course, the order of application, the layer-by-layer verification from the inside out with the exact command for each step, the checklist that can be reviewed in a pull request and the characteristic symptom of each layer's failure.
The key lesson is that "it is deployed" and "it is production-ready" are very different statements, and that the difference between them is precisely the objects and the checks that it is tempting to leave for later.
All of this has been relatively comfortable for one reason: web-store and bookings-api store nothing. They can be killed, moved and multiplied without consequences. In the next lesson, Running Stateful Applications, we move into the territory where that stops being true: bookings-postgres holds the personal data of Rutas Norte's customers, you cannot replace a pod light-heartedly, and the first question to answer honestly is whether that database should live in Kubernetes at all.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
