Chuyển đến nội dung chính

Container Image Hardening: Distroless, Multi-Stage and Cosign Signing

Duy Tran9 min
Container Image Hardening: Distroless, Multi-Stage and Cosign Signing
Every extra byte in a runtime image is an extra byte of attack surface. An ideal production image contains only the application binary, a few shared libraries, no shell, no package manager — and must be signed so the cluster can trust it.

Multi-stage builds and distroless base images

Reference pattern for a Go application:

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/app /app USER nonroot:nonroot ENTRYPOINT ["/app"]

Benefits:

  • Runtime image <20 MB, no sh, apt, curl.
  • Once an attacker reaches RCE, there are no tools to escalate or download a payload.
  • OS-level CVE count drops to near zero, reducing scan noise.

For Node.js/Python: use Chainguard Images, distroless/nodejs, distroless/python3. Avoid :latest; always pin by digest @sha256:....

Non-root, read-only filesystem, dropped capabilities

In Kubernetes:

securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
  seccompProfile:
    type: RuntimeDefault

This is the baseline of the Pod Security Standards: restricted profile. If the app needs to write files (cache, tmp), mount an emptyDir at that path instead of opening the rootfs.

Scan images with Trivy/Grype

- name: Build
  run: docker build -t ghcr.io/org/app:${{ github.sha }} .
  • name: Scan image uses: aquasecurity/trivy-action@master with: image-ref: ghcr.io/org/app:${{ github.sha }} severity: CRITICAL,HIGH exit-code: 1 ignore-unfixed: true

ignore-unfixed: true avoids breaking builds for CVEs without a fix yet — but they still appear in the report for tracking.

Cosign keyless: signing without managing private keys

Cosign keyless relies on an OIDC identity (GitHub Actions, Google, ...) and a short-lived certificate issued by Fulcio. Workflow:

permissions:
  id-token: write   # for OIDC
  contents: read
  packages: write
  • uses: sigstore/cosign-installer@v3

  • name: Sign image env: COSIGN_EXPERIMENTAL: "true" run: cosign sign --yes ghcr.io/org/app@${{ steps.push.outputs.digest }}

  • name: Attach SBOM as attestation run: | syft ghcr.io/org/app@${{ steps.push.outputs.digest }} -o cyclonedx-json > sbom.json cosign attest --yes --predicate sbom.json --type cyclonedx
    ghcr.io/org/app@${{ steps.push.outputs.digest }}

Verification entries are stored permanently on the Rekor transparency log. There is no private key to store or rotate.

Verify before deploy with Kyverno

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-cosign-signature
    match:
      any:
      - resources:
          kinds: ["Pod"]
    verifyImages:
    - imageReferences: ["ghcr.io/org/*"]
      attestors:
      - entries:
        - keyless:
            subject: "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"
            issuer: "https://token.actions.githubusercontent.com"

Pods are admitted only if the image carries a signature from the right repo + workflow + branch. This is a key building block of supply chain security (SLSA L2-L3).

Production image checklist

  • Multi-stage build, distroless/Chainguard base, pinned by digest.
  • Non-root USER, drop ALL capabilities, readOnlyRootFilesystem.
  • No curl, wget, bash in the runtime stage.
  • Scanned by Trivy/Grype, build fails on CRITICAL/HIGH with available fix.
  • Signed keyless with Cosign + SBOM CycloneDX attached.
  • Cluster has Kyverno verifyImages blocking unsigned images.
  • Clear HEALTHCHECK + EXPOSE; no unused ports exposed.

Conclusion

Image hardening is one of the highest-ROI investments in DevSecOps: small effort, big reduction in both attack surface and alert fatigue. Once the distroless + non-root + Cosign keyless pattern is internalised, every new service inherits a strong baseline almost for free — and you can confidently turn on admission policies that block non-compliant pods cluster-wide.