We closed module 6 with an uncomfortable diagnosis: the Rutas Norte platform is already powerful — it has state, it is extensible and it is automated — but it is completely opaque. We do not know whether bookings-api is genuinely healthy, nor how much memory bookings-postgres really consumes, nor what happened last night at three in the morning. Module 7 is devoted to opening that black box, and the first step is not to measure: it is teaching Kubernetes to tell a live container apart from a working application.
This lesson is about probes: the three mechanisms Kubernetes uses to ask each container periodically whether it is alive, whether it is ready to receive traffic and whether it has finished starting up. They are the piece that has been missing since module 2: there we said explicitly that a zero-downtime deployment is not possible without probes, and here we settle that debt. They are also, by a wide margin, the Kubernetes feature that causes the most production incidents when misconfigured: an overly aggressive livenessProbe can bring down an entire platform in a matter of minutes. Let us understand them thoroughly.
Contents
- The problem: "the process is alive" does not mean "the application works"
- The three probes and what Kubernetes does with each result
- The four handlers:
httpGet,tcpSocket,execandgrpc - The timing parameters and the exact detection-time calculation
- The most expensive mistake in the module: cascading liveness
startupProbe: the answer to the slow start-up ofbookings-postgres- Probes and zero-downtime deployments: settling the debt from 02-04
- Clean shutdown:
terminationGracePeriodSeconds,preStopand the 502s - Designing the probes for every Rutas Norte component
- Common mistakes and tips
- Exercises
- The problem: "the process is alive" does not mean "the application works"
Until now, the only health signal Kubernetes had about our containers was brutally simple: is the main process (PID 1) still running? If the process ends, the kubelet applies the restartPolicy we studied in 02-01 and restarts the container. If the process does not end, Kubernetes assumes everything is fine.
That assumption is false most of the time. Let us look at a real case involving bookings-api.
bookings-api is a Node.js API that keeps a connection pool against bookings-postgres, with a maximum of 20 connections. One Tuesday afternoon, a badly indexed query on the "travel history" screen starts taking 40 seconds. Users hit reload. Within two minutes, all 20 connections in the pool are taken up by slow queries. From that point on:
- The Node.js process is still alive: PID 1 exists, no uncaught exception has been thrown.
- The HTTP server is still accepting connections: the socket is open.
- But every request that needs the database waits for a free slot in the pool and eventually times out.
From Kubernetes' point of view, the pod is perfect. From the point of view of the customer trying to buy a Bilbao–Santander ticket, the platform is down. And worse still: the bookings-api Service keeps sending traffic to that pod, and to the other four that are in exactly the same state.
flowchart LR
C[Client] --> S[Service bookings-api]
S --> P1[Pod 1<br/>process alive<br/>pool exhausted]
S --> P2[Pod 2<br/>process alive<br/>pool exhausted]
S --> P3[Pod 3<br/>process alive<br/>pool exhausted]
P1 -.timeout.-> DB[(bookings-postgres)]
P2 -.timeout.-> DB
P3 -.timeout.-> DB
Probes exist precisely to close this gap. They give the application the chance to answer for itself two different questions:
- Am I so broken that the best thing you can do is restart me? →
livenessProbe. - Can I serve requests right now? →
readinessProbe.
They are different questions, with different consequences, and confusing them is the root of almost every disaster in this lesson.
- The three probes and what Kubernetes does with each result
Kubernetes defines three probes per container. All of them are optional and all of them are declared inside the container specification, not the pod's.
livenessProbe — are you still alive?
It checks whether the container is in a state it can no longer recover from on its own. If it fails the configured number of times, the kubelet kills the container and applies the restartPolicy (usually Always, so it restarts it). The pod is not recreated: it is the same pod, with the same name and the same IP, with its RESTARTS counter incremented.
Legitimate cases: a deadlock the process cannot escape, an infinite loop that stops responding, a known memory leak with no short-term fix. If your application has no state that can only be escaped by restarting, it is perfectly valid not to define a livenessProbe.
readinessProbe — can you serve traffic?
It checks whether the container can serve requests right now. If it fails, Kubernetes restarts nothing: it simply marks the pod as not ready (Ready=False) and the endpoints controller removes its IP address from the Service's EndpointSlice. Traffic stops arriving, but the pod stays alive. When the probe passes again, the IP is added back automatically.
This is the correct mechanism for the exhausted-pool case: the pod steps aside for a moment, stops receiving requests it cannot serve, drains its slow queries and comes back.
startupProbe — have you finished starting up?
It is a start-up probe. While it is running and has not yet passed, the other two probes are disabled. When it passes for the first time, it stops running for good and liveness and readiness come into play. If it never passes within its time budget, the container is killed and restarted.
It is meant for applications with slow or highly variable start-ups, where setting a huge initialDelaySeconds on liveness would make real failure detection painfully slow for the rest of the container's life.
Comparison table
| Aspect | livenessProbe |
readinessProbe |
startupProbe |
|---|---|---|---|
| Question it answers | Should I restart you? | Do I send you traffic? | Have you finished starting up? |
| Action on failure | Kills and restarts the container | Removes the IP from the Endpoints | Kills and restarts the container |
| Action on success | Nothing (keeps watching) | Returns the IP to the Endpoints | Disables itself for good |
| When does it run? | Throughout the container's life | Throughout the container's life | Only until the first success |
| Can it check external dependencies? | Never | Yes, and it should | Only the bare minimum to start |
| Does it affect the Service? | Not directly | Yes, directly | Indirectly (it blocks readiness) |
| Is it mandatory? | No | Practically yes, if there is a Service | Only if start-up is slow |
| Risk of misuse | Very high (cascading restarts) | Low | Low |
One rule worth memorising: liveness protects the container from itself; readiness protects the container's clients.
- The four handlers:
httpGet, tcpSocket, exec and grpc
httpGet, tcpSocket, exec and grpcEach probe uses exactly one of these four mechanisms to perform the check.
httpGet
The most common one and the most advisable for HTTP services. The kubelet issues a GET request to the pod's IP, on the given port and path.
livenessProbe:
httpGet:
path: /health # path that bookings-api will serve
port: 8080 # number or name of a port declared in ports
scheme: HTTP # HTTP (default) or HTTPS
httpHeaders:
- name: X-Probe-Source
value: kubeletImportant points for beginners:
- Any response code between 200 and 399 counts as a success, both inclusive. A
204is a success. A301is too, and this comes as a surprise: if your application redirects/healthto/login, the probe will pass even though the application is broken. A400,404,500or503is a failure. - The request is made by the node's kubelet, not by another pod. That is why the NetworkPolicies in
rutas-norte-prodo not block it: it does not go through the normal pod network. - The
portfield accepts the name of a port declared inports, which is far more readable and resilient to change. - The
httpHeadersfield serves two very practical purposes: identifying in the application logs which requests come from the kubelet (so you can exclude them from traffic metrics) and passing headers the application requires, such as a specificHost. - The kubelet does not send cookies and does not follow authentication: the health endpoint must be reachable without credentials from inside the pod.
tcpSocket
The kubelet tries to open a TCP connection to the given port. If the handshake completes, it is a success; if the connection is refused or times out, it is a failure.
This is the option for services that do not speak HTTP, such as redis-cache or PostgreSQL itself at a low level. Its big limitation: it only checks that something is listening. PostgreSQL can have the port open and be refusing connections because it has hit max_connections, or be in recovery mode. The TCP probe would pass all the same.
exec
The kubelet runs a command inside the container. Success if the exit code is 0, failure in any other case.
It is the most flexible and the most expensive. Every execution means creating a new process inside the container: it consumes the pod's CPU and memory (counted against its limits, which can trigger an OOMKilled if you are cutting it fine) and adds load to the node's runtime. With periodSeconds: 5 across 60 pods you are launching 12 processes per second in the cluster just to ask how things are going.
Tips:
- Use
execonly when there is no HTTP or TCP alternative. - Raise
periodSeconds(10–30 s) compared with what you would use withhttpGet. - The binary must exist in the image. It is a common mistake to write a probe using
curlin adistrolessimage that does not have it: the probe always fails and the container entersCrashLoopBackOff.
grpc
Since Kubernetes 1.24 it has been a stable feature. The kubelet acts as a client of the standard gRPC health checking protocol.
livenessProbe:
grpc:
port: 9000
service: bookings.Availability # optional: name of the service to queryThe application must implement the grpc.health.v1.Health service. It is a success if it answers SERVING. This avoids having to install grpc_health_probe as a binary in the image, which was the previous solution using exec.
Handler comparison
| Handler | When to use it | Cost | Main risk |
|---|---|---|---|
httpGet |
HTTP/REST services (bookings-api, web-store) |
Very low | 3xx codes count as success |
tcpSocket |
TCP services without HTTP (redis-cache) |
Very low | Superficial: it only looks at the socket |
exec |
Checks that require local logic (pg_isready) |
High | Resource consumption and missing binaries |
grpc |
gRPC services | Low | Requires implementing the health service |
- The timing parameters and the exact detection-time calculation
The five timing variables are identical for all three probes. Really understanding them means being able to answer the question "how long does Kubernetes take to notice that this container is dead?" with a number, not with a hunch.
| Parameter | Default | Minimum | Meaning |
|---|---|---|---|
initialDelaySeconds |
0 | 0 | Seconds to wait from container start-up until the first check |
periodSeconds |
10 | 1 | How many seconds between checks |
timeoutSeconds |
1 | 1 | Seconds to wait for the response before counting it as a failure |
successThreshold |
1 | 1 | Consecutive successes to move from failure to success |
failureThreshold |
3 | 1 | Consecutive failures before declaring the probe failed |
Two subtleties that are always forgotten:
successThresholdmust be 1 inlivenessProbeandstartupProbe. OnlyreadinessProbeaccepts larger values. It makes sense: you cannot "half restart" something.timeoutSeconds: 1(the default) is dangerously low for a real application under load. A health endpoint that normally takes 20 ms can take 1.5 s when the node is saturated, and then the probe fails on timeout even though the application is fine.
The calculation
The maximum time from a container ceasing to respond until the kubelet kills it is:
T_detection = initialDelaySeconds (first time only)
+ (failureThreshold - 1) * periodSeconds
+ timeoutSecondsPlus a margin of up to periodSeconds, because the failure may happen right after a successful check. In practice the pessimistic formula is used:
An example with the configuration we will use on bookings-api:
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3In other words: if bookings-api hangs, up to 33 seconds will pass before the kubelet restarts it, and after that you have to add the container's start-up time. If that is unacceptable for the business, you lower periodSeconds to 5 and failureThreshold to 3 → 18 seconds. But the more aggressive the probe, the greater the risk of false positives, and that risk is the subject of the next section.
And for readiness, the same calculation determines how long traffic will keep arriving at a pod that can no longer serve it:
readinessProbe:
httpGet:
path: /ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2Rule of thumb: readiness should be faster and more sensitive than liveness. Taking a pod out of rotation is cheap and reversible; restarting it is not.
- The most expensive mistake in the module: cascading liveness
This is the section to read twice. It is the failure that has brought down whole production platforms more often than any other, and always for the same reason.
The scenario
A Rutas Norte engineer, with the best of intentions, configures the liveness of bookings-api like this:
# WRONG — do not copy this
livenessProbe:
httpGet:
path: /health/full # checks API + PostgreSQL + Redis + payment gateway
port: 8080
periodSeconds: 5
timeoutSeconds: 1
failureThreshold: 2The /health/full endpoint runs a SELECT 1 against bookings-postgres, a PING to redis-cache and a call to pagos.proveedorexterno.example. It looks thorough. It is a bomb.
The May bank-holiday weekend arrives. Traffic multiplies sixfold. bookings-postgres starts responding in 1.2 s instead of 5 ms. Then:
- The health probe times out (1 s) on all ten
bookings-apipods at once, because they all depend on the same database. - After 10 seconds (
2 * 5), the kubelet kills all ten containers simultaneously. - All ten start again. On start-up, each one opens its pool of 20 connections against PostgreSQL: 200 new connections at once against a database that was already drowning.
- PostgreSQL becomes even more saturated. The probes fail again. Back to step 2.
- Kubernetes applies exponential backoff to the restarts, so the pods enter
CrashLoopBackOff. The platform goes from slow to completely down.
flowchart TD
A[Traffic spike] --> B[bookings-postgres responds slowly]
B --> C[Liveness checks PostgreSQL and times out]
C --> D[kubelet kills the 10 bookings-api pods]
D --> E[10 pods start at once and open 200 connections]
E --> B
D --> F[CrashLoopBackOff: total outage]
Note the essential point: the database was slow, not down. Without probes, the platform would have been slow for twenty minutes and would have recovered on its own. With that liveness, it went down completely. The probe amplified the failure instead of mitigating it.
The two golden rules
Rule 1:
livenessProbemust NEVER check external dependencies. Not databases, not caches, not third-party APIs, not other microservices. It must only answer "is this process, in isolation, still capable of handling a request?".
Rule 2:
readinessProbeSHOULD check the dependencies it needs in order to serve. Ifbookings-apicannot talk to PostgreSQL, it cannot serve bookings, and the correct behaviour is for it to leave the Endpoints until it can.
And what happens if readiness fails on all ten pods at once? The Service is left with no Endpoints and the Ingress returns 503. That is bad, but it is honest and reversible: as soon as PostgreSQL recovers, the ten pods are ready again within five seconds, with no cold starts, no connection avalanche, no CrashLoopBackOff. That is the difference between a service degradation and a disaster.
This is why bookings-api will have two different endpoints:
| Endpoint | Used by | What it checks | What it does NOT check |
|---|---|---|---|
/health |
livenessProbe |
Node's event loop responds; there is no deadlock; there is memory to respond | PostgreSQL, Redis, payment gateway |
/ready |
readinessProbe |
Everything above plus a free connection in the PostgreSQL pool and a PING to redis-cache |
The external payment gateway (see note) |
A note about the payment gateway: pagos.proveedorexterno.example is a third party outside our control. If we put it in readiness, an outage at the provider leaves the whole platform without Endpoints, including timetable lookups, which do not need payments. The correct decision is not to include it in any probe and to handle it with a circuit breaker inside the application that returns a specific error only in the payment flow.
startupProbe: the answer to the slow start-up of bookings-postgres
startupProbe: the answer to the slow start-up of bookings-postgresbookings-postgres, after last year's growth, takes between 20 and 180 seconds to accept connections when it starts: if the previous shutdown was dirty, it has to replay the WAL, and that time depends on the pending volume.
Without a startupProbe, we have two bad options:
- Option A:
initialDelaySeconds: 200on liveness. It covers the worst case, but during the first 200 seconds of every restart nobody is watching the container. And worse: you cannot lower the detection period afterwards. - Option B:
failureThreshold: 40withperiodSeconds: 5. It covers start-up, but it also means that, in steady state, a hang will take40 * 5 = 200 secondsto detect.
The startupProbe separates the two time budgets: a generous one for starting up, a strict one for steady-state life.
# Fragment of the postgres container in the bookings-postgres StatefulSet
startupProbe:
exec:
command: ["/bin/sh", "-c", "pg_isready -U rutasnorte -h 127.0.0.1"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30 # 30 * 10 = 300 s of start-up budget
livenessProbe:
exec:
command: ["/bin/sh", "-c", "pg_isready -U rutasnorte -h 127.0.0.1"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3 # in steady state: 35 s to detect a hang
readinessProbe:
exec:
command: ["/bin/sh", "-c", "pg_isready -U rutasnorte -h 127.0.0.1 && psql -U rutasnorte -d bookings -c 'SELECT 1' -tA"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 2How this behaves over time:
sequenceDiagram
participant K as kubelet
participant C as postgres container
K->>C: t=10s startupProbe → failure (replaying WAL)
K->>C: t=20s startupProbe → failure
Note over K: liveness and readiness do NOT run
K->>C: t=90s startupProbe → SUCCESS
Note over K: startupProbe disables itself for good
K->>C: t=100s livenessProbe → success
K->>C: t=100s readinessProbe → success
Note over K: the pod joins the headless Service Endpoints
If after 300 seconds the startupProbe is still failing, the container is killed and restarted. That is the correct behaviour: something really is wrong.
An important detail about the exec: pg_isready and psql do exist in the postgres:16.4 image, and we connect to 127.0.0.1 (inside the pod itself), not to the Service. Checking the Service from a pod's probe would be checking an external dependency, with all the problems described in section 5.
- Probes and zero-downtime deployments: settling the debt from 02-04
In 02-04 we saw RollingUpdate with maxSurge and maxUnavailable, and we wrote that this was not yet a zero-downtime deployment. Now it is clear why.
The Deployment controller considers a new pod "fine" when the pod is Ready. Without a readinessProbe, a pod is considered Ready as soon as its containers start. For bookings-api, that happens about 400 ms after launching node, long before Express is listening on 8080 and the connection pool is open.
The sequence of the silent disaster:
kubectl applywith the new image.- The new pod is created. After 400 ms it is
Runningand, without readiness,Ready. - The endpoints controller adds its IP to the EndpointSlice: it starts receiving real traffic.
- The Deployment, seeing a new pod ready, terminates an old pod.
- For the next 3–8 seconds, the new pod returns
ECONNREFUSEDto all the traffic arriving at it. - This repeats pod by pod. The result: a few seconds of errors for each replica replaced, invisible in
kubectl get pods, very visible in the error rate.
With a readinessProbe, step 2 changes: the pod is Running but Ready=False, it does not join the Endpoints and the Deployment does not move on until the probe passes. That is, literally, the mechanism behind zero-downtime deployment.
minReadySeconds
There is a residual case: applications that pass readiness and then fall over three seconds later (for example, on receiving the first real request that hits faulty new code). minReadySeconds forces the Deployment to wait N seconds with the pod continuously ready before accepting it and carrying on replacing pods.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
replicas: 6
minReadySeconds: 15 # 15 s continuously ready before moving on
progressDeadlineSeconds: 300 # if it makes no progress in 5 min, it is marked as failed
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2
maxUnavailable: 0 # never fewer than 6 pods serving
selector:
matchLabels:
app: bookings-api
environment: pro
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.8.1
ports:
- name: http
containerPort: 8080With maxUnavailable: 0 + readinessProbe + minReadySeconds: 15, a bookings-api deployment is genuinely zero-downtime. The price: it takes longer. A deployment of 6 replicas goes from 40 seconds to about 3 minutes. That is a reasonable price.
A word of warning about progressDeadlineSeconds: if readiness never passes (for example, because the new version has a configuration error), the Deployment gets stuck but the old pods keep serving. After 300 seconds the Deployment is marked as ProgressDeadlineExceeded, which allows it to be detected automatically. It does not roll back on its own: you decide that with kubectl rollout undo.
- Clean shutdown:
terminationGracePeriodSeconds, preStop and the 502s
terminationGracePeriodSeconds, preStop and the 502sWe have solved the arrival of new pods. What remains is the departure of the old ones, which is where the 502s Rutas Norte sees on every deployment come from.
The race
When Kubernetes decides to terminate a pod, two things happen in parallel, with no coordination between them:
sequenceDiagram
participant API as API Server
participant EPC as Endpoints controller
participant KP as kube-proxy / Ingress
participant KL as kubelet
participant P as Pod bookings-api
API->>EPC: pod marked for deletion
API->>KL: pod marked for deletion
par Path A (network)
EPC->>EPC: removes the IP from the EndpointSlice
EPC->>KP: propagates the updated rule
KP->>KP: reprograms iptables/IPVS (100-2000 ms)
and Path B (process)
KL->>P: immediate SIGTERM
end
Note over P,KP: the process dies BEFORE traffic stops arriving → 502
Path B (killing the process) is almost instantaneous. Path A (propagating the endpoint removal to every node and to the Ingress controllers) takes from a few hundred milliseconds to a couple of seconds in a loaded cluster. In that window, the load balancer keeps sending requests to a process that is already shutting down. Each one of those requests is a 502 for a Rutas Norte customer.
The solution: preStop
The preStop hook runs before the SIGTERM, and the kubelet waits for it to finish before sending it. Inserting a pause there gives path A time to complete.
It looks like a dirty trick, and in a way it is, but it is the solution recommended by the Kubernetes documentation itself and the one practically everybody uses in production. During those 10 seconds:
- The container carries on serving requests as normal (nobody has told it to stop).
- The endpoints controller has already removed it, so no new traffic arrives.
- In-flight requests finish quietly.
If your application has an endpoint that deliberately makes readiness fail, a more elegant variant is to call it and then wait. But the sleep works and requires no changes to the application.
The complete termination budget
spec:
terminationGracePeriodSeconds: 45
containers:
- name: api
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]The real timeline:
| Moment | What happens |
|---|---|
| t=0 s | The pod moves to Terminating. preStop starts and the terminationGracePeriodSeconds clock starts |
| t=0 to 2 s | The endpoint is removed from every node and from the Ingress |
| t=0 to 10 s | preStop sleeping. The container serves the in-flight requests |
| t=10 s | preStop finishes. The kubelet sends SIGTERM |
| t=10 to 45 s | The application closes the HTTP server, drains the PostgreSQL pool, commits what is pending and exits |
| t=45 s | If the process is still alive, the kubelet sends SIGKILL. No mercy |
A critical point that is always forgotten: the preStop time is deducted from the grace period, not added to it. With terminationGracePeriodSeconds: 45 and a 10 s preStop, the application only has 35 seconds to shut down after the SIGTERM. Set the number with that in mind.
And as we saw in 02-01, if your process starts via a shell (CMD npm start), the SIGTERM may reach the shell and not Node. Use the exec form (CMD ["node", "server.js"]) or shareProcessNamespace/tini to make sure the signal reaches the right process.
For notifications-worker, which processes confirmation emails, the grace period must cover the longest send in progress: we set terminationGracePeriodSeconds: 90 and the application stops taking new messages from the queue when it receives the SIGTERM.
- Designing the probes for every Rutas Norte component
We now apply all of the above component by component. The design table first, the manifests afterwards.
| Component | Liveness | Readiness | Startup | Notes |
|---|---|---|---|---|
web-store (nginx) |
httpGet / port 80 |
httpGet / port 80 |
No | Static and fast; having both the same is acceptable |
bookings-api |
httpGet /health |
httpGet /ready |
httpGet /health, 60 s |
The reference case of the lesson |
bookings-postgres |
local exec pg_isready |
exec pg_isready + SELECT 1 |
exec pg_isready, 300 s |
Variable start-up because of the WAL |
redis-cache |
exec redis-cli PING |
exec redis-cli PING |
No | Extremely fast start-up |
notifications-worker |
httpGet /health port 8081 |
None | No | It has no Service: readiness adds nothing |
occupancy-reports (CronJob) |
None | None | No | Short-lived Job: success is the exit code |
Two decisions that deserve an explanation:
notifications-workerwithout readiness: readiness only makes sense if something queries the Endpoints. This worker sits behind no Service; it consumes from a queue. Adding readiness would do nothing. It does have liveness, with a small internal HTTP server that answers200while the consumption loop is active.- CronJob without probes: a pod that lives for 4 minutes and whose success is measured by its exit code gains nothing from probes. Instead, in 07-04 we will alert on
kube_job_status_failed.
Complete bookings-api manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
replicas: 6
minReadySeconds: 15
progressDeadlineSeconds: 300
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2
maxUnavailable: 0
selector:
matchLabels:
app: bookings-api
environment: pro
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
terminationGracePeriodSeconds: 45
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.8.1
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1"
memory: "512Mi"
# ---- START-UP: generous budget, no watching yet ----
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12 # 12 * 5 = 60 s to start up
# ---- LIVENESS: the process only, NEVER external dependencies ----
livenessProbe:
httpGet:
path: /health
port: http
httpHeaders:
- name: X-Probe-Source
value: kubelet-liveness
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3 # worst case: 33 s until the restart
# ---- READINESS: this one does check PostgreSQL and Redis ----
readinessProbe:
httpGet:
path: /ready
port: http
httpHeaders:
- name: X-Probe-Source
value: kubelet-readiness
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2 # worst case: 12 s out of the Endpoints
successThreshold: 1
# ---- CLEAN SHUTDOWN: avoid the deployment 502s ----
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]What each endpoint should do internally
Pseudocode for /health (liveness). Cheap, local, no outbound network:
// GET /health -> used by livenessProbe
// Returns 200 if the process can handle requests. NO dependencies whatsoever.
app.get('/health', (req, res) => {
const loopDelayMs = measureEventLoopDelay(); // e.g. with perf_hooks
if (loopDelayMs > 5000) {
// The event loop has been blocked for 5 s: the process will not recover on its own
return res.status(503).json({ status: 'blocked', loopDelayMs });
}
return res.status(200).json({ status: 'alive', version: process.env.APP_VERSION });
});Pseudocode for /ready (readiness). It checks dependencies, with short timeouts:
// GET /ready -> used by readinessProbe
// Returns 200 only if we can genuinely serve a booking RIGHT NOW.
app.get('/ready', async (req, res) => {
const checks = {};
try {
// 1. Is there a free connection left in the pool? (the case from section 1)
checks.poolFree = pgPool.idleCount > 0 || pgPool.totalCount < pgPool.options.max;
if (!checks.poolFree) throw new Error('connection pool exhausted');
// 2. Does PostgreSQL respond in under 1 s?
await pgPool.query({ text: 'SELECT 1', timeout: 1000 });
checks.postgres = 'ok';
// 3. Does Redis respond? Acceptable degradation: without cache we still serve, more slowly
try {
await redis.ping();
checks.redis = 'ok';
} catch (e) {
checks.redis = 'degraded'; // does NOT prevent being ready
}
// NOTE: we deliberately do not check pagos.proveedorexterno.example
return res.status(200).json({ status: 'ready', checks });
} catch (err) {
return res.status(503).json({ status: 'not-ready', reason: err.message, checks });
}
});bookings-postgres and redis-cache
# Fragment of the bookings-postgres StatefulSet (main container)
containers:
- name: postgres
image: postgres:16.4
ports:
- name: pg
containerPort: 5432
startupProbe:
exec:
command: ["/bin/sh", "-c", "pg_isready -U rutasnorte -h 127.0.0.1"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30 # 300 s: covers the WAL recovery
livenessProbe:
exec:
command: ["/bin/sh", "-c", "pg_isready -U rutasnorte -h 127.0.0.1"]
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U rutasnorte -h 127.0.0.1 && psql -U rutasnorte -d bookings -tAc 'SELECT 1'
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 2# Fragment of the redis-cache StatefulSet
containers:
- name: redis
image: redis:7.4-alpine
ports:
- name: redis
containerPort: 6379
livenessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2Checking it in the cluster
# Readiness state of every pod in the namespace
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/part-of=rutas-norte
# View the failed-probe events of a specific pod
kubectl -n rutas-norte-pro describe pod bookings-api-7d9f8c4b5-x2klm | grep -A 20 Events
# View the Ready condition and its exact reason
kubectl -n rutas-norte-pro get pod bookings-api-7d9f8c4b5-x2klm \
-o jsonpath='{.status.conditions[?(@.type=="Ready")]}' | jq
# Check which IPs are really in the Endpoints (the Service's truth)
kubectl -n rutas-norte-pro get endpointslices -l kubernetes.io/service-name=bookings-api -o yamlTypical output for a pod whose readiness is failing:
Pay attention: STATUS is Running and RESTARTS is 0. Everything "looks" fine. The only signal is the 0/1 in the READY column. And in the events:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 2m (x24 over 4m) kubelet Readiness probe failed: HTTP probe failed with statuscode: 503That is the trace to look for. In 07-06 we will systematise this way of reading events.
Common Mistakes and Tips
1. Using the same probe for liveness and readiness, checking dependencies. This is the mistake from section 5 in disguise. If you copy the same httpGet /health/full into both, you have created the cascading restart loop. Different endpoints, always.
2. timeoutSeconds: 1 (the default). Under load, a healthy endpoint easily takes more than a second. Raise it to 2–3 s on readiness and 3–5 s on liveness. This default has caused more false positives than any other.
3. Forgetting the startupProbe on slow applications. The classic symptom: CrashLoopBackOff on a container that "starts fine locally". What is happening is that liveness kills it after 30 seconds and it never finishes starting. If you see cyclic restarts with no application error logs, suspect this first.
4. An exec probe with a binary that does not exist. curl, wget or nc are not present in distroless images nor in many minimal alpine ones. Check with kubectl exec -it <pod> -- which curl before writing the probe. The telltale event is Liveness probe errored: exec: "curl": executable file not found in $PATH.
5. A health endpoint that requires authentication. The kubelet sends no credentials. A /health protected by JWT returns 401 and the probe always fails. Leave the health endpoint open, with no sensitive data in the response, and protect it at the network level if you need to.
6. Trusting 3xx codes. Remember: 200–399 is success. A /health that redirects to / will keep "passing" even if the application is completely broken. Return an explicit 200.
7. readinessProbe without preStop. You have zero-downtime deployments on the way in but you are still generating 502s on the way out. The two mechanisms are complementary and you need both.
8. Probes that are too expensive. A /ready that runs SELECT count(*) FROM bookings is issuing a heavy query every 5 seconds for every replica. With 6 replicas that is 72 queries a minute just to ask how we are. The checks must be trivial: SELECT 1 and little more.
9. Using probes for what a PodDisruptionBudget is for. Probes do not protect you from a node drain or a cluster upgrade. That is 09-05.
10. A preStop longer than the grace period. If you set preStop: sleep 60 with terminationGracePeriodSeconds: 30, the SIGKILL arrives after 30 seconds and the application never receives the SIGTERM: it closes nothing cleanly. The preStop must always be comfortably shorter than the grace period.
Exercises
Exercise 1 — Calculate and adjust the detection budget
A colleague has configured the liveness of notifications-worker like this:
livenessProbe:
httpGet:
path: /health
port: 8081
initialDelaySeconds: 20
periodSeconds: 30
timeoutSeconds: 1
failureThreshold: 5Answer:
- In the worst case, how long does it take to detect that the worker has hung?
- The business requires a hung worker to be restarted in under 60 seconds. Propose a configuration that meets this without making it prone to false positives.
- The worker takes 8 seconds to start and connect to the queue. Is
initialDelaySeconds: 20correct? What better alternative exists?
Exercise 2 — Spot and fix a dangerous liveness
This is the real web-store manifest in rutas-norte-pre. It contains three problems related to probes and shutdown. Identify them and write the corrected manifest.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-pre
labels:
app: web-store
environment: pre
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
selector:
matchLabels:
app: web-store
environment: pre
template:
metadata:
labels:
app: web-store
environment: pre
spec:
containers:
- name: nginx
image: registry.rutasnorte.example/web-store:5.2.0
ports:
- containerPort: 80
livenessProbe:
httpGet:
path: /api/full-status # proxy to bookings-api and to PostgreSQL
port: 80
periodSeconds: 5
timeoutSeconds: 1
failureThreshold: 2Exercise 3 — Design the probes for a new component
Rutas Norte adds a new component: routes-search, a Java service (Spring Boot) that keeps an in-memory index of every route and timetable. The facts:
- On start-up it loads the full index from
bookings-postgres: between 45 and 150 seconds depending on the volume. - Once the index is loaded, it never touches PostgreSQL again: it serves everything from memory.
- It exposes
/actuator/health/livenessand/actuator/health/readiness(Spring Boot Actuator). - It sits behind a
routes-searchService thatweb-storequeries. - It occasionally suffers long garbage collector pauses (up to 4 seconds).
Write the complete probe block and justify every value.
Solutions
Solution 1
1. Detection time.
More than two and a half minutes with the worker hung and not sending a single confirmation email. Far above the 60-second requirement.
2. Proposed configuration.
livenessProbe:
httpGet:
path: /health
port: 8081
periodSeconds: 10
timeoutSeconds: 3 # 1 s was far too tight under load
failureThreshold: 4The period has been lowered from 30 to 10 s (faster detection) and timeoutSeconds raised from 1 to 3 s (fewer false positives). The failureThreshold: 4 leaves room for a one-off spike not to trigger a restart: four consecutive failures are needed, that is, 40 seconds of sustained trouble.
3. The initialDelaySeconds.
It is a crude solution. With a fixed 20 s: if one day start-up takes 25 s because the node is slow, liveness starts failing during start-up and the pod may enter CrashLoopBackOff. And if it starts in 8 s, we have lost 12 s of watching.
The correct alternative is a startupProbe that separates the budgets:
startupProbe:
httpGet:
path: /health
port: 8081
periodSeconds: 3
timeoutSeconds: 2
failureThreshold: 15 # 45 s of start-up margin, plenty for 8 s
livenessProbe:
httpGet:
path: /health
port: 8081
periodSeconds: 10 # no initialDelaySeconds: it is no longer needed
timeoutSeconds: 3
failureThreshold: 4An added benefit: the startupProbe with periodSeconds: 3 detects the finished start-up as soon as it happens, so a pod that starts in 8 s is ready in ~9 s, not in 20.
Solution 2
The three problems:
- Liveness checks external dependencies.
/api/full-statusis a proxy towardsbookings-apiand from there to PostgreSQL. If the API or the database are slow, all threeweb-storepods restart at once, leaving the public website completely down even though nginx was perfectly fine. It is exactly the scenario from section 5. - There is no
readinessProbe. Combined withmaxUnavailable: 1, every deployment sends traffic to nginx pods that have not finished starting → errors during every update. - There is no
preStopand no adjusted grace period. The pods being removed die before the endpoint removal propagates →502on every deployment. On top of that:timeoutSeconds: 1is far too tight.
Corrected manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-pre
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
environment: pre
spec:
replicas: 3
minReadySeconds: 10
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # never drop below 3 pods serving
selector:
matchLabels:
app: web-store
environment: pre
template:
metadata:
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
environment: pre
spec:
terminationGracePeriodSeconds: 30
containers:
- name: nginx
image: registry.rutasnorte.example/web-store:5.2.0
ports:
- name: http
containerPort: 80
# Liveness: nginx ONLY, without leaving the pod
livenessProbe:
httpGet:
path: /nginx-health # static location that returns 200 from nginx
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
# Readiness: the static site is served without depending on the API
readinessProbe:
httpGet:
path: /nginx-health
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]With the corresponding nginx configuration:
The access_log off; stops the probes (one every 5 s per pod, 3 pods → more than 50,000 lines a day) from flooding the logs we will centralise in 07-05.
Solution 3
# ---- START-UP: very generous budget, covers the 150 s worst case ----
startupProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 24 # 24 * 10 = 240 s > the 150 s worst case
# ---- LIVENESS: the JVM responds; NOTHING about PostgreSQL ----
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 15
timeoutSeconds: 6 # > the 4 s maximum GC pause
failureThreshold: 3 # worst case: 51 s
# ---- READINESS: is the index loaded and serving queries? ----
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 10
timeoutSeconds: 6
failureThreshold: 3
successThreshold: 1Justification of each decision:
startupProbewith a 240 s budget. Start-up ranges from 45 to 150 s. Allowing 60 % headroom over the known worst case avoids spurious restarts on the day PostgreSQL is slow during the load. Without this probe, liveness would kill the pod after ~50 s and it would enter an endlessCrashLoopBackOff: it would never manage to load the index.timeoutSeconds: 6on liveness and readiness. Garbage collector pauses reach 4 seconds. If the timeout were 3 s, every long pause would count as a failure. With 6 s, a GC pause triggers nothing. This is a JVM-specific adjustment that has to be made deliberately.periodSeconds: 15on liveness. The component is stable; we do not need sub-second detection. A long period reduces the probe pressure on the application.- Liveness against
/actuator/health/livenessand not against the general endpoint. Spring Boot separates theliveness(JVM state) andreadiness(dependencies ready) groups by default. Using plain/actuator/healthwould include the PostgreSQL indicators in liveness: the mistake from section 5. - Readiness that does check the index. Even though
routes-searchdoes not touch PostgreSQL in steady state, it does need the index loaded. Readiness failing while the index is not ready is correct: the pod should not receive searches it cannot resolve. successThreshold: 1. Return to service quickly as soon as it recovers.
A practical detail: in Spring Boot you have to enable the probe groups with management.endpoint.health.probes.enabled=true (it is enabled automatically if it detects that it is running in Kubernetes).
Conclusion
Probes are the first step towards an observable platform, and also the first towards a reliable one. In this lesson we have seen that:
- A live process is not a healthy application, and the exhausted connection pool case in
bookings-apiproves it beyond any doubt. livenessProberestarts,readinessProberemoves from the Service,startupProbedelays the other two. Three different questions with three different consequences.- The four handlers (
httpGet,tcpSocket,exec,grpc) have very different costs and precision;execis the most flexible and the most expensive. - Detection time is calculated, not guessed:
failureThreshold * periodSeconds + timeoutSeconds. - The rule that prevents the most expensive outage in the module: liveness never checks external dependencies; readiness should.
- Probes settle the debt from 02-04: together with
maxUnavailable: 0,minReadySecondsand apreStopthat wins the race against theSIGTERM, we finally have genuinely zero-downtime deployments without the502s we used to see on every update.
Rutas Norte now knows whether each component is healthy. What it still does not know is how much it consumes. In 03-04 we set the requests and limits of each component practically by eye, and we wrote that we would calibrate them by observing real consumption. That moment has arrived: in the next lesson we will install metrics-server, the first source of consumption data in the cluster, and with kubectl top we will compare what we ask for with what we actually spend.
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
