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.

SyntaxlivenessProbe: { 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

Example · php
// 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.

Example · yaml
# 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: 2

Keeping 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.

Example · yaml
# 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.

Example · bash
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

  • Be the first to comment on this lesson.