On Kubernetes

Web, cron and consumer deployments, probes that use health_check.php, shared media, and the schema change as a gated Job.

SyntaxreadinessProbe: httpGet: {path: /health_check.php, port: 8080}

On Kubernetes a Magento release is three kinds of workload and one job. The split matters because they fail differently: web pods must be replaceable at any moment, the scheduler must never be duplicated, and the schema change must happen exactly once per release.

The workloads

  • A web Deployment, nginx in front of php-fpm, scaled to traffic. The nginx container needs the built pub/static, so it runs an image from the same release.
  • A cron Deployment with replicas: 1 and strategy: Recreate. Two schedulers write competing rows into cron_schedule.
  • One consumer Deployment per queue, running bin/magento queue:consumers:start <name>. A consumer is a PHP process that reads one queue; it exits when --max-messages is reached and the kubelet restarts it.

Probes

Magento ships pub/health_check.php. It boots the application, connects to every configured database, tests every cache backend, and returns 500 if any of that fails. That makes it the right readiness probe and the wrong liveness probe: it is not cheap, so give it periodSeconds: 15 or more and use a TCP check on the php-fpm port for liveness.

Media and migrations

pub/media is written at runtime, so it is a ReadWriteMany volume mounted into the web and cron pods, or it moves off the filesystem entirely with bin/magento setup:config:set --remote-storage-driver=aws-s3. The schema change runs as its own Job, from the new image, gated on bin/magento setup:db:status: exit 2 means an upgrade is needed, exit 0 means it is not. Start the rollout only after the Job succeeds.

Example

Example · yaml
# k8s/web-deployment.yaml   apply with: kubectl apply -f k8s/web-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: magento-web
  namespace: store
spec:
  replicas: 4
  selector:
    matchLabels: {app: magento, role: web}
  template:
    metadata:
      labels: {app: magento, role: web}
    spec:
      containers:
        - name: nginx
          image: store/web:1.42          # same release: nginx plus pub/static
          ports: [{containerPort: 8080}]
          readinessProbe:
            httpGet: {path: /health_check.php, port: 8080}
            periodSeconds: 15
            timeoutSeconds: 5
          volumeMounts:
            - {name: media, mountPath: /app/pub/media}
        - name: php-fpm
          image: store/app:1.42
          livenessProbe:
            tcpSocket: {port: 9000}
            periodSeconds: 20
          volumeMounts:
            - {name: media, mountPath: /app/pub/media}
            - {name: env, mountPath: /app/app/etc/env.php,
               subPath: env.php, readOnly: true}
      volumes:
        - name: media
          persistentVolumeClaim: {claimName: magento-media}   # ReadWriteMany
        - name: env
          secret: {secretName: magento-env-php}

When to use it

  • Run a Magento store on an existing Kubernetes cluster without the web pods fighting over cron.
  • Keep a release from serving new code against the old database schema.
  • Choose a readiness probe that notices a lost database rather than only a dead port.
  • Share merchandiser-uploaded media across several web pods.

More examples

The schema change as a gated Job

One Job per release, named after the release, so a second apply is a no-op rather than a second migration. The exit code from setup:db:status decides whether anything runs at all, and backoffLimit: 0 stops Kubernetes from retrying a schema change that failed halfway. Use this instead of putting setup:upgrade in a web pod's entrypoint, where every replica would run it at once.

Example · yaml
# k8s/migrate-job.yaml   kubectl apply -f k8s/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: magento-migrate-1-42
  namespace: store
spec:
  backoffLimit: 0                 # never retry a half-applied schema change
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: store/app:1.42   # the release being deployed
          command: ["sh", "-c"]
          args:
            - |
              bin/magento setup:db:status; status=$?
              if [ "$status" -eq 0 ]; then echo "schema current"; exit 0; fi
              if [ "$status" -ne 2 ]; then echo "db:status failed"; exit 1; fi
              bin/magento setup:upgrade --keep-generated
          volumeMounts:
            - {name: env, mountPath: /app/app/etc/env.php,
               subPath: env.php, readOnly: true}
      volumes:
        - name: env
          secret: {secretName: magento-env-php}

Cron and consumer deployments

Recreate on the cron Deployment is the point: the default rolling update briefly runs two schedulers, which double-schedules jobs. The consumer takes replicas because several processes may read the same queue, and --max-messages makes each pod exit and be replaced before its memory use matters. Volume and secret mounts are omitted here for length; they match the web Deployment.

Example · yaml
# k8s/workers.yaml   kubectl apply -f k8s/workers.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: magento-cron, namespace: store}
spec:
  replicas: 1                     # exactly one scheduler, always
  strategy: {type: Recreate}      # never two during a rollout
  selector:
    matchLabels: {app: magento, role: cron}
  template:
    metadata:
      labels: {app: magento, role: cron}
    spec:
      containers:
        - name: cron
          image: store/app:1.42
          command: ["sh", "-c", "while true; do bin/magento cron:run; sleep 60; done"]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: magento-consumer-async, namespace: store}
spec:
  replicas: 2
  selector:
    matchLabels: {app: magento, role: consumer-async}
  template:
    metadata:
      labels: {app: magento, role: consumer-async}
    spec:
      containers:
        - name: consumer
          image: store/app:1.42
          command: ["bin/magento", "queue:consumers:start",
                    "async.operations.all", "--max-messages=1000"]

The order of one release

Each step waits for the previous one, which is the whole difference between a deploy and an outage. Stopping consumers and cron first prevents an old-code process from writing through a schema it does not know. Keep this as a script or a pipeline stage so nobody performs the steps from memory at 22:00.

Example · bash
# Workers stop first so no old-code process touches the new schema.
kubectl -n store scale deploy/magento-cron --replicas=0
kubectl -n store scale deploy/magento-consumer-async --replicas=0

kubectl -n store apply -f k8s/migrate-job.yaml
kubectl -n store wait --for=condition=complete \
  job/magento-migrate-1-42 --timeout=20m

kubectl -n store set image deploy/magento-web \
  nginx=store/web:1.42 php-fpm=store/app:1.42
kubectl -n store rollout status deploy/magento-web

# Workers come back on the new image only after the web pods are ready.
kubectl -n store scale deploy/magento-cron --replicas=1
kubectl -n store scale deploy/magento-consumer-async --replicas=2

# If the rollout stalls:
kubectl -n store rollout undo deploy/magento-web

Discussion

  • Be the first to comment on this lesson.