Deployments and Services
A Deployment describes the pods that run your Symfony image, and a Service gives them one stable address inside the cluster.
kubectl apply -f k8s/deployment.yaml -f k8s/service.yamlA 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
// 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.
# 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: 256MiThe 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.
# 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: TCPChecking 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.
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