← Back to list

Kubernetes Pod Security Context: Harden Containers With Seccomp, Capabilities, And Resource Limits

Most people, including me for a long time, copy security context fields from examples online and move on. The fields look right, the pod…

Rajesh Kumar · 2026-06-09 02:03 · 5 claps · 9.2 min read paywalled
#kubernetes #golang #devops #container-security #cloud-native
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Kubernetes Pod Security Context: Harden Containers With Seccomp, Capabilities, And Resource Limits

Kubernetes pod security context: harden containers with seccomp, capabilities, and resource limits

Kubernetes pod security context: harden containers with seccomp, capabilities, and resource limits

Most people, including me for a long time, copy security context fields from examples online and move on. The fields look right, the pod starts, and nobody questions it. The problem is that you have no idea what those fields actually do or whether the values you set are appropriate for your workload.

In this article I want to walk you through the right way to approach this. We will start with a completely insecure deployment, run kube-score to see exactly what is wrong, and fix each issue one at a time.

On a free medium plan? Read here for free.

The application is a simple Go HTTP server I wrote for this exercise. It has endpoints that allocate memory and burn CPU on demand, which makes it perfect for demonstrating resource behaviour. The application code is not the focus here, Kubernetes is.

One thing that tripped me up multiple times during this exercise: every time you apply a change to a deployment, Kubernetes creates a new pod with a new IP address. Before running any curl command, always get the current pod IP first with kubectl get pod -l app=resource-monitoring-app -o jsonpath='{.items[0].status.podIP}'. Testing against a stale IP will give you stale results and you will spend time debugging the wrong thing.

The starting point: deliberately insecure

This is the deployment we start with. No resource limits, no security context, nothing. All the files used in this article are in the GitHub repo: https://github.com/rajeshkio/resource-monitoring-app

apiVersion: apps/v1
kind: Deployment
metadata:
  name: resource-monitoring-app
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: resource-monitoring-app
  template:
    metadata:
      labels:
        app: resource-monitoring-app
    spec:
      containers:
        - name: app
          image: rk90229/resource-monitoring-app:v0.1.4
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          env:
            - name: PORT
              value: "8080"

Now run kube-score against it. kube-score is a static analysis tool for Kubernetes manifests. It checks your YAML against a set of best practices and tells you what is missing or misconfigured.

kube-score score deploy-insecure.yaml

The output tells us everything that needs fixing:

apps/v1/Deployment resource-monitoring-app in default  💥
[CRITICAL] Container Security Context ReadOnlyRootFilesystem
    · app -> Container has no configured security context
[CRITICAL] Container Ephemeral Storage Request and Limit
    · app -> Ephemeral Storage limit is not set
    · app -> Ephemeral Storage request is not set
[CRITICAL] Pod Probes
    · Container is missing a readinessProbe
[CRITICAL] Container Resources
    · app -> CPU limit is not set
    · app -> Memory limit is not set
    · app -> CPU request is not set
    · app -> Memory request is not set
[CRITICAL] Pod NetworkPolicy
    · The pod does not have a matching NetworkPolicy
[CRITICAL] Container Security Context User Group ID
    · app -> Container has no configured security context
[WARNING] Deployment Replicas
    · Deployment few replicas

Five critical issues. We will fix all of them except NetworkPolicy, which is a CNI-specific topic that deserves its own article. Let me go through each one.

How to set Kubernetes resource requests and limits from real measurements

The biggest mistake we make with resource limits is guessing the values. The right way is to measure the application under realistic load and then set values based on what we observe.

Start by checking idle usage right after the pod starts:

kubectl top pod -l app=resource-monitoring-app --containers

POD                                        NAME  CPU(cores)  MEMORY(bytes)
resource-monitoring-app-67d65d8478-2mzvn   app   1m          14Mi

That is your idle baseline, 1 millicore CPU and 14 MiB memory. Now put the application under load. I sent 1000 requests to the cache endpoint to fill memory, and ran CPU-intensive work in parallel:

# Terminal 1: fill the in-memory cache
for i in {1..1000}; do
  curl -s "http://<pod-ip>:8080/cache?size=128" > /dev/null
  sleep 0.5
done

# Terminal 2: burn CPU in parallel
for i in {1..1000}; do
  curl -s "http://<pod-ip>:8080/work?duration=500" > /dev/null
  sleep 0.5
done

Watch the metrics climb in Grafana while those loops run.

Memory climbs steadily from 0 to around 140 MiB over 14 minutes as the cache fills up

CPU ramps up to 0.5 cores and plateaus as concurrent work requests saturate the single vCPU

Peak observed usage: around 140 MiB memory and 500 millicore CPU. Now set values based on this.

Requests should be near your idle baseline. That is what the Kubernetes scheduler uses to decide which node to place your pod on. Limits should give you headroom above your observed peak to handle spikes without getting killed.

