Segurança de Containers em Docker e Kubernetes: hardening, isolamento e automação prática

A superfície de ataque de containers não termina no docker run. Ela começa no build da imagem, atravessa o runtime, entra no scheduler do kubernetes e se espalha por permissões de kernel, rede, secret management e supply chain. Em ambientes linux e devops, tratar containers como “mini-VMs” é um erro clássico: o modelo correto é assumir compartilhamento de kernel, reduzir privilégios ao mínimo e automatizar controles em cada camada do pipeline.

Uma estratégia séria de segurança de containers combina quatro frentes: imagens mínimas e verificadas, runtime com isolamento estrito, políticas de admissão e rede no cluster, e observabilidade para detectar desvio de comportamento. Abaixo, o foco vai ser operacional: comandos, manifests, hardening e decisões que funcionam em produção.

O ponto de partida: ameaça real, não teoria

Container não elimina risco; ele redistribui risco. O kernel do host continua sendo a barreira principal, então qualquer falha de configuração que aumente privilégio dentro do container vira potencial de escape lateral, persistência ou movimentação entre namespaces. Entre os vetores mais comuns estão:

  • imagens com pacotes desnecessários e binários de administração;
  • processos rodando como root sem necessidade;
  • --privileged, CAP_SYS_ADMIN e volumes sensíveis expostos;
  • secretos embutidos na imagem ou em variáveis de ambiente vazadas;
  • ausência de assinatura e verificação de integridade de imagens;
  • workloads Kubernetes sem securityContext e sem políticas de admissão.

O erro mais caro é acreditar que a segurança acontece só no cluster. Na prática, a cadeia completa precisa ser controlada: Dockerfile, registry, scanner, CI/CD, manifestos, políticas e runtime no host Linux.

Imagens pequenas, previsíveis e sem lixo operacional

O build da imagem define a maior parte da exposição. Quanto menos componentes, menos superfície de ataque e menos dependências para manter. Isso não significa escolher a menor base possível de forma cega; significa construir imagens previsíveis, com pacotes mínimos e sem ferramentas de debug embarcadas.

Dockerfile orientado a hardening

# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM alpine:3.20
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /out/app /app/app
USER app:app
EXPOSE 8080
ENTRYPOINT ["/app/app"]

Esse Dockerfile usa multi-stage build, elimina toolchain no runtime e executa sem root. Algumas melhorias adicionais fazem diferença em ambientes mais rígidos:

  • usar readOnlyRootFilesystem no Kubernetes;
  • montar /tmp como emptyDir com medium: Memory se a aplicação exigir escrita temporária;
  • remover shells se a aplicação não precisa deles;
  • fixar versões por digest, não por tag mutável.

Tag como alpine:3.20 ainda permite drift se o conteúdo da tag mudar. Em produção, a referência robusta é por digest:

docker pull alpine:3.20
docker image inspect alpine:3.20 --format '{{index .RepoDigests 0}}'

Depois, use o digest no pipeline ou no manifesto do Kubernetes. Isso congela a origem exata do artefato e evita surpresa silenciosa em deploys automatizados.

Execução sem root e redução de capacidades Linux

O maior salto de segurança em containers é parar de rodar como root. Root dentro do container não é igual a root no host, mas continua perigoso o suficiente para facilitar abuso de binários, escrita em volumes e exploração de falhas no runtime ou no kernel.

Docker run com política mínima

docker run 
  --name app 
  --read-only 
  --cap-drop ALL 
  --cap-add NET_BIND_SERVICE 
  --security-opt no-new-privileges 
  --pids-limit 100 
  --memory 256m 
  --cpus 0.5 
  -p 8080:8080 
  myapp@sha256:abcdef123456...

Esse conjunto reduz a chance de escalada lateral. --read-only bloqueia persistência no filesystem do container. --cap-drop ALL remove capabilities herdadas do kernel. no-new-privileges impede ganho de privilégio via SUID/SGID e execuções subsequentes. pids-limit reduz risco de fork bomb. Limites de memória e CPU evitam ruído operacional e ajudam a mitigar abuso por carga maliciosa.

