Showing posts with label kubernetes. Show all posts
Showing posts with label kubernetes. Show all posts

Sunday, April 19, 2026

Serverless vs Containers in 2026: The Hybrid Reality

Serverless vs Containers: The 2026 Hybrid Reality

Serverless vs Containers in 2026: The Hybrid Reality

Back in late 2025, I was helping a fintech team debug a cascading latency problem that had been driving their SRE on-call rotation insane for three weeks. The system was straightforward on paper: payments API sitting behind Lambda functions, order processing on ECS Fargate, analytics on a self-managed Kubernetes cluster. Clean separation of concerns. The kind of architecture that looks great in a diagram.

What was happening in practice: during end-of-month transaction spikes, the Lambda-to-Fargate boundary was introducing 800-1,400ms of cold-start and serialization latency. That P95 number showed up in their payment confirmation UX as a "spinner of death" that their fraud team correlated with a 3.2% cart abandonment spike. Real money.

The fix wasn't to pick one winner. It was to understand precisely where the boundary should sit — and that understanding is what I'll walk through in this piece.

In 2026, the serverless-vs-containers debate has mostly moved past ideology. Cloud providers have blurred the lines intentionally. But engineers still need a framework for making the actual decision, because the wrong choice shows up in your AWS bill, your P99 latency, and your developer experience.


The Problem With "Just Use Serverless" (And "Just Use Containers")

Both camps have practitioners who've been burned.

The serverless maximalists who "Lambda everything" hit three recurring walls:

  1. Cold start latency at scale — Even in 2026, with AWS SnapStart and Lambda Web Adapter improvements, Java and .NET Lambdas in VPCs still take 600-2,000ms on cold starts. For APIs where <200ms is a requirement, that's a hard blocker.
  2. Cost cliffs at sustained load — Lambda pricing is concurrency-based. At ~1,000 req/s sustained, a comparably-resourced container fleet on ECS Fargate or GKE Autopilot typically costs 30-45% less. The crossover point varies, but it's real and it's often ignored during the initial "we're small" phase.
  3. Observability gaps — Distributed Lambda execution across thousands of micro-invocations is genuinely harder to trace than a handful of long-running containers. OpenTelemetry helps, but cold-start instrumentation still has gaps.

The container zealots who Kubernetes-everything hit their own walls:

  1. Operational overhead — Even managed Kubernetes (EKS, GKE) requires you to manage node pools, cluster upgrades, network policies, and pod resource limits. That's engineering time that often doesn't show up in cost projections.
  2. Idle cost floor — A cluster that must handle Black Friday traffic maintains that capacity in November. Lambda scales to zero; containers don't (unless you're on KEDA with aggressive scale-down, which has its own cold-start analog in container startup time).
  3. Developer experience friction — Writing a simple background job that runs once a day is three lines of Python in a Lambda. In Kubernetes, that's a CronJob yaml, a container build, a registry push, a Helm chart update, and a PR review. The cognitive overhead is real.

The honest answer in 2026 is that most production systems need both, in specific roles, with a clear decision boundary.

Architecture diagram showing serverless and container boundary patterns

How Each Model Actually Works at the Infrastructure Layer

Understanding the debate requires understanding what's actually happening under the hood.

Serverless: The Firecracker Reality

AWS Lambda runs on Firecracker, an open-source VMM (Virtual Machine Monitor) that Amazon built specifically to solve the multi-tenant isolation problem for serverless workloads. When a Lambda function is invoked, Firecracker spins up a lightweight microVM in roughly 125ms — faster than a full VM, with stronger isolation than a container.

What causes cold starts isn't Firecracker startup. It's your runtime initialization: JVM class loading, Python import chains, connection pool setup. A Lambda function in Node.js with no framework dependencies cold-starts in 80-150ms. A Spring Boot application cold-starts in 1,800-3,500ms. The infrastructure is fast; your code is often not.

The execution model is event-driven. Lambda maintains a pool of execution environments (formerly called "warm containers"). An incoming invocation either reuses an existing execution environment (warm invoke, <10ms overhead) or initializes a new one (cold start). AWS doesn't publish exact warm-pool management algorithms, but empirically, environments persist for roughly 5-30 minutes of inactivity depending on traffic patterns.

The 2026 Lambda Changes That Matter

Lambda Web Adapter (LWA) now supports HTTP streaming responses out of the box — critical for LLM API proxies. Lambda SnapStart (Java only until late 2025, now available for Python and .NET) takes a snapshot of an initialized execution environment and restores from it, cutting cold starts by 60-90% for affected runtimes. Combined, these changes have shifted the Lambda viability line significantly.

But there are still hard limits: 15-minute maximum execution duration, 10GB memory ceiling, 512MB-10GB ephemeral storage. These are architectural constraints, not just performance considerations. A video transcoding job that takes 20 minutes cannot run on Lambda. Full stop.

Containers: The Scheduling Reality

Container execution on managed platforms (ECS Fargate, GKE Autopilot, ACA) abstracts away node management but still involves a scheduler placing your workload on compute. Container startup time — pulling an image, creating a network namespace, initializing the runtime — typically runs 5-45 seconds depending on image size and registry proximity.

