Health Checks and Monitoring

Two endpoints, one that touches nothing and one that checks the database, logs on stdout, and alerts that tell you what to do.

Syntaxhttp_response_code(int $response_code = 0): int|bool

A load balancer or container orchestrator needs a URL it can call to decide what to do with an instance. There are two different decisions, so there are two endpoints.

Liveness touches nothing

Make /health return 200 and a two-word body. No database, no cache, no session, no autoloaded framework if you can avoid it. It answers one question: is this process still able to run PHP. If it fails, the right action is to restart the container.

Readiness checks dependencies

/ready runs the cheapest real query there is, SELECT 1 through PDO, plus a ping of anything else the app cannot serve without. It returns 200 when every check passes and 503 when one does not, with a small JSON body naming which. If it fails, the right action is to take this instance out of rotation, not to kill it. http_response_code(503) is the shorthand for header('HTTP/1.1 503 Service Unavailable'); both set the same status line.

Logs go to stdout

In a container, write logs to the standard streams and let the platform collect them. Set error_log = /dev/stderr in php.ini, and in the FPM pool set catch_workers_output = yes with decorate_workers_output = no (PHP 7.3 and later) so lines arrive unprefixed. One JSON object per line, with a request id, turns grep into a search.

Alerts name an action

"5xx above 2% of requests for five minutes" tells someone what broke and what to look at. "CPU at 80%" tells them nothing, and after a week of it they stop reading.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Give Kubernetes or an ELB a probe URL that marks an instance unhealthy for the right reason.
  • Confirm a release is actually serving before deleting the previous one.
  • Find every log line from one failed checkout by its request id.

More examples

The two endpoints as they ship

The two files are shown together to compare them; in the project they are separate scripts. ATTR_TIMEOUT keeps a dead database from holding the check open until the web server gives up. The exception message goes to the log and never into the response, because a connection error names your host and user. Cache-Control: no-store stops a proxy from answering the check from cache, which would report a healthy instance long after it stopped being one.

Example · php
<?php

declare(strict_types=1);

// public/health.php - liveness. Loads nothing, touches nothing.
header('Content-Type: application/json');
header('Cache-Control: no-store');
http_response_code(200);
echo '{"status":"ok"}';

// ---------------------------------------------------------------
// public/ready.php - readiness. Cheapest possible real queries.
// (Its own file, so it opens with the same declare(strict_types=1).)

header('Content-Type: application/json');
header('Cache-Control: no-store');

$checks = [];

try {
    $pdo = new PDO(
        (string) getenv('DB_DSN'),
        (string) getenv('DB_USER'),
        (string) getenv('DB_PASSWORD'),
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_TIMEOUT => 2,
        ]
    );
    $pdo->query('SELECT 1');
    $checks['database'] = 'up';
} catch (PDOException $e) {
    error_log('readiness: database down: ' . $e->getMessage());
    $checks['database'] = 'down';
}

$ready = !in_array('down', $checks, true);
http_response_code($ready ? 200 : 503);

echo json_encode([
    'status' => $ready ? 'ready' : 'degraded',
    'checks' => $checks,
], JSON_THROW_ON_ERROR);

One JSON object per log line

error_log() writes to whatever error_log points at, so with /dev/stderr the same call that records a PHP warning records yours, and the platform picks both up. A request id threaded through every line is what lets you pull one visitor's whole request out of a million lines. Never put a password, a card number or a session id in the context array; logs are copied to places with weaker access control than your database.

Example · php
<?php

declare(strict_types=1);

namespace Shop;

final class Log
{
    private static string $requestId = '';

    public static function begin(string $requestId): void
    {
        self::$requestId = $requestId;
    }

    public static function write(string $level, string $message, array $context = []): void
    {
        $line = json_encode([
            'level' => $level,
            'msg' => $message,
            'request_id' => self::$requestId,
            'context' => $context,
        ], JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);

        error_log((string) $line);
    }
}

// Log::begin($_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)));
// Log::write('warning', 'payment retry', ['order_id' => 8812, 'attempt' => 2]);

Container logging settings

/dev/stderr and /proc/self/fd/2 are two names for the same stream; use whichever exists in your base image. Without catch_workers_output, anything a worker writes to stdout or stderr is discarded and your logs look empty. decorate_workers_output = no, available since PHP 7.3, removes the per-line prefix that otherwise breaks JSON parsing. daemonize = no keeps FPM in the foreground, which is what a container needs as its main process.

Example · ini
; /usr/local/etc/php/conf.d/99-logging.ini
display_errors = Off
log_errors = On
error_log = /dev/stderr

; /usr/local/etc/php-fpm.d/zz-logging.conf
[global]
error_log = /proc/self/fd/2
daemonize = no

[www]
catch_workers_output = yes
decorate_workers_output = no
access.log = /proc/self/fd/2

Discussion

  • Be the first to comment on this lesson.