Se o serviço escuta em porta alta, nem NET_BIND_SERVICE precisa existir. Em cluster, essa decisão precisa refletir no securityContext e na porta exposta pelo Service, não em gambiarras no entrypoint.

Kubernetes: securityContext de verdade

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: app
          image: myapp@sha256:abcdef123456...
          ports:
            - containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            medium: Memory

Esse manifesto já corta uma lista grande de riscos. runAsNonRoot impede execução como UID 0. allowPrivilegeEscalation: false bloqueia escalada via setuid. RuntimeDefault aplica um perfil seccomp padrão do runtime, o que corta syscalls desnecessárias. O uso de emptyDir para /tmp dá ao aplicativo um local de escrita controlado sem abrir o root filesystem.

Segredos não pertencem à imagem nem ao Git

Senha em variável de ambiente parece prática até o primeiro kubectl describe pod, dump de processo ou log de debug. O tratamento certo de segredos passa por separação clara entre artefato e configuração sensível.

Docker e Compose: não embutir credenciais

services:
  app:
    image: myapp@sha256:abcdef123456...
    env_file:
      - .env
    secrets:
      - db_password
secrets:
  db_password:
    file: ./secrets/db_password.txt

Mesmo em ambientes locais, secret no Compose é melhor do que variável solta em arquivo de ambiente commiteado por acidente. Em produção, a decisão correta é usar um mecanismo externo: Kubernetes Secret, Vault, SOPS, External Secrets Operator ou integração nativa com KMS, dependendo da maturidade do ambiente.

Kubernetes Secret com montagem como arquivo

apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
stringData:
  username: appuser
  password: supersecreto
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  template:
    spec:
      containers:
        - name: app
          image: myapp@sha256:abcdef123456...
          volumeMounts:
            - name: db-credentials
              mountPath: /run/secrets/db
              readOnly: true
      volumes:
        - name: db-credentials
          secret:
            secretName: db-credentials
            defaultMode: 0400

Montar como arquivo reduz exposição acidental em logs e ajuda o aplicativo a ler credenciais sob demanda. Para aplicações que insistem em variáveis, o ideal é adaptar o código. Se isso não for possível, limite a leitura da variável ao processo principal e evite exportar o valor em shell scripts intermediários.

Assinatura, provenance e scanner no pipeline

Securing containers não se fecha sem supply chain. A imagem precisa ser verificada antes de entrar em produção. Três controles importam: análise de vulnerabilidades, assinatura criptográfica e rastreabilidade da origem do build.

Scanner de imagem no CI

trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:build

O scanner precisa bloquear o pipeline quando encontrar vulnerabilidades críticas exploráveis no contexto da aplicação. Um relatório bonito sem gate de falha não muda a postura de risco. O ajuste fino depende do ambiente, mas o princípio é direto: falha de build quando o risco ultrapassa o limite definido pela equipe.

Assinatura com cosign

cosign generate-key-pair
cosign sign --key cosign.key myapp@sha256:abcdef123456...
cosign verify --key cosign.pub myapp@sha256:abcdef123456...

Assinar a imagem protege contra adulteração no registry e garante que o deploy só aceite artefatos produzidos pela pipeline autorizada. O passo seguinte é impor verificação no cluster. Em Kubernetes, isso geralmente entra via policy controller ou admission webhook. Sem enforcement, assinatura vira documentação, não controle.

Provenance também importa. Registros modernos podem armazenar metadados de build, mas o essencial é preservar ligação entre commit, build, imagem e assinatura. Em pipelines baseadas em Git, gere um artefato que registre:

  • SHA do commit;
  • hash da imagem;
  • versão da ferramenta de build;
  • dependências resolvidas;
  • resultado do scanner.

Exemplo de pipeline em GitLab CI

stages:
  - test
  - build
  - scan
  - sign

docker_build:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_TLS_CERTDIR: ""
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

image_scan:
  stage: scan
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

image_sign:
  stage: sign
  image: cgr.dev/chainguard/cosign:latest
  script:
    - cosign sign --key env://COSIGN_KEY $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

