Container security in Kubernetes requires a defense-in-depth approach spanning the entire lifecycle from image building to runtime protection. This guide provides practical implementations for securing your Kubernetes workloads using DevSecOps principles.
Container Image Security
Image Scanning in CI/CD
Integrate vulnerability scanning into your build pipeline:
# .github/workflows/container-security.yml
name: Container Security Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Container Image
run: |
docker build -t myapp:${{ github.sha }} .
- name: Run Trivy Vulnerability Scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload Trivy Results
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'
- name: Run Grype Scanner
uses: anchore/scan-action@v3
with:
image: 'myapp:${{ github.sha }}'
fail-build: true
severity-cutoff: high
- name: Scan Dockerfile with Hadolint
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: warningSecure Dockerfile Patterns
Build minimal, secure container images:
# Multi-stage build for minimal attack surface
FROM node:20-alpine AS builder
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies with security audit
RUN npm ci --only=production && \
npm audit --audit-level=high
COPY --chown=nextjs:nodejs . .
RUN npm run build
# Production stage - minimal image
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
# Copy only necessary files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Use non-root user
USER 1001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js || exit 1
EXPOSE 3000
CMD ["dist/server.js"]Image Signing and Verification
Implement supply chain security with cosign:
#!/bin/bash
# sign-and-verify.sh
IMAGE="registry.example.com/myapp:latest"
# Generate key pair (one-time setup)
cosign generate-key-pair
# Sign the image
cosign sign --key cosign.key $IMAGE
# Verify the signature
cosign verify --key cosign.pub $IMAGE
# Sign with keyless (Sigstore)
COSIGN_EXPERIMENTAL=1 cosign sign $IMAGE
# Verify keyless signature
COSIGN_EXPERIMENTAL=1 cosign verify \
--certificate-identity=user@example.com \
--certificate-oidc-issuer=https://accounts.google.com \
$IMAGEKubernetes Security Configurations
Pod Security Standards
Apply Pod Security Standards using namespace labels:
# namespace-security.yaml
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latestSecure Pod Configuration
Create pods following security best practices:
# secure-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: secure-app
template:
metadata:
labels:
app: secure-app
spec:
# Use non-root security context
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
# Service account with minimal permissions
serviceAccountName: secure-app-sa
automountServiceAccountToken: false
containers:
- name: app
image: registry.example.com/myapp:v1.0.0@sha256:abc123...
# Container security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
privileged: false
# Resource limits (prevent DoS)
resources:
limits:
cpu: "500m"
memory: "256Mi"
ephemeral-storage: "100Mi"
requests:
cpu: "100m"
memory: "128Mi"
# Probes for health checking
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
# Volume mounts for writable directories
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/.cache
# Environment from secrets
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
volumes:
- name: tmp
emptyDir:
sizeLimit: 50Mi
- name: cache
emptyDir:
sizeLimit: 100Mi
# Pod topology spread for HA
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: secure-appNetwork Security
Network Policies
Implement zero-trust networking with network policies:
# network-policies.yaml
# Default deny all ingress and egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# Allow specific app communication
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-app-traffic
namespace: production
spec:
podSelector:
matchLabels:
app: api-server
policyTypes:
- Ingress
- Egress
ingress:
# Allow from frontend pods
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
# Allow from ingress controller
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- protocol: TCP
port: 8080
egress:
# Allow to database
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow DNS
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
---
# Database network policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: database-policy
namespace: production
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api-server
ports:
- protocol: TCP
port: 5432Admission Controllers
OPA Gatekeeper Policies
Enforce security policies with OPA Gatekeeper:
# gatekeeper-constraints.yaml
# Constraint Template: Require Non-Root
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequirenonroot
spec:
crd:
spec:
names:
kind: K8sRequireNonRoot
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequirenonroot
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not container.securityContext.runAsNonRoot
msg := sprintf("Container %v must set runAsNonRoot to true", [container.name])
}
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
container.securityContext.runAsUser == 0
msg := sprintf("Container %v must not run as root (UID 0)", [container.name])
}
---
# Apply the constraint
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireNonRoot
metadata:
name: require-non-root
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces:
- production
- staging
---
# Constraint Template: Require Resource Limits
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequireresourcelimits
spec:
crd:
spec:
names:
kind: K8sRequireResourceLimits
validation:
openAPIV3Schema:
type: object
properties:
maxCpu:
type: string
maxMemory:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequireresourcelimits
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not container.resources.limits.cpu
msg := sprintf("Container %v must specify CPU limits", [container.name])
}
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not container.resources.limits.memory
msg := sprintf("Container %v must specify memory limits", [container.name])
}
---
# Constraint Template: Require Image Digest
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequireimagedigest
spec:
crd:
spec:
names:
kind: K8sRequireImageDigest
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequireimagedigest
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not contains(container.image, "@sha256:")
msg := sprintf("Container %v must use image digest, not tag", [container.name])
}Kyverno Policies
Alternative policy engine with Kyverno:
# kyverno-policies.yaml
# Require Labels
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-labels
spec:
validationFailureAction: Enforce
rules:
- name: require-team-label
match:
resources:
kinds:
- Pod
validate:
message: "The label 'team' is required"
pattern:
metadata:
labels:
team: "?*"
---
# Add Default Security Context
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-default-securitycontext
spec:
rules:
- name: add-security-context
match:
resources:
kinds:
- Pod
mutate:
patchStrategicMerge:
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
---
# Restrict Image Registries
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-image-registries
spec:
validationFailureAction: Enforce
rules:
- name: validate-registries
match:
resources:
kinds:
- Pod
validate:
message: "Images must be from approved registries"
pattern:
spec:
containers:
- image: "registry.example.com/* | gcr.io/myproject/*"Runtime Security
Falco Runtime Monitoring
Deploy Falco for runtime threat detection:
# falco-rules.yaml
customRules:
rules-custom.yaml: |-
# Detect shell spawned in container
- rule: Shell Spawned in Container
desc: Detect shell spawned in a container
condition: >
spawned_process and
container and
shell_procs and
not shell_allowed_parent_processes
output: >
Shell spawned in container
(user=%user.name container=%container.name shell=%proc.name
parent=%proc.pname cmdline=%proc.cmdline container_id=%container.id
image=%container.image.repository)
priority: WARNING
tags: [container, shell, mitre_execution]
# Detect sensitive file access
- rule: Sensitive File Access
desc: Detect access to sensitive files
condition: >
open_read and
container and
(fd.name startswith /etc/shadow or
fd.name startswith /etc/passwd or
fd.name startswith /proc/self/environ)
output: >
Sensitive file accessed
(user=%user.name file=%fd.name container=%container.name
image=%container.image.repository)
priority: CRITICAL
tags: [container, filesystem, mitre_credential_access]
# Detect crypto mining
- rule: Crypto Mining Activity
desc: Detect potential crypto mining
condition: >
spawned_process and
container and
(proc.name in (xmrig, minerd, cpuminer) or
proc.cmdline contains "stratum+tcp" or
proc.cmdline contains "pool.minexmr")
output: >
Crypto mining detected
(user=%user.name process=%proc.name container=%container.name
cmdline=%proc.cmdline image=%container.image.repository)
priority: CRITICAL
tags: [container, cryptomining, mitre_resource_hijacking]Security Monitoring with eBPF
Deploy Tetragon for kernel-level security:
# tetragon-policy.yaml
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: container-security-events
spec:
kprobes:
- call: "security_file_open"
syscall: false
args:
- index: 0
type: "file"
selectors:
- matchArgs:
- index: 0
operator: "Prefix"
values:
- "/etc/shadow"
- "/etc/passwd"
- "/root/.ssh"
action: Audit
---
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: network-connections
spec:
kprobes:
- call: "tcp_connect"
syscall: false
args:
- index: 0
type: "sock"
selectors:
- matchNamespaces:
- namespace: production
operator: In
action: AuditSecrets Management
External Secrets Operator
Sync secrets from external vaults:
# external-secrets.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-backend
namespace: production
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "production-app"
serviceAccountRef:
name: "vault-auth"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: app-secrets
creationPolicy: Owner
data:
- secretKey: database-url
remoteRef:
key: production/database
property: connection_string
- secretKey: api-key
remoteRef:
key: production/api
property: keySecurity Scanning Pipeline
Complete Security Pipeline
#!/usr/bin/env python3
# security_pipeline.py
import subprocess
import json
import sys
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class ScanResult:
scanner: str
passed: bool
critical: int
high: int
medium: int
findings: List[dict]
def run_trivy_scan(image: str) -> ScanResult:
"""Run Trivy vulnerability scan"""
result = subprocess.run(
["trivy", "image", "--format", "json", "--quiet", image],
capture_output=True,
text=True
)
data = json.loads(result.stdout)
critical = high = medium = 0
findings = []
for result_item in data.get("Results", []):
for vuln in result_item.get("Vulnerabilities", []):
severity = vuln.get("Severity", "UNKNOWN")
if severity == "CRITICAL":
critical += 1
elif severity == "HIGH":
high += 1
elif severity == "MEDIUM":
medium += 1
findings.append({
"id": vuln.get("VulnerabilityID"),
"severity": severity,
"package": vuln.get("PkgName"),
"title": vuln.get("Title")
})
passed = critical == 0 and high == 0
return ScanResult(
scanner="trivy",
passed=passed,
critical=critical,
high=high,
medium=medium,
findings=findings
)
def run_kubesec_scan(manifest_path: str) -> ScanResult:
"""Run kubesec Kubernetes manifest scan"""
result = subprocess.run(
["kubesec", "scan", manifest_path],
capture_output=True,
text=True
)
data = json.loads(result.stdout)
score = data[0].get("score", 0) if data else 0
critical_findings = data[0].get("scoring", {}).get("critical", []) if data else []
findings = [{"type": "critical", "rule": f} for f in critical_findings]
return ScanResult(
scanner="kubesec",
passed=score >= 0 and len(critical_findings) == 0,
critical=len(critical_findings),
high=0,
medium=0,
findings=findings
)
def main():
image = sys.argv[1] if len(sys.argv) > 1 else "myapp:latest"
manifest = sys.argv[2] if len(sys.argv) > 2 else "deployment.yaml"
print(f"🔍 Running security scans for {image}")
# Run scans
trivy_result = run_trivy_scan(image)
kubesec_result = run_kubesec_scan(manifest)
# Report results
all_passed = trivy_result.passed and kubesec_result.passed
print(f"\n📊 Trivy Results:")
print(f" Critical: {trivy_result.critical}")
print(f" High: {trivy_result.high}")
print(f" Status: {'✅ PASSED' if trivy_result.passed else '❌ FAILED'}")
print(f"\n📊 Kubesec Results:")
print(f" Critical: {kubesec_result.critical}")
print(f" Status: {'✅ PASSED' if kubesec_result.passed else '❌ FAILED'}")
print(f"\n{'✅ All security checks passed!' if all_passed else '❌ Security checks failed!'}")
sys.exit(0 if all_passed else 1)
if __name__ == "__main__":
main()Summary
Container security in Kubernetes requires multiple layers of protection:
- Build time: Scan images, use minimal base images, sign artifacts
- Deploy time: Admission controllers enforce policies
- Runtime: Monitor for anomalies and threats
- Network: Zero-trust with network policies
- Secrets: External management with rotation
Implementing these controls as part of your DevSecOps pipeline ensures security is automated and consistent across all environments.