Health Checks

Liveness answers whether the process can still run; readiness answers whether it can serve. Mixing them restarts healthy instances.

Syntax#[Route('/health/ready', name: 'app_health_ready', methods: ['GET'])]

An orchestrator such as Kubernetes asks a running application two different questions. Liveness: is this process still able to do work, or should it be killed and replaced? Readiness: can it serve traffic right now, or should it be taken out of the load balancer for a while? Different questions, so two endpoints with different contents.

Liveness touches nothing

A liveness endpoint should return 200 as long as PHP can execute the request. No database, no cache server, no outbound HTTP call. The reason is blunt: failing liveness gets the process killed. If liveness queries the database, a thirty-second database hiccup restarts every instance at once, each restart warms a cache and opens fresh connections, and a recoverable blip has become an outage you have to sit through.

Readiness checks what it needs

A readiness endpoint checks the things the application cannot serve a request without, normally the database, and returns 503 when one of them is missing. The orchestrator stops sending traffic but leaves the process alive, so it recovers by itself when the dependency returns. Keep the check cheap and bounded: one trivial query, a short connection timeout, no business logic.

Practicalities

Probes carry no credentials, so these paths must be reachable without a login. They also run every few seconds for every instance, so keep them out of access logs, out of the profiler, and out of anything you pay for per request.

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
{
    // Connection is the database connection object. It arrives through the
    // constructor, type-hinted, so this class never reaches for it itself.
    public function __construct(private readonly Connection $connection)
    {
    }

    #[Route('/health/live', name: 'app_health_live', methods: ['GET'])]
    public function live(): JsonResponse
    {
        // Deliberately empty: it answers while PHP can still answer.
        return $this->json(['status' => 'ok']);
    }

    #[Route('/health/ready', name: 'app_health_ready', methods: ['GET'])]
    public function ready(): JsonResponse
    {
        try {
            // Cheapest round trip that proves the connection works.
            $this->connection->executeQuery('SELECT 1');
        } catch (DbalException) {
            return $this->json(
                ['status' => 'degraded', 'database' => 'unreachable'],
                Response::HTTP_SERVICE_UNAVAILABLE,
            );
        }

        return $this->json(['status' => 'ok', 'database' => 'reachable']);
    }
}

When to use it

  • Give an orchestrator a probe that will not restart instances during a database outage.
  • Keep a rolling deploy from sending traffic to an instance whose cache is still warming.
  • Report a degraded dependency to a load balancer without taking the process down.
  • Confirm after a deploy that the new release can actually reach its database.

More examples

The same response without the controller helper

Worth seeing because a liveness endpoint is the one place where the dependencies of the code matter as much as its output. A class with no parent and no injected services cannot fail for a reason outside itself, which is the whole point of the endpoint. It also shows the longhand behind $this->json(): a JsonResponse built directly, so you can recognise either form in someone else's code.

Example · php
// src/Controller/LivenessController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

// No base class, so no $this->json() and nothing else pulled in.
final class LivenessController
{
    #[Route('/health/live', name: 'app_health_live', methods: ['GET'])]
    public function __invoke(): JsonResponse
    {
        // $this->json($data, $status) is shorthand for exactly this object
        // when no serializer service is registered. Response::HTTP_OK is 200;
        // the constant and the literal are the same value.
        return new JsonResponse(['status' => 'ok'], Response::HTTP_OK);
    }
}

Letting the probes through the firewall

Add this the first time a probe starts returning 302 or 401 instead of 200. PUBLIC_ACCESS is the built-in attribute meaning no authentication is required. Keep the paths narrow: /health/live and /health/ready expose nothing, but an endpoint that reports version numbers, queue depths or connection strings should sit behind authentication or be bound to an internal port.

Example · yaml
# config/packages/security.yaml
security:
    firewalls:
        main:
            lazy: true

    access_control:
        # a firewall is the layer that decides who a request belongs to;
        # probes send no credentials, so these paths must stay open
        - { path: '^/health/', roles: PUBLIC_ACCESS }

Wiring both probes in a deployment

The two endpoints only pay off when they are wired to the two probes, and this shows why they must differ. Three consecutive liveness failures kill the container. Two consecutive readiness failures only remove it from the service's endpoint list, and it rejoins on the next success. Readiness is checked more often because recovering from it is free; liveness is given more slack because the response to it is destructive.

Example · yaml
# k8s/deployment.yaml (probe section only)
livenessProbe:
  httpGet:
    path: /health/live
    port: 8080
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  periodSeconds: 5
  failureThreshold: 2

Discussion

  • Be the first to comment on this lesson.