← Recipes

阅读中文版本

Harden a Container with a Read-Only Root Filesystem

Run a Kubernetes container with a read-only root filesystem, drop Linux capabilities, and mount only the writable paths the workload actually needs.

~40 minkubernetessecuritycontainers

Task

Configure a Kubernetes Deployment with readOnlyRootFilesystem, dropped capabilities, and explicit emptyDir mounts for writable paths.

When to use this

A compromised process can modify application binaries or write a webshell into the container filesystem because it runs with a writable root and default capabilities.

Prerequisites

  • Kubernetes 1.25+ cluster with kubectl access to a staging namespace
  • Docker available for a local read-only smoke test
  • A container image that does not need to write outside known directories
  • The nginxinc/nginx-unprivileged:1.27-alpine image for the worked example

Steps

1. Prove the image starts with a read-only root and tmpfs mounts for its two writable paths before changing Kubernetes.

docker run --rm --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=32m \
  --tmpfs /var/cache/nginx:rw,noexec,nosuid,size=64m \
  nginxinc/nginx-unprivileged:1.27-alpine nginx -t

Expected output: nginx reports that the configuration file /etc/nginx/nginx.conf syntax is ok and the test is successful.

2. Confirm that the image root is not writable while the tmpfs path still accepts writes.

docker run --rm --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=32m \
  --tmpfs /var/cache/nginx:rw,noexec,nosuid,size=64m \
  nginxinc/nginx-unprivileged:1.27-alpine \
  sh -c 'touch /usr/share/nginx/html/probe 2>&1 || true; touch /tmp/ok && echo tmp-writable'

Expected output: touch reports Read-only file system for the image path and prints tmp-writable for /tmp.

3. Create the namespace and apply a Deployment with a read-only root, no capabilities, a non-root user, and emptyDir mounts for cache and temporary files.

kubectl create namespace hardening --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/nginx-readonly.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-readonly
  namespace: hardening
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-readonly
  template:
    metadata:
      labels:
        app: nginx-readonly
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        runAsGroup: 101
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: nginx
          image: nginxinc/nginx-unprivileged:1.27-alpine
          ports:
            - name: http
              containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          readinessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 2
            periodSeconds: 5
          volumeMounts:
            - name: nginx-cache
              mountPath: /var/cache/nginx
            - name: nginx-tmp
              mountPath: /tmp
      volumes:
        - name: nginx-cache
          emptyDir:
            sizeLimit: 64Mi
        - name: nginx-tmp
          emptyDir:
            sizeLimit: 32Mi

Expected output: The namespace is configured and the Deployment is created; the manifest includes readOnlyRootFilesystem: true and capabilities.drop: [ALL].

4. Wait for the rollout and confirm both replicas are Running with zero restarts.

kubectl -n hardening rollout status deploy/nginx-readonly --timeout=90s
kubectl -n hardening get pods -l app=nginx-readonly

Expected output: The rollout completes and both pods show Running with 0 restarts after several minutes.

5. Inspect the effective container security context to prove the settings were not dropped by admission or the manifest.

kubectl -n hardening get pod -l app=nginx-readonly \
  -o jsonpath='{.items[0].spec.containers[0].securityContext}' | jq

Expected output: The JSON shows readOnlyRootFilesystem true, allowPrivilegeEscalation false, and capabilities.drop containing ALL.

6. Attempt a write to the image filesystem and a write to the mounted temporary directory from inside the running container.

kubectl -n hardening exec deploy/nginx-readonly -- \
  sh -c 'touch /usr/share/nginx/html/probe 2>&1 || true; touch /tmp/ok && echo tmp-writable'

Expected output: The image path reports Read-only file system and /tmp remains writable through the emptyDir mount.

7. Enforce the restricted Pod Security Standard on the namespace and restart the Deployment to prove new pods are admitted.

kubectl label namespace hardening pod-security.kubernetes.io/enforce=restricted --overwrite
kubectl -n hardening rollout restart deploy/nginx-readonly
kubectl -n hardening rollout status deploy/nginx-readonly --timeout=90s

Expected output: The namespace is labeled restricted and the replacement pods become Ready without an admission error.