Before we look at the values, understand the difference between hitting a memory limit versus a CPU limit. If your pod exceeds its memory limit, Kubernetes kills it immediately with an OOMKill. If it exceeds its CPU limit, Kubernetes throttles it, it slows down but keeps running. This distinction matters when you are deciding how much headroom to leave.

resources:
  requests:
    cpu: 250m
    memory: 64Mi
    ephemeral-storage: 100Mi
  limits:
    cpu: 600m
    memory: 192Mi
    ephemeral-storage: 500Mi

Proving memory limits work

To show that the memory limit actually does what we expect, let me deliberately breach it. I sent 500 requests with 512 KB payloads each to push memory past 192 MiB:

for i in {1..500}; do
  curl -s "http://<pod-ip>:8080/cache?size=512" > /dev/null
done

Watch the pod status in a separate terminal while that runs:

kubectl get pod -l app=resource-monitoring-app -w

The pod status changes to OOMKilled and then restarts. You can confirm the exact reason afterwards:

kubectl describe pod -l app=resource-monitoring-app | grep -A 5 "Last State"

Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Sat, 06 Jun 2026 19:00:32 +0530
      Finished:     Sat, 06 Jun 2026 19:00:39 +0530

Exit code 137 always means OOMKill. The pod ran for seven seconds before Kubernetes killed it. In Grafana you can see this as a sharp vertical drop to zero.

Memory was at around 140 MiB then dropped to zero at 13:23 when Kubernetes OOMKilled the pod. The pod restarted fresh at around 5 MiB.

Kubernetes readiness probe: stop sending traffic to unready pods

Without a readiness probe, Kubernetes sends traffic to your pod the moment the container starts. If your application takes a few seconds to initialise, those early requests will fail. The readiness probe tells Kubernetes to wait until the application signals it is ready before sending any traffic.

Our application exposes a /health endpoint that returns 200 immediately once the server is listening. This is exactly what a readiness probe should point at:

readinessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

Drop Linux capabilities in Kubernetes containers with drop: ALL

Linux capabilities are a way of splitting root privileges into smaller pieces. Instead of a process either being root or not, capabilities let you grant specific permissions, like the ability to bind to ports below 1024, or the ability to change file ownership, without granting full root access.

By default, every container gets 14 capabilities even if it needs none of them. Let me show you what the container actually has:

kubectl exec deployment/resource-monitoring-app -- \
  cat /proc/1/status | grep -E "Cap(Inh|Prm|Eff|Bnd)"

CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 00000000a80425fb

The four fields here are: Inheritable (passed to child processes), Permitted (what the process could activate), Effective (what is currently active), and Bounding (the absolute ceiling of what is possible). The effective capabilities are already zero at runtime, the Go binaries do not use them. But the bounding set a80425fb is non-zero, meaning a process inside this container could potentially gain those capabilities.

Decode that hex value to see what is in it:

capsh --decode=00000000a80425fb

0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,
cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,
cap_net_bind_service,cap_net_raw,cap_sys_chroot,
cap_mknod,cap_audit_write,cap_setfcap

14 capabilities on an HTTP server that makes outbound API calls and returns JSON. It needs none of these. Drop all of them:

securityContext:
  capabilities:
    drop: ["ALL"]

After applying, check the bounding set again:

kubectl exec deployment/resource-monitoring-app -- \
  cat /proc/1/status | grep -E "Cap(Inh|Prm|Eff|Bnd)"

CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000000000

All four fields are zero. The application still serves requests normally. No capability was needed.

Kubernetes seccomp profile: block dangerous syscalls with RuntimeDefault

This is where I want to show you something important. After dropping all capabilities, you might think the container is well protected. Let me show you why that is not quite right.

Capabilities control what the process is allowed to do at the OS level. Seccomp controls which system calls the process can make to the kernel. These are two separate protection layers. Dropping capabilities does not prevent a process from making raw syscalls.

To demonstrate this, the application has a /syscall-test endpoint that attempts to call keyctl a syscall that interacts with the Linux kernel keyring subsystem. No HTTP server has any legitimate reason to call this. Let me show you what happens without a seccomp profile, even after dropping all capabilities:

curl http://<pod-ip>:8080/syscall-test

{"keyctl":"allowed","message":"keyctl reached kernel - no seccomp protection","safe":false}

The syscall reached the kernel. It failed because we passed invalid arguments, but the point is that it was not blocked. Now add a seccomp profile. The profile goes at the pod spec level, not the container level, this is a common mistake:

spec:
  template:
    spec:
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: app
          securityContext:
            capabilities:
              drop: ["ALL"]

Apply the change and test the same endpoint:

curl http://<pod-ip>:8080/syscall-test

{"keyctl":"blocked by seccomp","message":"keyctl blocked - seccomp active","safe":true}

