Deployments and Services

A Deployment describes the pods that run your Symfony image, and a Service gives them one stable address inside the cluster.

Syntaxkubectl apply -f k8s/deployment.yaml -f k8s/service.yaml

A Kubernetes cluster does not take orders to start containers. You hand it a description of the state you want and a controller works towards it. A Deployment is that description for a stateless application: which image, how many copies, which environment variables. A Service is a stable name and IP address in front of those copies.

What the control loop does

The Deployment controller creates a ReplicaSet, and the ReplicaSet creates pods. A pod is one or more containers scheduled together on a node, sharing one network address. Pods are disposable: when one dies the controller creates a replacement rather than repairing it. Change the image in the Deployment and a second ReplicaSet appears, scaling up while the first scales down.

Labels join the two objects

A Service holds no list of pods. It holds a selector, which is a set of labels. Any pod carrying those labels becomes an endpoint of the Service. The Service also gets a DNS name in its namespace, so other pods reach the application at http://web.

What this demands of the application

Three replicas means three PHP processes with three separate local filesystems. Sessions written to local disk, uploaded files and anything cached in a file are per pod, and vanish when that pod is replaced. State belongs in a database, a shared cache server or object storage.

What trips people up

If the selector and the pod labels disagree, the Service still resolves in DNS and every connection fails. kubectl get endpoints web shows none, and the application logs show nothing at all, because no request ever arrived.

Example

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

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class PodController extends AbstractController
{
    public function __construct(
        // Kubernetes sets HOSTNAME to the pod name inside every container.
        #[Autowire(env: 'HOSTNAME')]
        private readonly string $podName,
        #[Autowire(param: 'kernel.environment')]
        private readonly string $environment,
    ) {
    }

    #[Route('/_whoami', name: 'app_whoami', methods: ['GET'])]
    public function __invoke(): JsonResponse
    {
        return new JsonResponse([
            'pod' => $this->podName,
            'environment' => $this->environment,
        ]);
    }
}

// GET /_whoami returns 200 with {"pod":"web-6f8c9d7b54-k2xqz","environment":"prod"}.
// Repeated requests through the Service report different pod names, which is
// how you confirm the Service is really in front of all three replicas.

When to use it

  • Run three replicas of a Symfony application behind one cluster-internal address.
  • Release a new image with no drop in capacity by setting maxUnavailable to zero.
  • Diagnose a deployed application that answers nothing by checking Service endpoints first.
  • Give a background worker its own Deployment without exposing it through any Service.

More examples

The Deployment

The labels under spec.template.metadata are what the Service will match, and spec.selector.matchLabels must agree with them. maxUnavailable: 0 with maxSurge: 1 means a new pod is created and must become ready before an old one is removed, so capacity never dips during a release. The defaults are 25% for both, which allows a quarter of your pods to be gone mid-release. APP_DEBUG is quoted because every environment variable value must be a string in YAML.

Example · yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app.kubernetes.io/name: web
    spec:
      containers:
        - name: php
          image: registry.example.com/shop/web:2026-10-03.1
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: APP_ENV
              value: prod
            - name: APP_DEBUG
              value: "0"
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 256Mi

The Service

ClusterIP is the default type: reachable from inside the cluster only, which is what you want behind an ingress controller or load balancer. port is what callers connect to, targetPort is the container port. Naming the container port http and referring to it by name here means moving PHP from 8080 to another port is a one-line change in the Deployment. Reach it from another pod as http://web, or http://web.shop.svc.cluster.local from a different namespace.

Example · yaml
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: web
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP

Checking the two are actually joined

This is the first thing to run when an application is deployed but unreachable. An empty ENDPOINTS column means the Service selector matches nothing, so the problem is a label typo rather than anything in PHP. Three addresses means all three replicas are ready and in rotation. The throwaway curl pod proves the DNS name and the port resolve from inside the cluster, which is a different question from whether your ingress is configured.

Example · bash
kubectl rollout status deployment/web
# deployment "web" successfully rolled out

kubectl get endpoints web
# NAME   ENDPOINTS                                         AGE
# web    10.42.0.17:8080,10.42.0.18:8080,10.42.1.9:8080    4m

kubectl run curl --rm -it --image=curlimages/curl -- \
  curl -s http://web/_whoami
# {"pod":"web-6f8c9d7b54-k2xqz","environment":"prod"}

Discussion

  • Be the first to comment on this lesson.