Probes
Liveness, readiness and startup probes, which Symfony endpoint each one should call, and why a liveness probe that checks the database restarts every pod.
livenessProbe: { httpGet: { path: /health/live, port: http }, timeoutSeconds: 3 }A probe is a check the node runs against a container on a schedule: an HTTP request, a command, or a TCP connection. There are three kinds and they answer different questions, which is the whole lesson.
The three probes
- liveness asks: is this process still able to work? Failing it restarts the container.
- readiness asks: can this pod serve traffic right now? Failing it removes the pod from its Service endpoints, and the container keeps running.
- startup asks: has it finished booting? While it is still failing, the other two are suspended.
So liveness is about the process and readiness is about its dependencies. Give them separate routes: /health/live checks nothing external, /health/ready checks what the pod needs in order to answer a real request.
Why a wrong liveness probe is worse than no probe
Suppose liveness queries the database. A thirty second database outage fails the probe in every pod at the same time, so Kubernetes restarts all of them. A restarting pod serves nothing and loses its warm PHP opcache, so recovery is slower than the outage. Repeated restarts land the pods in CrashLoopBackOff, where Kubernetes waits longer between each attempt. The database comes back and the site is still down.
What trips people up
Defaults are tight: periodSeconds 10, failureThreshold 3 and timeoutSeconds 1. One second is not much for a PHP request on a busy node, so raise it. Probe requests arrive from the node with no session and no cookies, so a health route behind the firewall is never authenticated. An HTTP probe counts any status from 200 to 399 as success, so a 302 to the login form actually passes and hides a broken application, while an entry point that answers 401 or 403 instead fails it. Either way the probe is no longer telling you about the application, so keep these routes public.
Example
// src/Controller/HealthController.php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception as DbalException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class HealthController extends AbstractController
{
public function __construct(private readonly Connection $connection)
{
}
// Liveness: the kernel booted and this process can still answer. No I/O.
#[Route('/health/live', name: 'app_health_live', methods: ['GET'])]
public function live(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
}
// Readiness: everything a real request needs is reachable from this pod.
#[Route('/health/ready', name: 'app_health_ready', methods: ['GET'])]
public function ready(): JsonResponse
{
try {
// A bound-parameter-free constant query: cheapest proof of a live connection.
$this->connection->executeQuery('SELECT 1');
} catch (DbalException) {
return new JsonResponse(
['status' => 'degraded', 'reason' => 'database'],
Response::HTTP_SERVICE_UNAVAILABLE,
);
}
return new JsonResponse(['status' => 'ok']);
}
}
// GET /health/live returns 200 with {"status":"ok"} even while the database is down.
// GET /health/ready returns 503 with {"status":"degraded","reason":"database"} in that
// case, so the pod leaves the Service endpoints without being restarted.When to use it
- Hold traffic away from a pod whose database connection is not yet usable, without restarting it.
- Give an application with a slow boot enough time without delaying failure detection later.
- Work out why pods restart every few minutes under load while the site looks fine.
- Drain a pod from a Service during a maintenance window by making readiness fail on purpose.
More examples
All three probes on the container
The startup probe is the right way to give a slow boot more time: it is patient, and while it runs, liveness cannot restart the container. The alternative, a large initialDelaySeconds on liveness, delays real failure detection for the whole life of the pod. Readiness is checked more often than liveness and gives up sooner, because removing a pod from rotation is cheap and reversible while restarting it is not. Both HTTP probes refer to the port by its name, http.
# k8s/deployment.yaml (the container entry)
- name: php
image: registry.example.com/shop/web:2026-10-03.1
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 2
failureThreshold: 30 # allows up to 60s to boot
livenessProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2Keeping the health routes out of the firewall
Reach for this when probes fail against an application that is plainly working in a browser. Anything that answers a probe must be reachable with no session: a rule that forces authentication on /health/ turns the probe into a 302 to the login page, and a 302 is a failure. PUBLIC_ACCESS is the value that says explicitly that no authentication is required, and listing it first means the later rules cannot catch these paths. Keep the responses free of anything you would not publish, since this route is open.
# config/packages/security.yaml
security:
firewalls:
main:
lazy: true
# ... authenticators
access_control:
- { path: ^/health/, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }Reading what the probe did
A climbing RESTARTS count with CrashLoopBackOff almost always means a liveness probe, not a crash. The event text names the cause: context deadline exceeded is a timeout, so the endpoint was too slow for timeoutSeconds rather than wrong; a 404 means the route is not registered in the running image; a 401 or 403 means a firewall rule caught it, while a 302 redirect to the login page would have been counted as a success, since an HTTP probe treats every status from 200 to 399 as healthy. Check this before changing any application code, because the application may be entirely healthy.
kubectl get pods -l app.kubernetes.io/name=web
# NAME READY STATUS RESTARTS AGE
# web-6f8c9d7b54-k2xqz 0/1 CrashLoopBackOff 5 4m
kubectl describe pod web-6f8c9d7b54-k2xqz
# Events:
# Warning Unhealthy 2m (x9 over 4m) kubelet Liveness probe failed:
# Get "http://10.42.0.17:8080/health/live": context deadline exceeded
# Normal Killing 2m (x3 over 4m) kubelet Container php failed
# liveness probe, will be restarted
Discussion