O pipeline acima ainda precisa de credenciais protegidas e de um registry confiável. Não adianta assinar em um estágio e publicar a chave privada em variável sem controle. Em cenários mais rígidos, use KMS, HSM ou identidade federada para assinatura sem chave persistente em disco.

Namespace, seccomp, AppArmor e SELinux: a camada Linux que realmente segura a barra

Em Linux, a segurança do container depende do conjunto de namespaces, cgroups, LSMs e syscalls permitidas. Namespace isola visão; LSM impede ações específicas; cgroup controla consumo. Deixar essa base desligada é o mesmo que abrir mão do que o kernel oferece de melhor.

Seccomp como filtro de syscalls

Muitos workloads não precisam de dezenas de syscalls. Um perfil seccomp personalizado reduz a superfície de exploração. Exemplo simplificado:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "archMap": [
    { "architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"] }
  ],
  "syscalls": [
    { "names": ["read", "write", "exit", "futex", "clock_gettime", "epoll_wait", "openat", "close"], "action": "SCMP_ACT_ALLOW" }
  ]
}

Esse arquivo precisa ser calibrado com base na aplicação real. O processo correto é instrumentar a carga, observar syscalls e depois restringir. Usar o perfil padrão do runtime já melhora muito, mas perfis customizados fecham portas adicionais.

AppArmor e SELinux em hosts Linux

Em distribuições com AppArmor, perfis por aplicação ajudam a restringir acesso a arquivos e capacidades. Em ambientes com SELinux, o contexto correto evita que um container tenha acesso indevido a arquivos do host ou volumes montados. A equipe que administra o cluster deve padronizar isso na camada do nó, não como ajuste manual por pod.

Com Docker, o uso de AppArmor aparece em flags ou perfis do daemon. No Kubernetes, a adoção depende da versão e da configuração do runtime. O ponto operacional é o mesmo: o pod precisa herdar um contexto restritivo por padrão, e a exceção deve ser formalizada por política, nunca por improviso.

Rede: segmentação explícita entre workloads

Em clusters Kubernetes, rede plana é convite a movimentação lateral. Se qualquer pod alcança qualquer serviço, a contensão de incidente fica fraca. A resposta prática é aplicar NetworkPolicies com default deny e abrir apenas o tráfego necessário.

Default deny de entrada e saída

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: app
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Com isso, nada entra e nada sai até regras explícitas serem adicionadas. Depois, libere apenas o que o serviço precisa:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-app-to-db
  namespace: app
spec:
  podSelector:
    matchLabels:
      app: app
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              name: data
          podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432

Esse tipo de segmentação reduz impacto de comprometimento. Se um pod cair, ele não conversa com o cluster inteiro. Complementando isso, serviços sensíveis devem ficar em namespaces separados, com RBAC e políticas próprias. Segurança boa em Kubernetes nasce de isolamento por domínio funcional, não apenas por times.

RBAC sem permissões excessivas e sem atalhos

Outro erro recorrente é permitir que workloads consultem o API server sem necessidade. ServiceAccount padrão em muitos clusters vem com mais acesso do que deveria. A regra operacional é simples: se o pod não consulta o Kubernetes API, desabilite o uso do token e remova qualquer permissão implícita.

Desabilitar montagem automática de token

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: app
automountServiceAccountToken: false

No pod ou deployment, vincule essa conta apenas se houver motivo claro. Se o aplicativo realmente precisar falar com a API, crie Role e RoleBinding minimais. Nunca use ClusterRole amplo por conveniência. A prática de “dar admin para resolver e depois ajustar” costuma sobreviver em produção por meses.

Políticas de admissão: impedir configuração insegura antes de chegar ao runtime

Após consolidar boas práticas no manifesto, o próximo passo é impedir que elas sejam quebradas por engano. Isso entra na camada de admissão. Em clusters atuais, ferramentas como Kyverno ou OPA Gatekeeper traduzem requisitos de segurança em política auditável e versionada como código.