The syscall never reached the kernel this time. You can also verify directly that seccomp is active:

kubectl exec deployment/resource-monitoring-app -- \
  cat /proc/1/status | grep Seccomp

Seccomp:        2
Seccomp_filters: 1

Seccomp: 2 means the filter is active. A value of 0 means the container is running unconfined. RuntimeDefault blocks around 44 dangerous syscalls and almost never breaks real applications. It is one line of YAML and worth applying to every pod.

Run containers as non-root with readOnlyRootFilesystem in Kubernetes

First, check what user your process is running as:

kubectl exec deployment/resource-monitoring-app -- id

uid=100(app) gid=101(app) groups=101(app)

The user ID 100 is technically non-root, but kube-score flags anything below 10000 as too low because it risks conflicting with host system users. Update the Dockerfile to use a higher UID and set it explicitly in the security context:

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001

Now check what paths the application actually writes to at runtime. Do this before enabling readOnlyRootFilesystem, not after. Exec into the pod and try writing to different locations:

kubectl exec deployment/resource-monitoring-app -- touch /tmp/test
# no error — /tmp is writable

kubectl exec deployment/resource-monitoring-app -- touch /test
touch: /test: Permission denied

kubectl exec deployment/resource-monitoring-app -- touch /home/test
touch: /home/test: Permission denied

Only /tmp is writable, and our application does not actually write anything there at runtime, the logs go to stdout and all state lives in memory. Enable readOnlyRootFilesystem and mount /tmp as an emptyDir volume. Mounting /tmp means it stays writable through a volume even with the root filesystem locked down:

securityContext:
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001
  capabilities:
    drop: ["ALL"]
volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

After applying, verify both that /tmp is still writable via the volume and that the rest of the filesystem is locked:

kubectl exec deployment/resource-monitoring-app -- touch /tmp/test
# succeeds — emptyDir volume is writable

kubectl exec deployment/resource-monitoring-app -- touch /test
touch: /test: Read-only file system

The final hardened manifest

apiVersion: apps/v1
kind: Deployment
metadata:
  name: resource-monitoring-app
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: resource-monitoring-app
  template:
    metadata:
      labels:
        app: resource-monitoring-app
    spec:
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: app
          image: rk90229/resource-monitoring-app:v0.1.4
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          env:
            - name: PORT
              value: "8080"
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          securityContext:
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 10001
            runAsGroup: 10001
            capabilities:
              drop: ["ALL"]
          resources:
            requests:
              cpu: 250m
              memory: 64Mi
              ephemeral-storage: 100Mi
            limits:
              cpu: 600m
              memory: 192Mi
              ephemeral-storage: 500Mi
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}

kube-score after hardening

kube-score score deploy.yaml

apps/v1/Deployment resource-monitoring-app in default  💥
[CRITICAL] Pod NetworkPolicy
    · The pod does not have a matching NetworkPolicy
[OK] Pod Probes
[WARNING] Deployment Replicas
    · Deployment few replicas

The one remaining critical is NetworkPolicy. That is a valid concern but it is CNI-specific and worth a separate article. Everything else is resolved, each with command output to prove it.

What to take away from this

Set resource values from measurement, not from examples. Run your application under realistic load, observe the peak, and set limits with headroom above that. For memory, getting this wrong means OOMKills. For CPU, getting it wrong means throttling.

Understand that capabilities and seccomp are two different layers. Dropping all capabilities protects you at the OS permission level. A seccomp profile protects you at the kernel syscall level. You need both. Apply drop: ALL at the container level and seccompProfile: RuntimeDefault at the pod level.

Check what your application actually writes to disk before enabling readOnlyRootFilesystem. For stateless Go binaries the answer is usually nothing, which means you can lock the filesystem completely and mount only /tmp as a volume for safety.

None of this requires deep Linux knowledge to get right. It requires measuring before configuring and verifying after applying. That is the whole workflow.

You can find the complete manifests, the application code, and the Dockerfile at https://github.com/rajeshkio/resource-monitoring-app. Clone the repo and follow along.

If you found this useful, let us connect on LinkedIn. I write about infrastructure engineering, AI systems, and building things from scratch.


메타데이터
post_id
f9b858a030a7
slug
kubernetes-pod-security-context-harden-containers-with-seccomp-capabilities-and-resource-limits-f9b858a030a7
url
https://medium.com/@rk90229/kubernetes-pod-security-context-harden-containers-with-seccomp-capabilities-and-resource-limits-f9b858a030a7
canonical_url
https://medium.com/@rk90229/kubernetes-pod-security-context-harden-containers-with-seccomp-capabilities-and-resource-limits-f9b858a030a7
author_url
https://medium.com/@rk90229
status
ok
fetched_at
2026-06-22 12:55:45