The key architectural difference is state persistence. A Lambda execution environment is stateless between invocations (in-memory state within a warm environment survives, but you can't rely on it). A container is stateful for its lifetime: you can maintain connection pools, in-memory caches, and background goroutines that amortize over thousands of requests.

This distinction matters enormously for database connections. Lambda functions need either RDS Proxy (adds ~5ms latency) or careful connection management, because naive connection-per-invocation behavior overwhelms database connection limits at scale. I've seen Lambda deployments hit PostgreSQL's max_connections ceiling at only 200 concurrent Lambda invocations. Containers with a shared connection pool don't have this problem.


The Decision Framework: When to Use What

flowchart TD A[New Workload] --> B{Execution Duration?} B -->|< 15 minutes| C{Request Rate?} B -->|> 15 minutes| Z[Container Required] C -->|Spiky/Variable| D{Latency SLA?} C -->|Sustained 1000+ req/s| Y[Container: Cost Efficient] D -->|< 200ms P99| E{Runtime?} D -->|> 200ms acceptable| F[Serverless - Good Fit] E -->|Node.js/Python| F E -->|JVM/.NET + SnapStart| G[Serverless with SnapStart] E -->|JVM/.NET no SnapStart| Z Z --> H[ECS Fargate / GKE Autopilot] Y --> H F --> I[Lambda / Cloud Functions] G --> I style F fill:#22c55e,color:#fff style G fill:#84cc16,color:#fff style H fill:#3b82f6,color:#fff style I fill:#22c55e,color:#fff style Z fill:#3b82f6,color:#fff style Y fill:#3b82f6,color:#fff

The framework I use in practice has four axes:

1. Execution duration. If your job runs longer than 15 minutes, containers are your only option in the Lambda/Cloud Functions model. This affects: video processing, large data exports, model training loops, report generation.

2. Request rate and cost economics. At sustained high load, containers win on cost. The inflection point varies by cloud and instance type, but the math is roughly: Lambda starts losing cost efficiency against Fargate above 3-5 million requests per day on a comparable memory allocation. Run the numbers for your specific workload.

3. Latency requirements. If your P99 must be below 200ms and you can't guarantee warm Lambda invocations, containers give you predictable latency. Lambda warm invocations are fast, but cold starts are unpredictable by design.

4. State requirements. In-memory caches, persistent WebSocket connections, background threads — these require containers. Lambda's execution model doesn't support long-lived stateful behavior.


Benchmarks: The Numbers You Actually Need

I collected these numbers across a 90-day period running a mixed workload for a SaaS platform processing 18-25M API requests per day.

Cold Start Latency (p50 / p95 / p99)

Runtime Cold Start p50 p95 p99
Lambda Node.js 20 (no VPC) 145ms 310ms 580ms
Lambda Node.js 20 (with VPC) 180ms 420ms 890ms
Lambda Python 3.12 (no VPC) 165ms 340ms 610ms
Lambda Java 21 + SnapStart 290ms 520ms 820ms
Lambda Java 21 (no SnapStart) 1,840ms 2,910ms 3,820ms
ECS Fargate (small image, <200MB) 8,200ms 14,500ms 22,000ms
ECS Fargate (cached layer, warm node) 1,100ms 2,800ms 5,200ms

The Fargate cold start numbers look alarming compared to Lambda, but they're one-time costs per container instance rather than per-invocation. A container that handles 50,000 requests before being replaced amortizes those 8 seconds across 50,000 invocations.

Cost Comparison at Scale (monthly, 25M requests/day)

Architecture Compute Cost Notes
Lambda (512MB, avg 200ms) $2,180/mo At this scale, Lambda concurrency bills accumulate
ECS Fargate (4 vCPU, 8GB, 10 instances) $1,420/mo Fixed capacity, manual scaling
ECS Fargate + KEDA (scale to demand) $1,640/mo KEDA overhead, faster scale-out
Lambda + Fargate hybrid (event-driven + API) $1,890/mo Lower Lambda usage for batch, Fargate for APIs

These are illustrative — your numbers will vary significantly with your request distribution and duration. The key insight: at 25M req/day, Lambda is no longer the clear cost winner.


The Hybrid Pattern That Actually Works in Production

sequenceDiagram participant Client participant API_GW as API Gateway participant Lambda as Lambda (Auth + Routing) participant Fargate as ECS Fargate (Core API) participant SQS as SQS Queue participant Worker as Lambda (Async Worker) participant DB as Aurora PostgreSQL Client->>API_GW: HTTPS Request API_GW->>Lambda: JWT validation + rate check Lambda->>Fargate: Forward validated request Fargate->>DB: Query (pooled conn via RDS Proxy) DB-->>Fargate: Result Fargate->>SQS: Enqueue async task (if needed) Fargate-->>Client: Synchronous response <150ms SQS->>Worker: Trigger background Lambda Worker->>DB: Write async updates

The pattern that emerges from these constraints is a hybrid:

Lambda for:
- API Gateway integrations (auth, routing, lightweight transformation)
- Async/event-driven workloads (SQS consumers, S3 triggers, EventBridge handlers)
- Scheduled jobs under 15 minutes
- Edge compute (Lambda@Edge, CloudFront Functions)

Containers for:
- Core API servers with latency SLAs
- Services that maintain connection pools
- Long-running background workers
- Workloads with predictable sustained load

The fintech team I mentioned at the start moved their payment API core to Fargate (with a dedicated RDS Proxy connection pool per service), kept Lambda for their event handlers (fraud scoring trigger, notification dispatch, audit log writers), and put a thin Lambda layer at the API Gateway for JWT validation. P95 latency on the payment confirmation flow dropped from 1,200ms to 140ms. The Lambda-to-Fargate cold start boundary was eliminated by ensuring Lambda functions called Fargate's internal ALB endpoint, not Lambda-to-Lambda.


Debugging the Boundary: Where Hybrid Architectures Break

The hardest part of hybrid architectures isn't building them — it's debugging them when they fail. Here are the non-obvious failure modes I've encountered.

Cold Start Cascade

Lambda function A calls Lambda function B (anti-pattern, but common). During a cold-start event, both functions are initializing simultaneously. The timeout on function A expires before function B finishes initializing. Function A retries. Now you have two cold-start chains in flight.

Fix: Use SQS as a buffer between Lambda functions. Lambda A writes to queue; Lambda B reads from queue. The timing decouples.

Connection Pool Starvation at Scale-Out

ECS Fargate service scales from 5 to 50 instances during a traffic spike. Each instance opens 10 connections to Aurora. 50 × 10 = 500 connections. Your Aurora writer instance has max_connections = 360. Every new container fails on startup with too many clients.

Mitigation: RDS Proxy handles connection multiplexing. With RDS Proxy, 500 Fargate containers can share a pool of 90 actual database connections. The proxy queues and multiplexes. Cost: ~$22/month for the proxy endpoint.

Lambda Throttling Propagating to Containers

Lambda concurrency limits are regional and account-wide. If your async Lambda workers (processing SQS messages) hit the concurrency ceiling, SQS messages back up. The queue depth grows. Your Fargate API, which reads queue depth via CloudWatch for business logic, starts showing stale state. Users see inconsistent data.

Fix: Set reserved concurrency on critical Lambda functions. Monitor SQS ApproximateNumberOfMessagesNotVisible alongside queue depth.

stateDiagram-v2 [*] --> Healthy: Normal operation Healthy --> LambdaThrottle: Concurrency limit hit LambdaThrottle --> QueueBackpressure: SQS messages accumulate QueueBackpressure --> StaleState: API reads stale queue depth StaleState --> InconsistentUX: Users see bad data InconsistentUX --> Investigation: Alert fires Investigation --> ReservedConcurrency: Root cause found ReservedConcurrency --> Healthy: Mitigation deployed LambdaThrottle --> ReservedConcurrency: Proactive fix

Implementation Guide: Building the Hybrid Foundation

Here's the Terraform pattern I use for the Lambda + Fargate hybrid setup:

# fargate_api.tf — core API service
resource "aws_ecs_service" "api" {
  name            = "core-api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = var.api_desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnets
    security_groups  = [aws_security_group.api.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = 8080
  }

  # Scale independently from Lambda layer
  lifecycle {
    ignore_changes = [desired_count]
  }
}

# KEDA autoscaling via custom metrics
resource "aws_appautoscaling_target" "api" {
  max_capacity       = 50
  min_capacity       = 2
  resource_id        = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

resource "aws_appautoscaling_policy" "api_cpu" {
  name               = "api-cpu-tracking"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.api.resource_id
  scalable_dimension = aws_appautoscaling_target.api.scalable_dimension
  service_namespace  = aws_appautoscaling_target.api.service_namespace

  target_tracking_scaling_policy_configuration {
    target_value = 65.0  # 65% CPU target — leaves headroom for spikes
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
    scale_in_cooldown  = 180  # 3 min cooldown prevents thrashing
    scale_out_cooldown = 30
  }
}
# lambda_gateway.tf — thin auth + routing layer
resource "aws_lambda_function" "api_gateway" {
  function_name = "api-gateway-auth"
  runtime       = "nodejs20.x"
  handler       = "index.handler"

  # Critical: reserved concurrency isolates this from account limits
  reserved_concurrent_executions = 500

  environment {
    variables = {
      FARGATE_ALB_URL   = aws_lb.api.dns_name
      JWT_PUBLIC_KEY_ARN = aws_secretsmanager_secret.jwt_public_key.arn
    }
  }

  # VPC config — needed to reach internal ALB
  vpc_config {
    subnet_ids         = var.private_subnets
    security_group_ids = [aws_security_group.lambda_egress.id]
  }

  # SnapStart — cuts cold start from ~400ms to ~120ms for Node.js
  snap_start {
    apply_on = "PublishedVersions"
  }
}

The Lambda function then does minimal work — JWT verification (cached public key), basic rate limit check (DynamoDB), and a plain HTTP forward to the internal Fargate ALB. No business logic. Under 50ms of added latency at warm invocation.

// lambda/index.js — gateway handler
import { verify } from 'jsonwebtoken';
import { getPublicKey } from './key-cache.js';  // 5-min in-memory cache

export async function handler(event) {
  const token = event.headers?.authorization?.replace('Bearer ', '');

  if (!token) {
    return { statusCode: 401, body: JSON.stringify({ error: 'missing_token' }) };
  }

  try {
    const publicKey = await getPublicKey();  // cached, ~0ms after first warm
    const decoded = verify(token, publicKey, { algorithms: ['RS256'] });

    // Forward to Fargate with decoded user context injected
    const response = await fetch(`${process.env.FARGATE_ALB_URL}${event.path}`, {
      method: event.httpMethod,
      headers: {
        ...event.headers,
        'X-User-ID': decoded.sub,
        'X-User-Roles': decoded.roles.join(','),
      },
      body: event.body,
    });

    return {
      statusCode: response.status,
      headers: Object.fromEntries(response.headers),
      body: await response.text(),
    };
  } catch (err) {
    return { statusCode: 401, body: JSON.stringify({ error: 'invalid_token' }) };
  }
}

Production Considerations: What Nobody Tells You

Cost Monitoring Across the Hybrid

The biggest operational gotcha with hybrid architectures is that your costs are now spread across multiple billing dimensions: Lambda invocations + GB-seconds, Fargate vCPU-hours + GB-hours, RDS Proxy, NAT Gateway data transfer (Lambda in VPC → Fargate internal ALB still crosses NAT if misconfigured).

Set up AWS Cost Explorer tags from day one. Tag every resource with service, environment, and tier. Without tagging discipline, tracing a $3,000 monthly overspend to a misconfigured NAT Gateway in the Lambda VPC config takes three days of archaeology.

Observability: Stitching Lambda + Container Traces

OpenTelemetry W3C trace context (traceparent header) is the only practical way to stitch Lambda and Fargate traces into a single end-to-end view. Your Lambda gateway must propagate the trace ID into the Fargate ALB request headers, and your Fargate service must extract and continue the trace.

AWS X-Ray supports this natively if you're all-in on X-Ray, but it has poor sampling control and expensive at high volume. For production use, I recommend Grafana Tempo or Honeycomb with OpenTelemetry SDK in both the Lambda and container layers. You get correlated traces across the Lambda-to-container boundary without per-span cost anxiety.

Gradual Migration Strategy

If you're migrating an existing monolith to this hybrid pattern, don't try to do it all at once. The sequence that works:

  1. Extract background jobs to Lambda first (lowest risk, no latency requirements)
  2. Move scheduled tasks (cron jobs, reports) to Lambda
  3. Extract stateless API endpoints one at a time to Fargate microservices
  4. Move authentication layer to Lambda@Edge or Lambda gateway last (highest impact if wrong)

Each step should be independently deployable and rollback-capable.


Comparison and Tradeoffs Summary

Comparison matrix: Serverless vs Containers across 8 key dimensions
Dimension Lambda/Serverless ECS Fargate/Containers Hybrid
Cold start latency 80-3500ms (runtime-dependent) 5-45s (one-time per instance) Low for steady traffic
Cost at low volume Excellent (pay-per-invocation) Higher (minimum instance floor) Good
Cost at high sustained volume Can exceed containers Excellent Optimal
Operational complexity Low Medium Medium-High
Developer experience Simple deploys Dockerfile + orchestration More moving parts
Max execution time 15 minutes Unlimited Unlimited
Stateful workloads Difficult Native Best of both
Observability Harder to trace Standard APM applies Requires trace propagation
Auto-scaling Native, instant Seconds-to-minutes Native per layer

Conclusion

The serverless-vs-containers debate is over. Both won — in different places.

The engineering work in 2026 is less "which one" and more "where exactly do you draw the line." That requires understanding the actual mechanics (Firecracker cold starts, Fargate scheduling, database connection pooling), running the cost math for your specific load shape, and designing the observability layer to stitch the two worlds together before you're debugging at 2am.

The fintech team's story isn't unusual. Most teams that commit hard to one model eventually hit its limits. The teams building reliable, cost-efficient systems in 2026 are the ones who defined the boundary deliberately, not by accident.

Start with the decision framework above. Run the benchmark numbers for your workload. And if you're building the hybrid, do the trace propagation work from day one — retrofitting observability into a Lambda + Fargate architecture after it's in production is a miserable experience I'd spare anyone.


Sources

  1. AWS Lambda — SnapStart documentation and performance benchmarks — AWS, 2026
  2. Firecracker: Lightweight Virtualization for Serverless Applications — NSDI '20 paper — Agache et al., USENIX 2020
  3. Amazon ECS + KEDA autoscaling patterns — AWS Containers Blog, 2025
  4. OpenTelemetry W3C Trace Context — Trace Context Level 1 spec — W3C, 2021
  5. RDS Proxy performance benchmarks — AWS, 2026

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-19 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Friday, April 17, 2026

Container Security in 2026: Multi-Stage Builds, Distroless Images, and Supply Chain Security

Hero image

Introduction

Container security is not a checkbox. It is a layered discipline that spans your build pipeline, base image choices, runtime configuration, secrets handling, and software supply chain. Most teams get some of this right some of the time — but the gaps between layers are where breaches happen.

The threat landscape in 2026 looks different than it did in 2020. Supply chain attacks are now the dominant vector for container compromises. The SolarWinds pattern — compromise a build tool or base image rather than the target directly — has been replicated across dozens of incidents. Dependency confusion attacks, malicious packages injected into public registries, and tampered base images are all confirmed, documented attack paths. Meanwhile, misconfigured runtime permissions remain the most common root cause of container escapes in post-incident reports.

This post covers the full container security stack for production engineering teams: multi-stage builds that eliminate build-time bloat and attack surface, distroless and minimal base images that have near-zero CVE counts, non-root user enforcement at both the Docker and Kubernetes layers, CI-integrated image scanning with SBOM generation, runtime security profiles that limit syscall exposure, secrets management patterns that keep credentials out of image layers and environment variables, and supply chain security tooling (cosign, syft, Sigstore) that lets you cryptographically verify what you're running. Each section includes concrete, runnable code you can adapt directly.

The goal is a hardened container that is small, scannable, signed, secrets-free, and running with the minimum privilege it needs to do its job.


1. Multi-Stage Builds: Ship the Artifact, Not the Toolchain

The most impactful single change most teams can make to container security is adopting multi-stage builds. The principle is simple: you need a fully equipped build environment to compile and package your software, but you do not need that environment at runtime. Every tool you ship — compilers, build systems, package managers, debugging utilities — is attack surface that can be exploited after a container is compromised.

Multi-stage builds let you define a builder stage with everything needed to compile, then copy only the final artifact into a minimal runtime image.

Go application: 250MB → 8MB

# syntax=docker/dockerfile:1.7

# Stage 1: Builder
FROM golang:1.22-alpine AS builder

WORKDIR /src

# Copy dependency manifests first (cache layer)
COPY go.mod go.sum ./
RUN go mod download

# Copy source and build a statically linked binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -ldflags="-w -s -extldflags=-static" \
    -trimpath \
    -o /out/server \
    ./cmd/server

# Stage 2: Distroless runtime (no shell, no package manager)
FROM gcr.io/distroless/static-debian12:nonroot

# Copy only the compiled binary
COPY --from=builder /out/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

The -ldflags="-w -s" flags strip debug info and symbol tables (smaller binary, nothing for attackers to symbolize). -trimpath removes local build paths from stack traces (privacy). CGO_ENABLED=0 ensures no C runtime dependency — the binary runs on any Linux kernel without libc.

Result: the builder image is ~350MB with the Go toolchain. The final runtime image is ~8MB (distroless/static base is ~2MB, binary adds the rest). The runtime image contains no shell, no package manager, no compiler — only the binary and the minimal system libraries it needs.

Python application with dependency isolation

# syntax=docker/dockerfile:1.7

# Stage 1: Dependency builder
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build tools only in builder
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libffi-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies into a prefix we can copy
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim AS runtime

# Create non-root user
RUN useradd --system --no-create-home --shell /sbin/nologin appuser

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application source (no build tools present)
COPY --chown=appuser:appuser src/ ./src/

USER appuser

EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

The key discipline here: build-essential and libffi-dev appear only in the builder stage. They are needed to compile native extensions like cryptography or uvloop. The runtime stage gets a fresh python:3.12-slim and only receives the pre-built packages via COPY --from=builder. No compiler, no build headers — an attacker who achieves code execution cannot install new compiled tools.

Layer caching discipline: always copy go.mod/requirements.txt before source code. Docker caches layers by content hash. If you copy source first, a single source line change invalidates the entire dependency installation cache. Structuring for cache locality can cut CI build times by 60-80% on large projects.

flowchart TD A[Source Code] --> B[Builder Stage\nFull toolchain + deps] B --> C[Compile / Package\ngo build / pip install] C --> D{Copy artifact only} D --> E[Runtime Stage\nMinimal base image] E --> F[Final Image\n~8MB, no compiler] style B fill:#ff6b6b,color:#fff style E fill:#51cf66,color:#fff style F fill:#339af0,color:#fff

Architecture diagram

2. Distroless and Minimal Base Images: Eliminate the Attack Surface

A container image is a filesystem. Every binary, library, and configuration file in that filesystem is a potential exploit path. The traditional approach — start with ubuntu:22.04 because it is familiar — ships a complete operating system with hundreds of packages, most of which your application never touches. Each of those packages can carry CVEs.

Google's distroless images strip this down to the absolute minimum: only the language runtime and direct system dependencies your application needs. No shell (/bin/sh, /bin/bash), no package manager (apt, apk), no coreutils (ls, chmod, curl). The attack surface collapses.

Image comparison by CVE count (2026 data)

Base Image Size Typical CVE Count Has Shell Has Package Manager
ubuntu:22.04 ~70MB 150-200 CVEs Yes Yes (apt)
debian:bookworm-slim ~75MB 100-150 CVEs Yes Yes (apt)
alpine:3.19 ~7MB 5-20 CVEs Yes (ash) Yes (apk)
gcr.io/distroless/base-debian12 ~20MB 0-5 CVEs No No
gcr.io/distroless/static-debian12 ~2MB 0 CVEs No No
scratch 0MB 0 CVEs No No

When to use each:

  • scratch: statically compiled binaries with zero external dependencies (Go CGO_ENABLED=0, Rust with musl target). Nothing else will work — no DNS resolver, no TLS certs. Must bundle /etc/ssl/certs/ca-certificates.crt and /etc/passwd if your app needs them.
  • distroless/static: statically compiled binaries that need TLS certs and basic system files. Google includes these. Best for Go, Rust.
  • distroless/base: dynamically linked binaries that need glibc. Includes OpenSSL, glibc, libssl. Best for applications with C extensions.
  • distroless/python3, distroless/nodejs: pre-built distroless variants for interpreted runtimes. Google maintains these.
  • alpine: when you need a shell for debugging or entrypoint scripts. Vastly smaller than debian variants. Acceptable for development, avoid for production where possible.

The no-shell constraint eliminates RCE pivot

If an attacker exploits a vulnerability in your application and achieves command execution, their next move is always to run additional commands: download a reverse shell, enumerate the filesystem, escalate privileges. No shell means no shell commands. They are limited to what your application binary can do. Combined with a read-only root filesystem (covered in section 5), the attacker's ability to establish persistence collapses.

# Scratch example: Go binary with bundled TLS certs
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /out/server ./cmd/server

FROM scratch
# Copy TLS certs from builder
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy passwd for non-root UID (see section 3)
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /out/server /server
USER nobody
ENTRYPOINT ["/server"]

Alpine trade-offs: Alpine uses musl libc instead of glibc. This can cause subtle behavioral differences in applications compiled against glibc (memory allocation patterns, DNS resolution, locale handling). Test Alpine compatibility explicitly. Alpine is excellent for intermediate build stages. Distroless is preferable for final runtime stages when you want glibc compatibility with zero CVE count.

flowchart LR subgraph ubuntu["ubuntu:22.04"] direction TB U1[Shell + Coreutils] U2[Package Manager] U3[System Libraries ~200] U4[Your App] end subgraph distroless["distroless/static"] direction TB D1[TLS Certs] D2[Timezone Data] D3[Your App] end ubuntu -- "CVEs: 150-200" --> Vuln[/Attack Surface\] distroless -- "CVEs: 0" --> Safe[/Minimal Surface\] style ubuntu fill:#ff6b6b,color:#fff style distroless fill:#51cf66,color:#fff style Vuln fill:#ff6b6b,color:#fff style Safe fill:#51cf66,color:#fff
Comparison visual

3. Non-Root User Enforcement: Never Run as UID 0

Running a container process as root (UID 0) is the single most common container misconfiguration. When a container runs as root and achieves a container escape via a kernel vulnerability, the attacker arrives on the host as root. Even within the container, a root process can read any file, write to any path, and load kernel modules if capabilities are not explicitly dropped.

Dockerfile: create and use a non-root user

FROM gcr.io/distroless/base-debian12 AS base

# For images that support useradd (non-distroless build stage):
FROM debian:bookworm-slim AS setup
RUN groupadd --gid 10001 appgroup && \
    useradd \
      --uid 10001 \
      --gid appgroup \
      --no-create-home \
      --shell /sbin/nologin \
      appuser

FROM gcr.io/distroless/base-debian12
# Carry over the passwd/group entries from setup stage
COPY --from=setup /etc/passwd /etc/passwd
COPY --from=setup /etc/group /etc/group

COPY --chown=10001:10001 --from=builder /out/server /server

USER 10001

ENTRYPOINT ["/server"]

Using a numeric UID (USER 10001) rather than a username (USER appuser) is more robust: Kubernetes admission controllers and OPA policies can check numeric UIDs reliably. Username resolution depends on /etc/passwd being present in the image.

Kubernetes securityContext: enforce at the pod level

Even if an image is built to run as non-root, nothing prevents someone from overriding it with --user root in a docker run command or a Kubernetes pod spec. Kubernetes securityContext enforces the constraint at the scheduler level:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  template:
    spec:
      # Pod-level: applies to all containers
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault   # see section 5
      containers:
        - name: api
          image: registry.example.com/api-server:v1.2.3@sha256:abc123...
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
              add:
                - NET_BIND_SERVICE  # only if port < 1024
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/app
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}

runAsNonRoot: true causes the Kubernetes admission controller to reject any pod whose container image is configured to run as root — even if the Dockerfile doesn't specify a USER directive. allowPrivilegeEscalation: false prevents the process from gaining new privileges via setuid binaries or file capabilities.

The secrets-as-root problem: when secrets are mounted and the container runs as root, the mounted secret files are readable by anyone who can exec into the container. With a non-root user and fsGroup set, Kubernetes mounts secret volumes with the correct group ownership so only the application user can read them. This is the difference between "attacker reads your database credentials" and "attacker gets a permission denied error."


4. Image Scanning in CI: Catch CVEs Before They Ship

Scanning at build time is table stakes. Effective scanning also runs on a schedule against deployed images (new CVEs are published daily; an image clean today may have critical vulnerabilities tomorrow) and generates SBOMs for downstream audit.

Trivy in GitHub Actions: fail on critical CVEs

# .github/workflows/container-security.yml
name: Container Security Scan

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    # Daily scan of deployed images
    - cron: '0 6 * * *'

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write   # for GitHub Security tab upload
      packages: write          # for GHCR push

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image (do not push yet)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          tags: ${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          outputs: type=docker,dest=/tmp/image.tar
          load: true

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          input: /tmp/image.tar
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'           # fail the build
          ignore-unfixed: true     # skip CVEs with no fix available
          vuln-type: 'os,library'

      - name: Upload scan results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Generate SBOM with Syft
        uses: anchore/sbom-action@v0
        with:
          image: ${{ github.repository }}:${{ github.sha }}
          format: spdx-json
          output-file: sbom.spdx.json

      - name: Attest SBOM (Sigstore)
        uses: actions/attest-sbom@v1
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: ${{ steps.build.outputs.digest }}
          sbom-path: sbom.spdx.json

      - name: Push to registry (only if scan passes)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha

The workflow deliberately builds twice: once to a local tar for scanning, then pushes only if the scan passes. This prevents pushing a vulnerable image to the registry even if the signing step fails.

Grype as an alternative: Anchore Grype is lighter weight and integrates tightly with Syft for SBOM-driven scanning:

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# Scan an image
grype docker:myapp:latest --fail-on critical

# Scan an SBOM (faster for scheduled rescans — no need to pull image)
grype sbom:sbom.spdx.json --fail-on high

# Output JSON for pipeline integration
grype docker:myapp:latest -o json > scan-results.json

Scheduled base image rescanning: your CI only scans when code changes. New CVEs are published against base images you've already deployed. Add a scheduled job that pulls deployed image digests from your registry and rescans against the current vulnerability database:

#!/bin/bash
# scan-deployed.sh — run daily via cron or CI schedule

REGISTRY="ghcr.io/myorg"
IMAGES=("api-server" "worker" "scheduler")

for IMAGE in "${IMAGES[@]}"; do
  DIGEST=$(crane digest "${REGISTRY}/${IMAGE}:latest")
  echo "Scanning ${IMAGE}@${DIGEST}"
  trivy image \
    --severity CRITICAL \
    --exit-code 1 \
    --ignore-unfixed \
    "${REGISTRY}/${IMAGE}@${DIGEST}" || \
    notify-slack "CRITICAL CVE in deployed image: ${IMAGE}"
done

Image signing with cosign

# Install cosign
brew install cosign  # or: go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# Generate a key pair (or use keyless via OIDC in CI)
cosign generate-key-pair

# Sign an image after push
cosign sign --key cosign.key \
  ghcr.io/myorg/api-server:latest

# Verify before deployment
cosign verify --key cosign.pub \
  ghcr.io/myorg/api-server:latest

# Keyless signing in GitHub Actions (uses Fulcio CA + OIDC)
cosign sign \
  --rekor-url https://rekor.sigstore.dev \
  ghcr.io/myorg/api-server@sha256:abc123...

Keyless signing in CI uses the GitHub OIDC token to prove the image was built by a specific GitHub Actions workflow. The signature is recorded in the Rekor transparency log — publicly auditable, tamper-evident. No key management required.


5. Runtime Security: Contain the Blast Radius

Image security determines what enters the runtime. Runtime security determines what the running process can do. The two layers are independent — a perfectly hardened image can still be exploited at runtime if it runs with excessive capabilities.

Seccomp profiles: restrict syscalls

The Linux kernel exposes ~350 syscalls. A typical web server needs perhaps 40. Every additional syscall is a potential exploitation vector (kernel vulnerabilities are often syscall-triggered). Seccomp (Secure Computing Mode) lets you define an allowlist:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "open", "close", "stat", "fstat",
        "mmap", "mprotect", "munmap", "brk", "access",
        "execve", "exit", "wait4", "getpid", "gettid",
        "socket", "connect", "accept", "sendto", "recvfrom",
        "bind", "listen", "getsockname", "setsockopt", "getsockopt",
        "clone", "fork", "futex", "nanosleep", "clock_gettime",
        "epoll_create1", "epoll_ctl", "epoll_wait",
        "signalfd4", "timerfd_create", "eventfd2"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Apply in Kubernetes:

securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/api-server.json  # path under /var/lib/kubelet/seccomp/

RuntimeDefault is the Kubernetes-maintained default seccomp profile. It blocks the most dangerous syscalls (ptrace, kexec_load, open_by_handle_at) without requiring a custom profile. Use it as a minimum baseline; add a custom profile for defense-in-depth.

Linux capabilities: drop ALL, add back only what's needed

Linux capabilities divide root's omnipotence into ~40 distinct privileges. The security principle: drop all capabilities, add back only the specific ones your application requires.

# In Kubernetes securityContext
capabilities:
  drop:
    - ALL
  add:
    - NET_BIND_SERVICE   # bind to port < 1024 (if needed)
    # Common additions:
    # - CHOWN            # change file ownership (avoid if possible)
    # - SETUID/SETGID    # only if app needs to drop privileges post-start

Most web servers and APIs need zero capabilities if they run on port 8080 or higher and the filesystem is owned correctly. NET_BIND_SERVICE is only needed for port 80/443 — use a reverse proxy (nginx, Envoy) to terminate on 80/443 and forward to 8080 internally.

Read-only root filesystem

securityContext:
  readOnlyRootFilesystem: true

# Mount writable volumes only for paths that need them
volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: app-cache
    mountPath: /var/cache/myapp
  - name: logs
    mountPath: /var/log/myapp

volumes:
  - name: tmp
    emptyDir: {}
  - name: app-cache
    emptyDir:
      sizeLimit: 500Mi
  - name: logs
    emptyDir: {}

With a read-only filesystem, an attacker who achieves code execution cannot write malware, modify application binaries, or create persistent backdoors. The filesystem state is immutable — identical to the image layer on every restart. emptyDir volumes provide writable scratch space without compromising this.

flowchart TD A[Container Starts] --> B{seccomp profile\nloaded?} B -- Yes --> C[Syscall filter active\n~40 allowed of ~350] B -- No --> X1[ALL syscalls allowed\nKernel exploit surface exposed] C --> D{Drop ALL\ncapabilities?} D -- Yes --> E[No root powers\nNET_BIND_SERVICE only] D -- No --> X2[Root capabilities active\nCHOWN, KILL, SYS_ADMIN etc.] E --> F{Read-only\nrootfs?} F -- Yes --> G[Immutable filesystem\nNo persistence possible] F -- No --> X3[Writable rootfs\nMalware can persist] G --> H[Hardened Runtime\nBlast radius contained] style X1 fill:#ff6b6b,color:#fff style X2 fill:#ff6b6b,color:#fff style X3 fill:#ff6b6b,color:#fff style H fill:#51cf66,color:#fff

6. Secrets Management: Keep Credentials Out of Image Layers

The three most common ways secrets end up in container images — all of them wrong:

Wrong #1: Environment variables in Dockerfile

# NEVER DO THIS
ENV DATABASE_URL="postgresql://user:password@prod-db:5432/app"
ENV API_KEY="sk-live-abc123..."

Environment variables are stored in the image manifest. They appear in docker inspect <container>, docker history <image>, and in Kubernetes pod specs visible to anyone with kubectl get pod -o yaml. Even if the container is stopped, the credentials persist in the image layer indefinitely.

Wrong #2: COPY or ADD credentials into the image

# ALSO NEVER DO THIS — even with a subsequent RUN rm
COPY .env /app/.env
RUN pip install -r requirements.txt
RUN rm /app/.env   # THIS DOES NOT HELP

Docker layers are content-addressed and immutable. RUN rm /app/.env creates a new layer that hides the file but does not delete it from the underlying layer. docker history --no-trunc and layer extraction tools will retrieve the credentials from the earlier layer. This has been exploited against real registries.

Right approach #1: Docker secrets (Swarm / BuildKit)

# syntax=docker/dockerfile:1.7

FROM python:3.12-slim AS builder

# Mount a secret during build — never written to any layer
RUN --mount=type=secret,id=pip_config \
    pip install \
    --index-url "$(cat /run/secrets/pip_config)" \
    --no-cache-dir \
    -r requirements.txt
# Pass secret at build time via BuildKit
docker buildx build \
  --secret id=pip_config,src=./private-pip.conf \
  .

The secret is available only during the RUN step as a tmpfs mount. It never appears in any image layer. docker history shows no trace of it.

Right approach #2: Kubernetes Secrets mounted as files

# Create the secret
kubectl create secret generic db-credentials \
  --from-literal=url='postgresql://user:pass@db:5432/app' \
  --from-literal=password='s3cr3t'

# Mount in pod spec
spec:
  containers:
    - name: api
      volumeMounts:
        - name: db-creds
          mountPath: /run/secrets/db
          readOnly: true
  volumes:
    - name: db-creds
      secret:
        secretName: db-credentials
        defaultMode: 0400   # owner read-only

Kubernetes mounts secrets as tmpfs — memory-only, not written to node disk. With defaultMode: 0400 and runAsUser: 10001 plus fsGroup: 10001, only the application user can read the files.

Right approach #3: HashiCorp Vault agent injection

For secrets rotation and audit logging, Vault agent injection is the production standard:

# Vault agent injects secrets as init container, writes to shared tmpfs
annotations:
  vault.hashicorp.com/agent-inject: "true"
  vault.hashicorp.com/agent-inject-secret-db-creds: "secret/data/myapp/db"
  vault.hashicorp.com/agent-inject-template-db-creds: |
    {{- with secret "secret/data/myapp/db" -}}
    DATABASE_URL=postgresql://{{ .Data.data.username }}:{{ .Data.data.password }}@db:5432/app
    {{- end }}
  vault.hashicorp.com/role: "myapp"

Vault injects an init container that authenticates via Kubernetes service account, fetches the secret, and writes it to a shared in-memory volume at /vault/secrets/. Your application reads it as a file. Vault agent sidecar handles rotation — when the secret expires, the file is rewritten without restarting your pod.

Right approach #4: AWS Secrets Manager via CSI driver

apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: aws-secrets
spec:
  provider: aws
  parameters:
    objects: |
      - objectName: "prod/myapp/db-credentials"
        objectType: "secretsmanager"
        jmesPath:
          - path: "password"
            objectAlias: "db-password"
          - path: "username"
            objectAlias: "db-username"

The CSI driver mounts AWS Secrets Manager values directly as files without ever storing them in a Kubernetes Secret object. This avoids etcd storage entirely.


7. Supply Chain Security: Sign, Verify, and Audit Everything

Supply chain attacks target the gap between "the code you wrote" and "the binary running in production." This gap includes every dependency, every base image, every build tool, and every CI step. Closing that gap requires cryptographic attestation at each stage.

Syft: generate SBOMs

A Software Bill of Materials (SBOM) is a machine-readable inventory of every package and library in your image. It enables downstream CVE scanning, license compliance checks, and incident response (when a new vulnerability is published, you can immediately query which of your images contain the affected package).

# Install syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# Generate SBOM in SPDX format
syft ghcr.io/myorg/api-server:latest -o spdx-json > sbom.spdx.json

# Generate in CycloneDX format (better tool support)
syft ghcr.io/myorg/api-server:latest -o cyclonedx-json > sbom.cdx.json

# Scan the SBOM for vulnerabilities (faster than scanning the image)
grype sbom:sbom.spdx.json --fail-on critical

# Attest the SBOM to the image (stored in registry alongside image)
cosign attest \
  --predicate sbom.spdx.json \
  --type spdxjson \
  ghcr.io/myorg/api-server@sha256:abc123...

Cosign and Sigstore: cryptographic image signing

# Full signing workflow in CI (keyless, using OIDC)
# 1. Build and push the image
docker buildx build --push \
  -t ghcr.io/myorg/api-server:v1.2.3 .

# 2. Get the digest of what was pushed
DIGEST=$(crane digest ghcr.io/myorg/api-server:v1.2.3)

# 3. Sign (records to Rekor transparency log)
cosign sign \
  ghcr.io/myorg/api-server@${DIGEST}

# 4. At deploy time, verify before running
cosign verify \
  --certificate-identity-regexp "^https://github.com/myorg/myrepo/.github/workflows/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/api-server@${DIGEST}

The Rekor transparency log (rekor.sigstore.dev) is a public, append-only, cryptographically verifiable ledger. Every signature is recorded with the signing identity, timestamp, and image digest. You can audit exactly which CI run signed which image.

OPA/Gatekeeper: enforce trusted base image policy

# OPA ConstraintTemplate: require signed images from approved registries
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: requiresignedimages
spec:
  crd:
    spec:
      names:
        kind: RequireSignedImages
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package requiresignedimages

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not startswith(container.image, "ghcr.io/myorg/")
          msg := sprintf("Image %v is not from the approved registry", [container.image])
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not regex.match(".*@sha256:[a-f0-9]{64}$", container.image)
          msg := sprintf("Image %v must be pinned to a digest, not a tag", [container.image])
        }

The digest-pinning rule is critical: image tags are mutable. myapp:latest can be silently overwritten by an attacker who gains registry access. Referencing by digest (@sha256:abc...) is immutable — the content is cryptographically bound to the identifier.


8. Production Container Hardening Checklist

A reference checklist for production deployments. Every item should be verifiable in CI or via admission controller policy.

Category Check Tool/Method
Build Multi-stage build: runtime image has no compiler/build tools docker history
Build No secrets in ENV or COPY — use BuildKit --mount=type=secret docker history --no-trunc
Build Base image pinned to digest, not tag Dockerfile inspection
Build .dockerignore excludes .git, .env, credentials, test data .dockerignore review
Image Distroless or Alpine base (not ubuntu/debian full) Image scan
Image Final image < 50MB (ideally < 15MB for Go/Rust) docker images
Image Trivy/Grype scan passes with no unpatched CRITICAL CVEs CI gate
Image SBOM generated and attested cosign attest
Image Image signed with cosign cosign verify
Image Tagged with git digest, not latest Registry policy
Runtime USER directive sets non-root UID in Dockerfile Dockerfile inspection
Runtime runAsNonRoot: true in Kubernetes securityContext OPA policy
Runtime readOnlyRootFilesystem: true OPA policy
Runtime allowPrivilegeEscalation: false OPA policy
Runtime capabilities: drop: [ALL] OPA policy
Runtime seccompProfile: RuntimeDefault or custom profile OPA policy
Runtime Resource limits set (CPU + memory) OPA policy
Runtime No hostPID, hostNetwork, hostIPC OPA policy
Secrets No secrets in environment variables kubectl get pod -o yaml audit
Secrets Secrets mounted as files via Kubernetes Secret or Vault Pod spec review
Secrets Secret volumes mounted with readOnly: true Pod spec review
Secrets Vault/CSI driver used for rotation-capable secrets Vault audit log
Network NetworkPolicy restricts ingress/egress to required paths kubectl get networkpolicy
Scanning Base images rescanned daily (not just at build time) Scheduled CI job
Audit Falco or similar runtime threat detection enabled Falco rules active

Conclusion

Container security is most effective when it is automated and enforced by policy — not when it depends on individual developers remembering to do the right thing. The patterns in this post compose into a layered defense: multi-stage builds eliminate build-time bloat, distroless images reduce the CVE surface to near zero, non-root enforcement removes the most common privilege escalation path, image scanning catches known vulnerabilities before they reach production, runtime security profiles contain the blast radius if something does get exploited, secrets management ensures credentials never appear in image layers or environment variables, and supply chain tooling provides cryptographic proof of what you're actually running.

None of these layers is sufficient alone. A perfectly hardened image is worthless if the running container has allowPrivilegeEscalation: true. A well-enforced runtime policy is undermined if secrets are stored in environment variables visible to docker inspect. The checklist in section 8 is a dependency graph as much as a checklist — each item strengthens the others.

Start with the high-impact items: multi-stage builds and distroless base images reduce your attack surface by the largest margin for the least engineering effort. Add non-root enforcement and readOnlyRootFilesystem next — both are single-line changes in a Dockerfile and Kubernetes spec. Then layer in CI scanning, secrets management, and supply chain attestation as your team's capacity allows. The goal is a container that is immutable, minimal, scannable, signed, and running as close to zero privilege as its workload requires.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-04 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Kubernetes Networking: Services, Ingress, Network Policies, and Service Mesh

Hero image

Introduction

Kubernetes networking is the layer that most engineers understand just enough to debug obvious failures — and not enough to prevent subtle ones. Pod-to-pod communication works by default, but "works by default" means all pods can reach all other pods with no access controls. A compromised pod in your frontend namespace can reach your database service. A misconfigured Ingress routes production traffic to a test deployment. A missing network policy allows a compromised dependency to exfiltrate data.

This post covers Kubernetes networking at production depth: how Services work at the IP table level, why ClusterIP vs NodePort vs LoadBalancer matters beyond YAML syntax, Ingress controllers and their performance characteristics, Network Policies as the foundation of zero-trust networking in Kubernetes, the CNI layer (what Cilium actually does), service mesh trade-offs, and the automation stack (ExternalDNS, cert-manager) that makes production cluster management manageable. Each section includes the debugging approach for when things go wrong — because in production, they always do eventually.

The Kubernetes Network Model

Kubernetes mandates three properties of its network model:
1. Every pod gets a unique IP address
2. Pods on the same node communicate without NAT
3. Pods on different nodes communicate without NAT

How this is implemented depends on the CNI (Container Network Interface) plugin: Flannel (simple VXLAN overlay), Calico (BGP + iptables), Cilium (eBPF), or Weave. The API is uniform; the implementation varies significantly in performance and features.

Pod IPs are ephemeral — they change when pods restart. Services provide stable virtual IPs (ClusterIPs) that route to healthy pod endpoints. The kube-proxy component (or its replacement) implements this routing.

Architecture diagram

Services: ClusterIP, NodePort, and LoadBalancer

# ClusterIP: stable virtual IP, only reachable within cluster
apiVersion: v1
kind: Service
metadata:
  name: payment-api
  namespace: payments
spec:
  type: ClusterIP
  selector:
    app: payment-api        # routes to pods with this label
  ports:
  - port: 80               # service port (what callers use)
    targetPort: 8080       # pod port (what your app listens on)
    protocol: TCP
---
# NodePort: exposes service on each node's IP at a static port
# Use for local development, not production (bypasses Ingress/LoadBalancer)
apiVersion: v1
kind: Service
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080        # 30000-32767 range; if omitted, auto-assigned
---
# LoadBalancer: provisions a cloud load balancer (NLB/ELB)
# Each LoadBalancer service costs money (one cloud LB per service)
# Use Ingress to multiplex many services behind one LoadBalancer
apiVersion: v1
kind: Service
spec:
  type: LoadBalancer
  ports:
  - port: 443
    targetPort: 8443

How ClusterIP routing works: kube-proxy (or eBPF in Cilium) watches the Endpoints object (list of healthy pod IPs) for each Service. It programs iptables rules that DNAT the ClusterIP:port to one of the healthy pod IPs using random or round-robin selection. When a pod fails its liveness probe and is removed from Endpoints, it's removed from the iptables rules within seconds. The Service IP is stable; the underlying pod IPs rotate.

The iptables implementation has an O(n) lookup time — at 10,000 services, iptables rules become a performance bottleneck. Cilium's eBPF implementation uses hash tables for O(1) lookup and is the reason large clusters migrate away from kube-proxy.

Ingress: HTTP Routing and TLS Termination

An Ingress resource defines HTTP routing rules: host-based and path-based routing to Services. One Ingress controller (typically one LoadBalancer service) multiplexes traffic to hundreds of backend services.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: production
  annotations:
    # nginx-specific annotations
    nginx.ingress.kubernetes.io/rate-limit: "100"
    nginx.ingress.kubernetes.io/rate-limit-window: "1m"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/use-regex: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - api.example.com
    secretName: api-tls-cert  # TLS cert stored as Secret
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /api/v1/payments(/|$)(.*)
        pathType: Prefix
        backend:
          service:
            name: payment-api
            port:
              number: 80
      - path: /api/v1/orders(/|$)(.*)
        pathType: Prefix
        backend:
          service:
            name: order-api
            port:
              number: 80
      - path: /
        pathType: Prefix
        backend:
          service:
            name: frontend
            port:
              number: 80

Ingress controller choices in 2026:
- nginx-ingress: most widely deployed, extensive annotation-based configuration, well-documented
- Traefik: native Let's Encrypt integration, middleware chain, better UI
- Kong: API gateway features (auth plugins, rate limiting, request transformation)
- Gateway API (standard): the successor to Ingress, more expressive, better multi-team support

The Gateway API (now stable as of Kubernetes 1.28+) separates cluster-level infrastructure (GatewayClass, Gateway) from application-level routing (HTTPRoute), enabling better multi-tenant control. The Ingress API remains supported but new features are being added to Gateway API.

# Gateway API: replacing Ingress
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: payment-route
spec:
  parentRefs:
  - name: prod-gateway           # references the Gateway (cluster-level)
  hostnames:
  - "api.example.com"
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /api/v1/payments
    backendRefs:
    - name: payment-api
      port: 80
      weight: 90               # 90% traffic to stable
    - name: payment-api-canary
      port: 80
      weight: 10               # 10% traffic to canary — traffic splitting

The Gateway API's traffic splitting capability enables canary deployments at the Ingress layer — weight-based routing without a service mesh.

Network Policies: Zero-Trust Kubernetes Networking

Without Network Policies, all pods in a cluster can reach all other pods. A compromised pod in your CDN service can connect to your database. Network Policies define which pods can talk to which pods.

# Default deny: no ingress or egress from the payments namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: payments
spec:
  podSelector: {}              # applies to all pods in the namespace
  policyTypes:
  - Ingress
  - Egress
---
# Allow: payments pods can receive traffic from API gateway only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-gateway-ingress
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payment-service
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          name: api-gateway     # only from api-gateway namespace
    - podSelector:
        matchLabels:
          app: api-gateway      # and only from api-gateway pods
    ports:
    - port: 8080
      protocol: TCP
---
# Allow: payments pods can reach the database and DNS only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: payment-service-egress
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payment-service
  policyTypes:
  - Egress
  egress:
  - to:
    - namespaceSelector:
        matchLabels:
          name: databases
    ports:
    - port: 5432               # PostgreSQL
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
    ports:
    - port: 53                 # DNS — don't forget this or pod DNS breaks
      protocol: UDP
    - port: 53
      protocol: TCP

The default-deny-all then explicit-allow pattern is the correct approach for production. Start locked down; add permissions as needed. The common mistake is writing podSelector: {} (allow-all) for both ingress and egress because it "just works" — then you have no network segmentation at all.

Network Policy enforcement requires a CNI that supports it. Flannel does not. Calico, Cilium, and Weave do. Verify your CNI supports Network Policies before applying them — on Flannel clusters, the policies are accepted by the API server but silently not enforced.

Comparison visual

EndpointSlices and Large-Scale Service Routing

The Endpoints object has a 1MB size limit — a single Service with more than ~5,000 pod IPs exceeds this limit and breaks. EndpointSlices (stable since Kubernetes 1.21) shard the endpoint data: each EndpointSlice holds up to 100 endpoints, and kube-proxy watches all EndpointSlices for a Service.

EndpointSlices also reduce kube-proxy CPU load: when a single pod is added to a Service, only the affected EndpointSlice is updated — not the entire endpoint list. At 1,000-pod Services, this reduces the update payload by ~99%.

Topology-aware routing (also called topology hints) assigns EndpointSlices to the same zone as the requesting pod. This reduces cross-zone data transfer costs (in cloud providers, cross-zone traffic is billed at ~$0.01/GB) and latency for services with geographically distributed nodes:

# Enable topology-aware routing on a Service
apiVersion: v1
kind: Service
metadata:
  name: recommendation-api
  annotations:
    service.kubernetes.io/topology-mode: "auto"  # prefer same-zone endpoints
spec:
  selector:
    app: recommendation-api
  ports:
  - port: 80
    targetPort: 8080

With topology-mode: auto, kube-proxy (or Cilium) preferentially routes requests to endpoints in the same availability zone. If no in-zone endpoints are available, it falls back to any endpoint. The annotation works best when endpoints are roughly evenly distributed across zones — severely imbalanced distributions disable the optimization automatically.

Cilium: eBPF-Based Networking

Cilium replaces kube-proxy and extends Network Policies with capabilities not possible in iptables:

# Cilium NetworkPolicy: L7-aware policies (iptables only does L3/L4)
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: payment-l7-policy
spec:
  endpointSelector:
    matchLabels:
      app: payment-service
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: api-gateway
    toPorts:
    - ports:
      - port: "8080"
        protocol: TCP
      rules:
        http:
        - method: "POST"
          path: "/api/v1/charge"   # only allow this specific endpoint
        - method: "GET"
          path: "/api/v1/status"   # and this one
        # All other paths blocked at the network layer

L7-aware policies block specific HTTP paths or gRPC methods at the network layer — not the application layer. A compromised internal service cannot call DELETE /api/v1/all_orders even if it can reach the payment service's pod IP.

Cilium's eBPF implementation also provides:
- Network performance: O(1) service lookup vs O(n) iptables, measurable improvement at >1000 services
- Hubble observability: real-time flow visibility, service dependency maps, network policy verification
- Transparent encryption: WireGuard-based pod-to-pod encryption without application changes
- Bandwidth management: per-pod egress bandwidth limits

The operational trade-off: Cilium requires a more recent Linux kernel (5.4+ for most features, 5.10+ for advanced features) and a more complex installation than vanilla kube-proxy. In 2026, most managed Kubernetes offerings (EKS, GKE, AKS) support or default to Cilium.

ExternalDNS: Automating DNS Record Management

Manually updating DNS records when LoadBalancer IPs change is error-prone. ExternalDNS watches Services and Ingresses and automatically updates Route 53, Cloud DNS, or Cloudflare when load balancer IPs are assigned:

# ExternalDNS deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
spec:
  template:
    spec:
      containers:
      - name: external-dns
        image: registry.k8s.io/external-dns/external-dns:v0.14.0
        args:
        - --source=service
        - --source=ingress
        - --domain-filter=example.com      # only manage example.com records
        - --provider=aws                   # Route 53
        - --policy=upsert-only             # never delete records (safer)
        - --aws-zone-type=public
        - --log-level=info

When you create an Ingress with host: api.example.com, ExternalDNS automatically creates a Route 53 A record pointing to the Ingress controller's LoadBalancer IP. When the LoadBalancer IP changes (during cluster migration, for example), ExternalDNS updates the record automatically.

Combined with cert-manager (automatic TLS certificate provisioning from Let's Encrypt), you get fully automated HTTPS endpoint management:

# cert-manager ClusterIssuer for Let's Encrypt
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
    - http01:
        ingress:
          class: nginx

# Ingress with cert-manager annotation: auto-provisions certificate
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod  # triggers cert provisioning
spec:
  tls:
  - hosts:
    - api.example.com
    secretName: api-example-com-tls   # cert-manager stores cert here
  rules:
  - host: api.example.com
    ...

The combination of ExternalDNS + cert-manager + nginx-ingress is the standard production Kubernetes HTTP stack: deploy an application, annotate the Ingress, and within 2 minutes it's live with a valid HTTPS certificate and DNS record — fully automated.

Service Mesh: When You Need It

A service mesh (Istio, Linkerd, Cilium Service Mesh) adds a sidecar proxy (or eBPF) to each pod that handles:
- Mutual TLS (mTLS) between all services
- Traffic management (canary deployments, circuit breakers, retries)
- Observability (distributed traces, metrics, per-service dashboards)

The value is real. The cost is also real: Istio adds 7-15MB memory per sidecar, 1-3ms latency per hop, and significant operational complexity (CRDs, control plane management, certificate rotation).

Use a service mesh when:
- You require mTLS for compliance (SOC 2, PCI DSS) and can't add TLS to each service individually
- You need circuit breaking, retries, and timeout policies applied consistently across all services
- You want distributed tracing without application-level instrumentation (OpenTelemetry auto-instrumentation is usually sufficient)
- You have 20+ services that need consistent traffic management

Don't use a service mesh when:
- You have fewer than 10 services (overhead exceeds benefit)
- Your team doesn't have operational experience with the service mesh control plane
- You can achieve the same goals with Cilium Network Policies + OpenTelemetry + Gateway API traffic splitting

Linkerd has a much simpler operational model than Istio (no complex VirtualService/DestinationRule CRDs, lower resource overhead, automatic mTLS with near-zero config). If mTLS compliance is the primary driver, Linkerd is the better choice. If advanced traffic management is needed, Istio's capabilities justify its complexity.

Note that Cilium Service Mesh (available when Cilium is your CNI) provides mTLS and basic traffic management via eBPF without sidecars — zero sidecar overhead. For clusters already running Cilium, this is worth evaluating before adopting Istio or Linkerd, since the infrastructure is already in place.

Debugging Kubernetes Network Issues

A systematic approach to the most common networking failures:

Pod can't reach another pod by Service name:

# Step 1: Verify DNS resolves
kubectl exec -it debug-pod -- nslookup payment-api.payments.svc.cluster.local
# If fails: CoreDNS problem or pod DNS config issue

# Step 2: Verify Service has endpoints
kubectl get endpoints payment-api -n payments
# If ADDRESS column is empty: selector doesn't match pod labels

# Step 3: Test direct pod-to-pod connectivity (bypass Service)
kubectl get pods -n payments -o wide          # get pod IP
kubectl exec -it debug-pod -- curl http://10.244.1.5:8080/health
# If succeeds but Service fails: iptables/kube-proxy issue

# Step 4: Check Network Policy isn't blocking
kubectl exec -n payments debug-pod -- curl http://payment-api:80
# If denied: check NetworkPolicy objects in both namespaces

# Cilium policy troubleshooting with Hubble
hubble observe --namespace payments --follow
# Shows dropped packets with reason: network policy rule, etc.

Ingress not routing correctly:

# Check Ingress controller logs
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller | grep payment-api

# Verify Ingress backend resolves
kubectl describe ingress api-ingress -n production
# Look for: "Default backend: default-http-backend:80 (<error>)"

# Check that the backend Service and its pods are healthy
kubectl get svc payment-api -n payments
kubectl get endpoints payment-api -n payments  # must have addresses

# Test with curl from inside the cluster (bypasses Ingress, tests Service)
kubectl run test --image=curlimages/curl --rm -it -- \
    curl http://payment-api.payments.svc.cluster.local/health

Connection timeout to external services:

# Check egress Network Policy allows the external IP/port
kubectl get networkpolicy -n payments

# Test DNS resolution of external service
kubectl exec -it pod -- nslookup api.stripe.com

# Check if traffic is being NATted correctly
kubectl exec -it pod -- curl -v https://api.stripe.com/v1/balance \
    -H "Authorization: Bearer sk_test_..."

Intermittent connection failures under load:
This is often a conntrack table overflow (the kernel's connection tracking table is full). Symptoms: nf_conntrack: table full, dropping packet in node kernel logs. Mitigation:

# On each node
sysctl net.netfilter.nf_conntrack_max
# Increase if close to limit under load
# Also check: sysctl net.netfilter.nf_conntrack_count

With Cilium (eBPF), conntrack is handled in eBPF maps with much higher limits than the kernel conntrack table — this is one of the scaling advantages.

DNS in Kubernetes: CoreDNS and Service Discovery

CoreDNS is the default DNS server in Kubernetes. Services are resolvable via DNS within the cluster:

# DNS name format: <service>.<namespace>.svc.cluster.local
# Short forms also work within the same namespace:
# - <service>                            (same namespace)
# - <service>.<namespace>                (any namespace)
# - <service>.<namespace>.svc            (any namespace)
# - <service>.<namespace>.svc.cluster.local (full FQDN)

# From within payments namespace:
# payment-api              → resolves to ClusterIP
# payment-api.payments     → same
# postgres.databases       → cross-namespace

# Headless services (ClusterIP: None): DNS returns pod IPs
# Used for StatefulSets where you need to reach specific pods

CoreDNS performance issues at scale: with many pods making many DNS lookups, CoreDNS can become a bottleneck. Mitigations:
- ndots:5 default causes each DNS lookup to try 5 search domain suffixes before returning. Set ndots:2 for services that use FQDNs
- NodeLocal DNSCache: DNS cache on each node, reduces CoreDNS load by ~70%
- Increase CoreDNS replicas: HPA on CoreDNS based on requests/second

Production Kubernetes Networking Stack: Recommendations by Scale

Small clusters (1-20 nodes, <100 services):
- CNI: Flannel or Calico (simple, well-understood)
- Service routing: kube-proxy (iptables overhead not visible at this scale)
- Ingress: nginx-ingress (most documentation, easiest debugging)
- Network Policies: Calico provides enforcement even with Flannel overlay
- Service mesh: skip (overhead exceeds benefit)
- DNS automation: ExternalDNS + cert-manager

Medium clusters (20-200 nodes, 100-1000 services):
- CNI: Cilium (eBPF performance advantage becomes measurable, Hubble for visibility)
- Service routing: Cilium replaces kube-proxy
- Ingress: nginx-ingress or Gateway API
- Network Policies: Cilium NetworkPolicy (L7-aware)
- Service mesh: Linkerd if mTLS compliance required; skip otherwise
- Topology-aware routing for cross-zone cost reduction

Large clusters (200+ nodes, 1000+ services):
- CNI: Cilium (iptables simply doesn't scale here)
- EndpointSlices: enabled and monitored
- Ingress: Gateway API (mature at this cluster size)
- Service mesh: likely required (traffic management at this scale benefits from centralized control)
- NodeLocal DNSCache: required (CoreDNS becomes bottleneck without it)

Conclusion

Kubernetes networking has multiple layers, each with distinct concerns. Services provide stable virtual IPs for ephemeral pods. Ingress (and increasingly, Gateway API) multiplexes HTTP traffic with host and path routing. Network Policies provide the access controls that make multi-tenant clusters safe. Cilium's eBPF implementation delivers L7-aware policies and O(1) service lookup at scale. Service meshes add mTLS and advanced traffic management — valuable for large deployments, over-engineered for small ones.

The security posture that production clusters should target: default-deny Network Policies per namespace, explicit-allow ingress and egress for every service, CoreDNS always permitted. Combined with Cilium for enforcement and Hubble for visibility, you have network segmentation comparable to a traditional firewall — but dynamic and programmable through Kubernetes manifests.

The automation stack of ExternalDNS plus cert-manager plus an Ingress controller eliminates manual DNS and certificate management — the operational overhead that previously made Kubernetes networking painful to manage. In 2026, a new service can be deployed, DNS-assigned, and HTTPS-terminated in minutes with no manual intervention. When things go wrong, the debugging workflow follows the layers: DNS resolution, Service endpoints, Network Policy rules, Ingress configuration — in that order. Cilium's Hubble CLI makes network policy debugging particularly tractable, showing dropped packets with the exact policy rule that blocked them.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...