Blocking Privileged Containers with Pod Security Admission After PSP Removal
K8s Security Deep Dive, Part 2 — Part 1 locked down network isolation with NetworkPolicy. This time it is workload privilege control.
PSP Is Gone. Privileged Containers Are Not.
PodSecurityPolicy (PSP), deprecated in Kubernetes v1.21, was fully removed in v1.25. The catch: clusters that had been using PSP lost their entire blocking layer on upgrade. The result is a surprising number of clusters where dangerous Pods—privileged: true, hostPath mounts, running as root—come up with no resistance. If worker-node privileges are ever compromised via container escape, every workload on that node is at risk.
The built-in standard filling that gap is Pod Security Admission (PSA). It went GA in v1.25, ships inside the API server with no extra install, and turns on with a single namespace label. Alongside the CIS Kubernetes Benchmark and supply-chain hardening, the restricted profile has become the de facto recommended default.
In production, PSA is not a “block everything” switch. Treat it as a guardrail you enable in stages so it lands without breakage. This post lays out that procedure in copy-paste form.
PSA at a Glance: 3 Modes × 3 Levels
PSA is namespace-scoped. You set policy as a combination of mode (how it reacts) and level (how strict it is).
The Three Modes
| Mode | Behavior | Use |
|---|---|---|
enforce | Reject violating Pods | Actual blocking (production) |
audit | Do not reject; record in audit logs only | Discover violations |
warn | Do not reject; show a warning in kubectl | Immediate user feedback |
All three modes can be applied at the same time. For example, enforce=baseline + audit=restricted enforces baseline while tracking restricted violations in logs—useful for staged rollout.
The Three Levels and What They Ban
| Item | privileged | baseline | restricted |
|---|---|---|---|
privileged: true | Allow | ❌ Ban | ❌ Ban |
hostNetwork / hostPID / hostIPC | Allow | ❌ Ban | ❌ Ban |
hostPath volumes | Allow | ❌ Ban | ❌ Ban |
| Adding dangerous capabilities | Allow | Some only | ❌ (must drop ALL) |
allowPrivilegeEscalation: false | Not required | Not required | ✅ Required |
runAsNonRoot: true | Not required | Not required | ✅ Required |
seccompProfile: RuntimeDefault | Not required | Not required | ✅ Required |
In short: privileged = unrestricted, baseline = block known privilege-escalation paths (minimum restriction), restricted = current security best practices (strong restriction). PSA inspects the Pod spec at the admission controller, so objects that violate policy are rejected before they are stored in etcd.
Blocking It for Real: Applying Namespace Labels
Create a test namespace and turn on restricted enforce.
kubectl create namespace secure-demo
# enforce + 버전 고정 (버전 핀으로 향후 정책 강화 시 불시 차단 방지)
kubectl label namespace secure-demo \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.30
# 라벨 확인
kubectl get ns secure-demo --show-labelsRecommended pattern that also enables audit/warn at the same time:
kubectl label namespace secure-demo \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.30 \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/audit-version=v1.30Now try creating a privileged Pod.
# bad-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: bad-pod
namespace: secure-demo
spec:
containers:
- name: app
image: nginx:1.27
securityContext:
privileged: true
volumes:
- name: host
hostPath:
path: /kubectl apply -f bad-pod.yamlThe actual rejection message looks like this.
Error from server (Forbidden): error when creating "bad-pod.yaml": pods "bad-pod" is forbidden:
violates PodSecurity "restricted:v1.30": privileged (container "app" must not set securityContext.privileged=true),
allowPrivilegeEscalation != false (container "app" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]),
restricted volume types (volume "host" uses restricted volume type "hostPath"),
runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "app" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")What each line means:
- privileged — The container gets nearly unrestricted access to the node kernel. The most dangerous item.
- allowPrivilegeEscalation != false — Does not block privilege escalation via
setuidand similar. - unrestricted capabilities — Does not drop all Linux capabilities.
- restricted volume types: hostPath — Attempts to mount the node filesystem directly (here, even
/as root). - runAsNonRoot != true — May run as root (UID 0).
- seccompProfile — No syscall filtering applied.
Making It Pass: a securityContext That Satisfies restricted
Fix the violations one by one. Here is a Before/After comparison.
Before (rejected):
spec:
containers:
- name: app
image: nginx:1.27
securityContext:
privileged: true
volumes:
- name: host
hostPath:
path: /After (passes):
apiVersion: v1
kind: Pod
metadata:
name: good-pod
namespace: secure-demo
spec:
securityContext: # Pod-level
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginxinc/nginx-unprivileged:1.27 # 비특권 포트(8080)로 동작
ports:
- containerPort: 8080
securityContext: # container-level
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
volumes:
- name: cache
emptyDir: {} # hostPath → emptyDir로 교체The key points: (1) use an unprivileged image instead of default nginx that must run as root, (2) replace hostPath with an allowed volume type such as emptyDir/configMap/PVC, and (3) fill in the four-item securityContext set (runAsNonRoot, allowPrivilegeEscalation: false, drop ALL, seccompProfile) at both Pod and container level.
Wrap-up: Staged Migration Without Breaking Production
Turning on enforce=restricted immediately on an existing cluster will reject production workloads in bulk. Follow this order.
# ① 먼저 경고/감사만 — 차단하지 않고 위반만 노출
kubectl label namespace prod \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
# (kubectl apply/rollout 시 warning 메시지로 위반 워크로드 즉시 식별)- Apply warn + audit only → identify which workloads trip the policy
- Check audit logs → aggregate violations from API server audit logs via the
pod-security.kubernetes.io/audit-violationsannotation - Fix securityContext → bulk-update GitOps manifests using the After example above
- Promote to enforce → finally switch to
enforce=restricted
If you need cluster-wide defaults, configure the PodSecurity plugin defaults (default enforce/audit/warn levels) and exemptions in the API server’s AdmissionConfiguration. That applies even to unlabeled namespaces, which is safer.
Finally, PSA is a built-in baseline guardrail. Fine-grained custom rules such as “restrict image registries” or “require certain labels” are typically complemented by Kyverno or OPA Gatekeeper. That division of labor is the standard approach.
Coming next (Part 3): RBAC least-privilege design — shrink who can do what and cut the attack surface.
FAQ
Q. How do I exempt namespaces that truly need hostPath/privileged, such as monitoring agents?
A. Label only that namespace with pod-security.kubernetes.io/enforce=privileged, or do not apply labels at all. If you set strong cluster-wide defaults, register the namespace under exemptions.namespaces in AdmissionConfiguration. Keep exemptions to a minimum and track them separately.
Q. Why does the command look successful when a Pod created by a Deployment or Job is rejected?
A. The Deployment/Job object itself is created successfully; the actual violation happens when the ReplicaSet/Job creates the Pod. So kubectl get deploy looks fine even though Pods never come up. Check kubectl get events -n <ns> and kubectl describe rs <replicaset> for FailedCreate and violates PodSecurity to pinpoint the container and the violated items.
Q. If raising the enforce level causes an outage, can I roll it back immediately?
A. Yes. Enforce is label-based, so kubectl label ns <ns> pod-security.kubernetes.io/enforce=baseline --overwrite relaxes it immediately. Pods that were already rejected still need to be recreated (a rollout).
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.