Exemplo de política Kyverno para bloquear privileged

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-privileged
spec:
  validationFailureAction: Enforce
  rules:
    - name: privileged-containers
      match:
        resources:
          kinds:
            - Pod
      validate:
        message: "Containers privilegiados são proibidos"
        pattern:
          spec:
            containers:
              - securityContext:
                  =(privileged): false

Uma política assim elimina classes inteiras de erro humano. O mesmo padrão vale para bloquear hostNetwork, hostPID, hostIPC, volumes hostPath e imagens sem digest. Sem enforcement, todo time acaba produzindo sua própria exceção.

Host Linux endurecido: o container herda o que o nó permite

Um nó Kubernetes mal configurado invalida boa parte da disciplina no nível do pod. O host precisa ser tratado como infraestrutura crítica. Isso inclui atualização frequente do kernel, runtime compatível, política de reboot controlada, firewall, logging e redução de serviços expostos.

Checagens rápidas no host

uname -r
systemctl status kubelet
docker info 2>/dev/null | grep -i security
sysctl net.ipv4.ip_forward
sysctl kernel.unprivileged_userns_clone

Em hosts Linux, ajuste sysctls com intenção clara. Não habilite features por padrão sem entender o efeito no isolamento. Revise acesso SSH, autenticação por chave, rotação de credenciais e grupo sudo. O nó é parte da superfície do cluster, não uma caixa preta “administrada pela plataforma”.

Se o ambiente roda em bare metal ou em VMs administradas por equipe própria, vale integrar hardening via Ansible ou outra ferramenta de IaC. Exemplo de tarefa simples para restringir sysctl:

- name: Harden kernel parameters
  hosts: k8s_nodes
  become: true
  tasks:
    - name: Set restrictive sysctl
      ansible.posix.sysctl:
        name: kernel.kptr_restrict
        value: "2"
        state: present
        reload: true

Observabilidade e resposta: detectar container fora do padrão

Segurança sem monitoramento vira controle estático. Quando um container começa a abrir conexões inesperadas, gerar processos filhos demais, escrever onde não deveria ou tocar arquivos sensíveis, o sinal precisa aparecer. Ferramentas de runtime security e logs estruturados ajudam a fechar esse ciclo.

O que observar no runtime

  • processos executados fora da árvore esperada;
  • syscalls anormais bloqueadas por seccomp;
  • conexões de saída para destinos não previstos;
  • tentativas de escrita em filesystem read-only;
  • uso excessivo de CPU ou criação massiva de processos;
  • erros de permissão em volumes montados.

Em Kubernetes, vale integrar logs do kubelet, eventos do cluster e métricas do runtime em um stack de observabilidade. Alertas úteis são aqueles com contexto: namespace, pod, image digest, node, serviceAccount e alteração recente no deployment. Sem isso, a investigação vira caça ao tesouro.

Checklist operacional para aplicar já no próximo rollout

Uma política de segurança eficiente funciona quando se torna parte do fluxo de entrega, não quando depende de memória individual. Antes de promover um workload, valide este conjunto mínimo:

  • imagem construída com multi-stage e sem toolchain no runtime;
  • execução sem root;
  • readOnlyRootFilesystem habilitado;
  • capabilities reduzidas a zero ou ao estritamente necessário;
  • allowPrivilegeEscalation: false;
  • seccomp padrão ou perfil customizado;
  • secret montado como arquivo, não exposto em Git;
  • imagem assinada e verificada;
  • scan de vulnerabilidades com gate no CI;
  • NetworkPolicy com default deny;
  • RBAC mínimo e automountServiceAccountToken: false quando possível;
  • limites de CPU, memória e pids;
  • policy de admissão bloqueando privilégios perigosos.

Quando isso entra como padrão, o ganho é imediato: menos ruído operacional, menos superfície de ataque e menos chance de deploy inseguro passar despercebido. Em Docker e Kubernetes, segurança boa não é um pacote adicional; é uma propriedade da pipeline inteira, do host Linux ao manifesto final. Quem automatiza esses controles enxerga o cluster como um sistema controlado. Quem deixa para revisar depois normalmente aprende em incidente.