Shipping and Operating It

One immutable image, manifests with probes and secrets, migrations run before the rollout, then logs, metrics and a single alert.

Syntaxphp bin/console doctrine:migrations:migrate --no-interaction

The last step turns the application into something that runs without you watching it. Four parts: an image, manifests, a migration step, and enough signal to know when it is unwell.

The image

Built once per commit and tagged with the commit sha, never latest, so the thing you tested is the thing that runs. Production dependencies only, the autoloader optimised, the cache warmed at build time. No secrets inside: configuration arrives as environment variables.

Manifests

A Deployment for the web process and a second one for the Messenger worker, because they scale and fail differently. Secrets come from a Secret object, not the image. Two probes: liveness, which Kubernetes uses to decide whether to restart the container, and readiness, which decides whether to send it traffic.

Migrations in a rolling deploy

During a roll, old and new pods serve at the same time. So migrations must be additive: add a nullable column, deploy code that writes it, backfill, and only drop the old column in a later release. Run doctrine:migrations:migrate --no-interaction as a Job before the rollout, from the new image, so a failed migration stops the deployment instead of half-applying during it. --all-or-nothing wraps the run in one transaction, which helps on PostgreSQL; MySQL commits schema changes implicitly and cannot roll them back.

Operating it

Logs as JSON on stderr, so the platform collects them. A correlation id on each request. One alert to begin with: the share of 5xx responses over a few minutes, pointing at a runbook that says what to check.

Example

Example · php
<?php
// src/Controller/HealthController.php

declare(strict_types=1);

namespace App\Controller;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception as DbalException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class HealthController
{
    public function __construct(
        private readonly Connection $connection,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Liveness: only answers whether this process is still running.
     * It touches nothing else, so an outage elsewhere cannot cause restarts.
     */
    #[Route('/health/live', name: 'app_health_live', methods: ['GET'])]
    public function live(): JsonResponse
    {
        return new JsonResponse(['status' => 'ok']);
    }

    /**
     * Readiness: whether this pod should receive traffic right now.
     */
    #[Route('/health/ready', name: 'app_health_ready', methods: ['GET'])]
    public function ready(): JsonResponse
    {
        try {
            $this->connection->executeQuery('SELECT 1');
        } catch (DbalException $e) {
            $this->logger->error('Readiness check failed', ['exception' => $e]);

            return new JsonResponse(['status' => 'unavailable'], 503);
        }

        return new JsonResponse(['status' => 'ok']);
    }
}

// GET /health/live returns 200 with {"status":"ok"} as long as PHP answers.
// GET /health/ready returns 200 normally, or 503 with
// {"status":"unavailable"} while the database is unreachable.

When to use it

  • Build one image in CI and deploy the same tag to staging and production
  • Add a column without breaking the pods still running the previous release
  • Get the first useful alert in place: 5xx rate over five minutes, linked to a runbook

More examples

The production image

--no-dev keeps test and debug packages out, and the classmap autoloader removes filesystem lookups per class. cache:warmup runs with the build's environment, so any variable the container compiler reads must have a value in .env; put real values in the Secret, not here. Build this once in CI and deploy that exact tag to every environment.

Example · dockerfile
# Dockerfile
FROM dunglas/frankenphp AS app

ENV APP_ENV=prod
ENV APP_DEBUG=0
# FrankenPHP serves /app/public and listens on SERVER_NAME.
ENV SERVER_NAME=:8080

WORKDIR /app

COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

# Dependencies first, so editing source does not reinstall vendor.
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --no-progress

COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative \
 && php bin/console cache:warmup \
 && php bin/console asset-map:compile

EXPOSE 8080

Deployments, probes and secrets

One image, two workloads: the web pods answer requests and the worker pods consume the queue, so a backlog of email does not need more web capacity. Scale them independently, and remember that a worker holds compiled code in memory for its whole life, which is why it exits on a time limit after a deploy. Without envFrom the pods start and then fail on the first database call.

Example · yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: articles-web
spec:
  replicas: 3
  selector:
    matchLabels: { app: articles-web }
  template:
    metadata:
      labels: { app: articles-web }
    spec:
      containers:
        - name: app
          image: registry.example.com/articles:9f2c1ab
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef: { name: articles-secrets }
          readinessProbe:
            httpGet: { path: /health/ready, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /health/live, port: 8080 }
            periodSeconds: 10
---
# The worker runs the same image with a different command.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: articles-worker
spec:
  replicas: 2
  selector:
    matchLabels: { app: articles-worker }
  template:
    metadata:
      labels: { app: articles-worker }
    spec:
      containers:
        - name: worker
          image: registry.example.com/articles:9f2c1ab
          # --time-limit lets the pod exit cleanly and be replaced,
          # which keeps long-running PHP processes from accumulating memory.
          args:
            - php
            - bin/console
            - messenger:consume
            - async
            - --time-limit=3600
          envFrom:
            - secretRef: { name: articles-secrets }

The migration step, and logs the platform can read

backoffLimit: 0 means a failed migration fails the Job once and the pipeline stops there, rather than retrying a half-applied change. Writing to php://stderr with the JSON formatter is what makes log collection work in a container, where there is no log file anyone will read. The fingers_crossed handler keeps normal traffic quiet while still giving you the requests that led up to an error.

Example · yaml
# k8s/migrate-job.yaml
# Applied from CI before the Deployment is updated. The name carries the
# commit sha so each release gets its own Job object.
apiVersion: batch/v1
kind: Job
metadata:
  name: articles-migrate-9f2c1ab
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/articles:9f2c1ab
          command:
            - php
            - bin/console
            - doctrine:migrations:migrate
            - --no-interaction
          envFrom:
            - secretRef: { name: articles-secrets }

---
# config/packages/prod/monolog.yaml
monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
            # Keep the lead-up to an error, drop the rest.
            buffer_size: 50
        nested:
            type: stream
            path: php://stderr
            level: debug
            formatter: monolog.formatter.json

Discussion

  • Be the first to comment on this lesson.