Troubleshooting

Problem

The container crashes with mkdir() /var/cache/nginx/client_temp failed (30: Read-only file system)

Solution

nginx needs a writable cache directory. Mount an emptyDir at /var/cache/nginx, or configure all temp paths to a writable tmpfs.

Problem

The pod cannot write to an emptyDir despite the mount

Solution

Check that runAsUser, runAsGroup, and fsGroup allow the process to write. A mismatch between the image user and the volume group causes permission denied.

Problem

A custom image tries to write /var/run/nginx.pid or rewrite /etc/nginx

Solution

Use an unprivileged image that writes its pid to /tmp, or mount a small emptyDir for the specific runtime path instead of making the whole root writable.

Limitations

  • A read-only root filesystem does not make the container immutable; emptyDir mounts and writable volumes remain writable at runtime.
  • Memory-backed emptyDir volumes count against the pod memory limit and can trigger an OOM kill if they grow.
  • Applications that install packages or rewrite configuration at startup need a redesign or an init container before this hardening can be applied.

The root filesystem is an attack surface

Most container images ship a writable root filesystem because it is convenient. A compromised process can then modify application code, drop a webshell into a static directory, or rewrite a configuration file that is read by a supervisor. A read-only root removes that persistence path. It does not stop the process from writing to explicitly mounted volumes, but it shrinks the places an attacker can hide and makes tampering visible.

The key is to identify every path the process legitimately writes. For nginx, that is typically /var/cache/nginx for temporary request bodies and proxy buffers, and /tmp for the pid file in the unprivileged image. Mount only those paths as emptyDir volumes. If the application writes logs to a file, route them to stdout instead; the container runtime collects stdout without a writable filesystem. If it writes uploaded files, use object storage or a dedicated persistent volume, not the container root.

Defense in depth with securityContext

readOnlyRootFilesystem is one field in a larger hardening profile. Run as a non-root UID, set allowPrivilegeEscalation: false, drop all Linux capabilities, and apply the RuntimeDefault seccomp profile. Those settings block the most common container escape primitives even if the process is compromised. The restricted Pod Security Standard encodes the same expectations; labeling the namespace turns a convention into an admission check.

Be careful with UIDs and volume permissions. The image user must be able to write the emptyDir, which usually means matching runAsUser, runAsGroup, and fsGroup to the image’s user. Kubernetes changes volume ownership based on fsGroup, but it does not make arbitrary users able to write. Test with an actual exec into the running container, not only by reading the manifest.

Verify from outside and inside

The local Docker smoke test catches a missing writable path before a rollout. Kubernetes verification has three layers: the rollout reaches Ready with zero restarts, the pod’s effective securityContext contains the expected fields, and a write attempt from inside the container fails on the image path but succeeds on the mount. The last check is the one that matters; admission can accept a manifest whose runtime behavior still crashes on a hidden write.

After enabling the restricted policy, restart the Deployment. Existing pods are not retroactively validated, so a rollout is the proof that the new profile is admitted. If the new pods fail to schedule or are rejected, read the admission error; it names the exact field that violates the policy.

Pitfalls

Do not mount the entire root as a writable volume to make a failing app start; that defeats the control. Do not put large caches on a memory-backed emptyDir; use a disk-backed volume or an external cache and set a size limit. Do not assume the image tag is immutable; pin the digest in production. And do not treat a read-only root as a substitute for patching the image, limiting network access, and running the process as non-root.

Example

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-readonly
  namespace: hardening
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-readonly
  template:
    metadata:
      labels:
        app: nginx-readonly
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        runAsGroup: 101
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: nginx
          image: nginxinc/nginx-unprivileged:1.27-alpine
          ports:
            - name: http
              containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          readinessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 2
            periodSeconds: 5
          volumeMounts:
            - name: nginx-cache
              mountPath: /var/cache/nginx
            - name: nginx-tmp
              mountPath: /tmp
      volumes:
        - name: nginx-cache
          emptyDir:
            sizeLimit: 64Mi
        - name: nginx-tmp
          emptyDir:
            sizeLimit: 32Mi