Issue Per-Pod mTLS Certificates with PodCertificateRequest
Your team has been asked to get mutual TLS between two services in the payments
namespace. The usual answer is a service mesh: a control plane, a sidecar or a
node proxy per workload, and a CA you now operate. For two services, that is a
lot of machinery to take on.
Kubernetes 1.37 made an alternative generally available. The kubelet can generate a private key for a pod, ask a signer for a certificate, and drop the result into the pod's filesystem, rotating it before it expires. The pod gets an identity it never has to handle, mint, or renew. No sidecar, no mesh, no bearer token exchanged for a certificate.
The API is served out of the box now. What Kubernetes still does not ship is a
certificate authority. It will accept a request, record who is asking, and then
wait for something else to answer it. Your platform team put that piece in place:
a pod-identity-signer service on cplane-01, with its own CA.
Two workloads are deployed:
payments-api: two replicas, one per worker node, meant to serve HTTPS on8443. Its pods are stuck inContainerCreatingand have never started.checkout: the client. Running, and with no identity of its own.
The plan:
- Get
payments-apirunning with certificates the cluster issued it. - Give
checkoutits own identity. - Make
payments-apireject anyone who does not have one.
Constraints:
- Use the signer that is already installed. Do not replace its CA.
- Both workloads keep their own service accounts, and their certificates come from
the
podCertificateprojected volume, not from aSecretyou fill in yourself. - Edit the deployments in place. Do not delete and recreate them.
- Do not recreate the cluster.
Where this can fail
Three conditions have to hold before a pod holds a certificate it can use, and each one fails in its own way:
- The pod asks. A
podCertificateprojected volume is what makes the kubelet generate a key and file a request at all. - A signer answers. Kubernetes has no built-in CA for this. A request nobody
answers stays pending forever, nothing times out on the pod's behalf, and the
pod stays in
ContainerCreating. - The application uses the certificate for something. A key on disk is not mutual TLS until the server checks the other end.
The first two are platform work. The third is the part a service mesh would have done for you, and the part people forget when they replace one.
Pre-flight
Start on cplane-01:
kubectl -n payments get pods -o wide
kubectl -n payments logs -l app=payments-api --tail=5
journalctl -u pod-identity-signer -n 20 --no-pager
diff <(kubectl -n payments get deployment payments-api -o yaml) /home/laborant/payments-api.yaml
Read all four before you change anything. The last one is the interesting one.
Step 1: Get the server its certificates
The payments-api pods have never started. A pod with a podCertificate projected
volume does not start until every certificate in it has been issued, so this is not
a crash loop, it is a wait.
kubectl -n payments get podcertificaterequests -o wide
A request carrying no condition has not been answered by anybody. Nothing times out on the pod's behalf, so this state lasts indefinitely.
Hint 1: who is supposed to answer
Kubernetes records the request and stops there. It ships no certificate authority for pod certificates and will never issue one itself. Something has to watch for these requests and write a certificate back into the status.
That something runs on cplane-01, and it reports what it is doing when it starts:
systemctl status pod-identity-signer
journalctl -u pod-identity-signer -n 20 --no-pager
It is up, and it is healthy. So "is the signer running" is not the question.
Hint 2: two names that have to agree
A signer watches for one signer name and ignores every request that does not carry it. A pod asks for one signer name and waits for exactly that one. Put the two side by side:
kubectl -n payments get podcertificaterequests -o wide
journalctl -u pod-identity-signer -n 20 --no-pager | head -3
If they disagree, both halves are working perfectly and neither is talking to the other. The signer takes its copy from its unit:
systemctl cat pod-identity-signer
Either side can be brought into line with the other, and this step is judged on the outcome, so either fix passes.
Hint 3: making a unit change take effect
systemd reads a unit file when it loads it, not when the service restarts. After
editing a unit, systemctl restart on its own still runs what systemd loaded
earlier. systemctl cat shows you what it currently believes the unit says.
Step 2: Give the client an identity
checkout can already reach the server and verify it, because the CA bundle is
mounted at /etc/pod-identity/ca.crt:
POD=$(kubectl -n payments get pod -l app=checkout -o name | head -1)
kubectl -n payments exec ${POD#pod/} -- \
curl -sS --cacert /etc/pod-identity/ca.crt https://payments-api.payments.svc:8443/
That is one-way TLS. The client knows who the server is; the server has no idea who
the client is. Give checkout a certificate of its own.
Add a podCertificate projected volume to the checkout deployment, mounted at
/var/run/pod-identity, with the certificate at tls.crt and the key at tls.key,
signed by the same signer payments-api uses.
The shape of the volume
You do not have to invent this. payments-api already has a working one, so read
its spec:
kubectl -n payments get deployment payments-api -o yaml
The field is also self-documenting from the cluster:
kubectl explain pod.spec.volumes.projected.sources.podCertificate
That output covers every field, including which key types the kubelet will generate, and a third path option that writes the key and the chain into a single file. Prefer that single file when your application can read it, because two separate files can be read mid-rotation and disagree with each other. nginx needs them separate, which is why the server here does not use it.
The identity comes from the service account
checkout runs as the checkout service account, and the signer here turns that
into spiffe://cluster.local/ns/payments/sa/checkout. Read what was issued:
kubectl -n payments get podcertificaterequests -o json \
| jq -r '.items[] | select(.spec.serviceAccountName == "checkout") | .status.certificateChain' \
| openssl x509 -noout -ext subjectAltName
Two workloads sharing a service account share an identity. That is a design decision about your service accounts, not about certificates.
Step 3: Make the server enforce it
Both workloads now have certificates. Nothing is checking them.
POD=$(kubectl -n payments get pod -l app=checkout -o name | head -1)
kubectl -n payments exec ${POD#pod/} -- \
curl -sS --cacert /etc/pod-identity/ca.crt https://payments-api.payments.svc:8443/
# payments-api ok
# client-verify=NONE
A request with no client certificate at all is still served. Make payments-api
require one and verify it against the workload CA, so that the same request is
refused while a request presenting an issued certificate succeeds.
Where the server's configuration lives
kubectl -n payments get configmap payments-api-nginx -o yaml
The server terminates TLS but asks nothing of the client. Two directives are
missing, and they do different jobs. One gives nginx a CA to verify client certificates against; the other
makes presenting one mandatory instead of optional. The CA bundle is already
mounted into the server pod at /etc/pod-identity/ca.crt.
Both directives are in the
ngx_http_ssl_module reference;
look for the two whose names begin with ssl_verify and ssl_client.
The change is applied but nothing changed
nginx reads its configuration at startup, and updating a ConfigMap does not restart anything by itself. The projected file inside the running container updates on its own schedule, and the process will not notice either way:
kubectl -n payments rollout restart deployment payments-api
kubectl -n payments rollout status deployment payments-api
Related on iximiuz Labs
Other people's work on the same terrain, worth doing alongside this one:
- Building a Minimal Service Mesh with eBPF and Envoy by Teodor Janez Podobnik. The road this challenge deliberately does not take, and the best way to judge the trade
- Troubleshoot CrashLoopBackOff Caused by a Missing TLS Secret by Omkar Shelke. The Secret-mounted certificate this feature is meant to replace, failing in the same way
- Getting Started with OpenBao/Vault by Márk Sági-Kazár. The other common answer to getting certificates into workloads