Guide · AWS EKS · Pod Security
Pod Security Admission for MCP Servers: Enforcing Security Contexts on EKS
PodSecurityPolicy was deprecated in Kubernetes 1.21 and removed in 1.25 — Pod Security Admission (PSA) is the built-in replacement, enforced by a webhook already present in every Kubernetes cluster. The restricted policy level requires non-root user, read-only root filesystem, dropped capabilities, and a seccomp profile — applying it to kube-system will immediately break CoreDNS and other system components. The standard migration strategy is to enable warn and audit modes first, observe violations, fix them, then flip to enforce.
TL;DR
Add pod-security.kubernetes.io/enforce: restricted to your MCP server namespace labels, but start with warn and audit first to surface violations without blocking deployments. The most common breakages are Alpine-based images running as root (fix with an explicit USER directive) and container processes writing to /tmp (fix with an emptyDir volume mounted at /tmp). Never apply enforce: restricted or even enforce: baseline to kube-system — it will break system pods. Use OPA Gatekeeper or Kyverno to enforce policies beyond the three built-in PSA levels.
From PodSecurityPolicy to Pod Security Admission
PodSecurityPolicy (PSP) was a cluster-wide admission controller that evaluated every pod creation against a set of policies. It was powerful but notoriously difficult to configure: RBAC binding controlled which policies applied to which users and service accounts, the interaction rules were non-obvious, and many clusters ended up with overly permissive PSPs just to get things working. Kubernetes deprecated PSP in 1.21 and removed it entirely in 1.25.
Pod Security Admission is PSP's replacement. It is built directly into the Kubernetes API server admission chain — no separate webhook to install, no CRDs to manage. It implements three fixed policy levels (privileged, baseline, restricted) and three enforcement modes (enforce, warn, audit) applied per-namespace via labels. The tradeoff: PSA is simpler and always present, but the three policy levels are coarser than PSP's per-field controls. For policies beyond what PSA provides, the recommended pattern is PSA for the namespace floor plus OPA Gatekeeper or Kyverno for custom rules.
The three policy levels
Each level is a strict superset of the previous — a pod that passes restricted also passes baseline and privileged.
| Level | What it restricts | Typical use |
|---|---|---|
privileged |
Nothing — completely unrestricted | Cluster-level infrastructure (CNI plugins, storage drivers, node agents) |
baseline |
Prevents known privilege escalation paths: no hostPID, no hostNetwork, no hostPath volumes, no privileged containers, no dangerous capabilities (NET_RAW, SYS_ADMIN, etc.) |
General workloads that don't need host access; a safe default for most application namespaces |
restricted |
Superset of baseline: container must run as non-root (runAsNonRoot: true), read-only root filesystem required, all capabilities dropped, seccomp profile must be RuntimeDefault or Localhost, allowPrivilegeEscalation: false |
Security-sensitive workloads like probe workers that process untrusted input |
The baseline level is a meaningful improvement over nothing — it stops the most common container escape vectors — but a process running as root inside a container with a writable filesystem is still dangerous. For MCP probe workers that fetch and execute logic from arbitrary remote endpoints, restricted is the appropriate floor.
Enforcement modes and namespace label syntax
Each policy level can be applied in three modes independently. You can have warn: restricted and audit: restricted before you are ready to add enforce: restricted — this lets you see what would be rejected without blocking any deployments.
# Apply all three modes to the alivemcp namespace
kubectl label namespace alivemcp \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.29 \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.29 \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/audit-version=v1.29
The -version labels pin the policy to a specific Kubernetes version's definition of that level. Without a version pin, the cluster's current version is used, which means a cluster upgrade could silently tighten your policy. Pinning to a specific version gives you predictable, reviewable policy changes. The version must be a valid Kubernetes minor version (e.g., v1.29) or latest.
# Equivalent as a namespace manifest
apiVersion: v1
kind: Namespace
metadata:
name: alivemcp
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.29
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.29
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.29
The enforcement modes behave differently:
- enforce — the admission webhook rejects the pod creation request. The user sees an error immediately. Existing running pods are not affected.
- warn — the pod is created but the API server returns a warning header.
kubectlprints the warning to stderr. CI pipelines see the warning in output but the deploy succeeds. - audit — the pod is created without any visible warning, but a policy violation annotation is added to the audit log event. Requires audit logging to be enabled to be useful.
Migration pattern for existing namespaces: apply warn + audit on day 1, monitor for violations for a sprint, fix all workloads, then apply enforce. Do not add enforce to a namespace with running pods that haven't been tested against the policy — those pods continue running but any restart (upgrade, scaling event, node failure) would fail.
Complete securityContext for restricted compliance
The restricted level requires specific fields to be set. A securityContext that passes all checks looks like this:
# deployment.yaml — securityContext fragment for restricted compliance
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: probe-worker
image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/probe-worker:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# emptyDir volumes for writable paths (see below)
volumeMounts:
- name: tmp
mountPath: /tmp
- name: npm-cache
mountPath: /home/app/.npm
volumes:
- name: tmp
emptyDir: {}
- name: npm-cache
emptyDir: {}
The seccompProfile can be set at either the pod level or the container level. Setting it at the pod level applies it to all containers and init containers in the pod, which is less error-prone. RuntimeDefault uses the container runtime's default seccomp profile (containerd ships with a profile that blocks ~40 uncommon syscalls while permitting all syscalls needed by typical server applications). Localhost allows referencing a custom profile file on the node filesystem.
The pod-level runAsNonRoot: true is a check, not a setter — it causes the admission controller to reject pods where the container image's USER is root (UID 0), but it does not change the UID. You must set runAsUser explicitly, or ensure the Dockerfile sets a non-root USER. Both together is belt-and-suspenders: even if the image accidentally ships with USER root, the pod-level check rejects it.
The readOnlyRootFilesystem trap: emptyDir for writable paths
readOnlyRootFilesystem: true makes the container's root filesystem read-only at the kernel level — the overlay mount is applied with the ro flag. Any write to a path that is not a mounted volume fails with EROFS: read-only file system. This catches a surprising number of things that programs write to without you realizing:
/tmp— many Node.js and Python libraries useos.tmpdir()or/tmpfor scratch space during HTTP response parsing, file uploads, or format conversion/var/run— PID files, Unix domain sockets, lock files~/.npm,~/.cache— package manager caches that write on first use, even in production/app/logsor/app/data— log files or local SQLite if the app writes to its own directory
The fix is always the same: mount an emptyDir volume at each path your process needs to write to. emptyDir is allocated from the node's disk (or memory if you set medium: Memory), backed by the kubelet, and is writable regardless of readOnlyRootFilesystem. It is ephemeral — data is lost when the pod is deleted — which is usually exactly what you want for temp files.
# Dockerfile changes to support readOnlyRootFilesystem
FROM node:20-alpine
# Create non-root user (UID 1000 is conventional for app users)
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app app
WORKDIR /app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev
COPY --chown=app:app . .
# Tell npm to use /tmp for its cache instead of ~/.npm
# (works with emptyDir at /tmp)
ENV NPM_CONFIG_CACHE=/tmp/.npm-cache
# The process listens on an unprivileged port
EXPOSE 8080
USER app
CMD ["node", "server.js"]
Node.js-specific: if your probe worker uses node_modules with native addons, the addon may try to write a lock file next to the .node binary at startup. This will fail with a read-only filesystem error. Solution: set NODE_PATH or restructure the build so native addon files land in a path that is overlaid by an emptyDir volume.
Common breakages when migrating to PSA restricted
Beyond the readOnlyRootFilesystem issue, these are the most common migration failures in practice:
Alpine base image running as root. The official node:20-alpine and python:3.12-alpine images default to root (UID 0). The pod-level runAsNonRoot: true check will reject these unless you explicitly add a non-root user in the Dockerfile and switch to it with USER. The warn mode will surface this before enforce breaks the deploy.
# Alpine non-root user pattern
RUN addgroup -g 1000 app \
&& adduser -D -u 1000 -G app app
USER app
hostPath volumes. baseline and restricted both block hostPath volumes (which mount a path from the node filesystem into the pod). If your MCP server mounts a config file via hostPath, replace it with a ConfigMap or Secret volume. Legitimate uses of hostPath (node monitoring agents, log collectors) belong in a privileged-mode namespace, not your application namespace.
NET_RAW capability. Some base images include ping and tcpdump as debugging tools, and these require the NET_RAW capability. The baseline level blocks NET_RAW. The fix: drop these tools from production images. If you need them for debugging, run a separate debug container with kubectl debug rather than bundling them in the production image.
Init containers running as root. PSA enforces the policy on init containers too. A common pattern is an init container that runs chmod or chown on a volume as root before the main container starts. This breaks under restricted. Alternative: set the correct permissions at image build time, use fsGroup to control volume ownership, or build the init container to run as a non-root user that has sufficient permissions for the setup task.
OPA Gatekeeper as a complement to PSA
PSA enforces three fixed policy levels. OPA Gatekeeper adds a policy engine that can enforce arbitrary custom rules using Rego, Kubernetes' policy language. The pattern is: PSA sets the security floor for the namespace; Gatekeeper enforces organization-specific rules beyond that floor.
Gatekeeper uses two CRDs: ConstraintTemplate (defines the Rego policy logic) and Constraint (applies the template to a scope with parameters). Example — enforce that all containers in the alivemcp namespace have resource limits set:
# constraint-template-require-limits.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: requireresourcelimits
spec:
crd:
spec:
names:
kind: RequireResourceLimits
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package requireresourcelimits
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not container.resources.limits.cpu
msg := sprintf("Container '%v' missing cpu limit", [container.name])
}
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not container.resources.limits.memory
msg := sprintf("Container '%v' missing memory limit", [container.name])
}
---
# constraint-require-limits.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: RequireResourceLimits
metadata:
name: require-resource-limits-alivemcp
spec:
match:
kinds:
- apiGroups: ["apps"]
kinds: ["Deployment", "StatefulSet", "DaemonSet"]
namespaces: ["alivemcp"]
Another useful Gatekeeper policy: enforce that pods use images only from your approved ECR registry, blocking external image pulls in production:
# Rego fragment — enforce approved image registry
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not startswith(container.image, "111122223333.dkr.ecr.us-east-1.amazonaws.com/")
msg := sprintf(
"Container '%v' uses unapproved image registry: %v",
[container.name, container.image]
)
}
Gatekeeper's critical operational concern: the webhook is fail-closed by default. If the Gatekeeper webhook pods are unavailable (OOMKilled, node failure, upgrade), pod creation attempts time out and fail. Run at least two Gatekeeper controller replicas with a pod disruption budget, and consider setting failurePolicy: Ignore on the webhook for less critical namespaces — but understand that Ignore means policies are skipped if the webhook is unreachable.
Kyverno as an alternative policy engine
Kyverno is a Kubernetes-native policy engine that expresses policies as YAML rather than Rego. For teams that prefer YAML over a custom policy language, Kyverno is often faster to adopt. It supports validate (block), mutate (auto-fix), and generate (create related resources) rules in the same ClusterPolicy object.
# kyverno-policy-probe-worker.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: probe-worker-security
spec:
validationFailureAction: Enforce # block violations
background: true # also audit existing resources
rules:
# Require minimum 2 replicas for probe-worker deployments
- name: require-min-replicas
match:
any:
- resources:
kinds: ["Deployment"]
namespaces: ["alivemcp"]
selector:
matchLabels:
app: probe-worker
validate:
message: "probe-worker deployments must have at least 2 replicas"
pattern:
spec:
replicas: ">=2"
# Auto-mutate: inject seccompProfile if missing
- name: add-seccomp-profile
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["alivemcp"]
mutate:
patchStrategicMerge:
spec:
securityContext:
+(seccompProfile):
type: RuntimeDefault
The +(field) syntax in Kyverno's strategic merge patch means "add this field only if it does not already exist" — the mutation is idempotent and won't override an explicit Localhost seccomp profile with RuntimeDefault. Kyverno can also auto-generate a default NetworkPolicy for every new namespace using a generate rule, reducing the chance a new namespace is accidentally left open.
How AliveMCP uses Pod Security Admission
The alivemcp namespace on AliveMCP's EKS cluster carries enforce: restricted with a version pin to the Kubernetes minor version in use. The probe-worker Dockerfile starts from node:20-alpine, creates a non-root user (addgroup -g 1000 app && adduser -D -u 1000 -G app app), and runs with USER app. No host networking, no hostPath volumes, and no NET_RAW capability are needed — probe workers make outbound HTTP/HTTPS requests to MCP endpoints and write results to MSK, both of which work fine within the restricted profile. Two emptyDir volumes are mounted: one at /tmp for temporary probe response caching (HTTP responses are buffered before being parsed and serialized to MSK), and one at /home/app/.npm for npm's runtime cache writes.
The seccompProfile: RuntimeDefault policy has a direct security benefit for probe workers: MCP servers under test could theoretically return payloads designed to exploit vulnerabilities in response-parsing libraries. A syscall that an exfiltration tool or reverse shell would need — such as ptrace, open_by_handle_at, or socket operations to unexpected destinations — is blocked by the RuntimeDefault profile before the kernel executes it. OPA Gatekeeper runs alongside PSA to enforce that all containers reference images from AliveMCP's ECR registry, that every deployment in the namespace specifies CPU and memory limits, and that no deployment runs fewer than two replicas. The Gatekeeper webhook runs with two replicas and a PodDisruptionBudget of minAvailable: 1 to prevent both replicas from being disrupted simultaneously during a node drain.
Failure modes reference
| Symptom | Root cause | Fix |
|---|---|---|
Init container rejected: runAsNonRoot: true, but image uses root |
Init container image runs as UID 0; the pod-level runAsNonRoot check applies to init containers too |
Add a non-root user to the init container image and set USER; or add a container-level securityContext.runAsUser: 1000 override on the init container |
Runtime error: EROFS: read-only file system, open '/tmp/...' |
readOnlyRootFilesystem: true blocks all writes to the container layer; app writes to /tmp without a mounted volume |
Mount an emptyDir at /tmp in the pod spec; for npm/pip caches, also set the cache env var (NPM_CONFIG_CACHE=/tmp/.npm) to redirect writes to the emptyDir |
CoreDNS pods fail to start after adding enforce: baseline to kube-system |
CoreDNS and other system components use capabilities and host networking features blocked by baseline | Remove PSA labels from kube-system immediately; PSA enforce labels on system namespaces break cluster networking; only apply PSA to application namespaces |
| PSA version pin too old — new restricted requirements not enforced after cluster upgrade | enforce-version: v1.24 applies the 1.24 definition of restricted even when the cluster runs 1.29; newer seccomp requirements were added in 1.25 |
Update version pin labels when upgrading the cluster; review the Kubernetes release notes for PSA level changes in each minor version |
| All pod creation fails cluster-wide — OPA Gatekeeper webhook timeout | Gatekeeper webhook is configured failurePolicy: Fail (fail-closed); both Gatekeeper controller pods are unavailable |
Run at least 2 Gatekeeper controller replicas with a PodDisruptionBudget; monitor Gatekeeper pod health separately; consider failurePolicy: Ignore on non-critical namespaces as a safety valve |