>_ josetilabs.com
← blog

15 out 2026 · segurança, linux, containers · 5 min

Como rodar um contêiner sem root de verdade

Um contêiner sem user roda com 14 capacidades do kernel e escreve em /etc sem reclamar. Trocando só o user para 1000, as capacidades efetivas vão a zero e a escrita em /etc passa a falhar. É o teste que cabe numa tela de terminal, e é o que o compose.yaml desta postagem monta.

O que cada trava resolve

user troca o dono do processo. cap_drop: ALL tira as capacidades que sobrariam. read_only transforma a imagem em sistema de arquivos que não aceita escrita. no-new-privileges impede que nada dentro do processo vire root durante a execução.

O kernel explica a parte mais importante: root é um usuário comum com um pacote extra de capacidades anexado. Capabilities são exatamente esse pacote. Tirar as capacidades é tirar o root.

O guia de segurança do Docker já vem dizendo isso desde sempre: por padrão o motor mantém uma lista curta de capacidades e solta o resto. O endurecimento a seguir não inventa nada, ele fecha a lista até o fim.

Terminal com quatro colunas do mesmo script de sonda: sem trava o processo roda como root com 14 capacidades e escreve em /etc; com user 1000 as capacidades caem para 0 e a escrita em /etc falha; com cap_drop ALL e read_only o NoNewPrivs vira 1; no compose com volume corrigido o processo uid 10001 escreve em /dados
Quatro execuções do mesmo script. Só muda quem roda e o que o container tem de permitido.

O que o leitor precisa ver aí: as capacidades caem de 14 para 0 já na segunda coluna, sem depender do cap_drop. E a última coluna mostra o detalhe que mais derruba serviço em produção, o volume.

O artefato

O compose.yaml abaixo é o arquivo inteiro, comentado linha por linha. Ele precisa de um Dockerfile de quatro linhas ao lado, que cria o usuário sem privilégio e o diretório /dados já com o dono certo.

yaml
services:
  app:
    build: .
    image: exemplo-endurecido:1

    # 1. uid e gid do processo. A imagem precisa ter esse usuario.
    user: "10001:10001"

    # 2. nenhuma capacidade do kernel sobrando.
    cap_drop:
      - ALL

    # 3. sistema de arquivos de leitura: /etc, /usr e o resto fecham.
    read_only: true

    # 4. ninguem vira root durante a execucao.
    security_opt:
      - no-new-privileges:true

    # escrita que pode existir, guardada em memoria e morta com o container
    tmpfs:
      - /tmp:rw,size=16m,mode=1777
      - /run:rw,size=4m

    # volume nomeado. O dono do volume tem de bater com o user acima.
    volumes:
      - dados:/dados

    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  dados:

E o Dockerfile que ele espera:

dockerfile
FROM debian:stable-slim

# usuario sem privilegio e diretorio de dados com o dono certo
RUN groupadd -g 10001 app \
 && useradd -u 10001 -g 10001 -M -s /usr/sbin/nologin app \
 && mkdir -p /dados && chown 10001:10001 /dados

USER 10001:10001
WORKDIR /app
CMD ["sleep", "infinity"]

Repare no chown da terceira linha. Ele resolve a quebra mais comum, e ela não tem nada a ver com segurança.

Sobre /dados valem as mesmas regras de qualquer volume: ele só vale alguma coisa se a cópia for testada. O backup com rsync restaurável mostra o teste que leva poucos minutos.

Como verificar

O verifica-contexto.sh abaixo é o script que roda lá dentro. Ele não instala nada e não escreve fora das pastas testadas. Ele é também o que produz a figura.

bash
#!/bin/sh
# verifica-contexto.sh: mostra quem o processo e, dentro do container.
# Copie para dentro do container e rode com: sh verifica-contexto.sh
# Nao instala nada e nao escreve nada fora das pastas testadas.

id
grep -E '^(CapEff|NoNewPrivs)' /proc/self/status

# conta os bits ligada em CapEff: root normal vem com 14
c=$(awk '/^CapEff/{print $2}' /proc/self/status)
n=0
for i in $(seq 0 63); do
  n=$((n + ((0x$c >> i) & 1)))
done
echo "caps efetivos: $n"

# escrita em cada pasta: a imagem aceita ou esta fechada?
for dir in /etc /var/lib /tmp /dados; do
  [ -d "$dir" ] || continue
  if touch "$dir/sonda.txt" 2>/dev/null; then
    echo "escrita em $dir: deu certo"
  else
    echo "escrita em $dir: falhou"
  fi
done

Suba o serviço e pergunte ao processo quem ele é:

bash
docker compose up -d
docker compose exec app sh /app/verifica-contexto.sh

A saída esperada é o uid 10001, CapEff: 0000000000000000, NoNewPrivs: 1, falha em /etc e em /var/lib, e sucesso em /tmp e /dados. Se aparecer qualquer bit diferente de zero em CapEff, alguma trava não chegou ao container.

Para ver o antes, basta tirar uma linha do compose. Sem user, o mesmo script volta com uid 0, 14 capacidades e a escrita em /etc dada como certa.

Agora a parte que assusta: monte o mesmo volume sem --user e o processo root escreve em /dados sem uma palavra. Volte a rodar como 1000 e o mesmo volume dá Permission denied, porque o diretório continua pertencendo ao root. O volume nomeado não sabe qual uid o serviço vai usar.

Se der errado

O apt-get update morre com E: List directory /var/lib/apt/lists/partial is missing. Acquire (13: Permission denied). Causa: o read_only fechou o caminho que o apt usa. Correção: pacote se instala no Dockerfile, nunca no container em execução.

O processo reinicia sem dizer por quê. Causa: tmpfs sem espaço, ou volume sem permissão. Correção: aumentar size do tmpfs e conferir o dono com ls -ld /dados.

A imagem não sobe com no-new-privileges. Causa: entrada em /etc/shadow ou setcap, que dependem de elevação. Correção: trocar por USER fixo no Dockerfile, que resolve o mesmo problema.

Tabela

Trava O que tira Custo
user root vira usuário comum 0 ms, capacidades caem para 0
cap_drop: ALL o resto das capacidades 0 ms
read_only escrita na imagem 0 ms, quebra apt em execução
no-new-privileges elevação durante o run 0 ms
tmpfs em /tmp escrita que some com o container sai da RAM

O tmpfs sai da RAM de verdade, e some quando o container morre. Para ver isso de perto com número, o post sobre auditoria de memória mostra quanto custa um processo que cresce sem teto.

FAQ

Preciso de --privileged para algo de rede? Só se o programa mexe em interface, rota ou porta baixa. Para quase tudo, rede comum e cap_add: NET_BIND_SERVICE bastam.

Como descubro o uid certo da minha imagem? docker run --rm imagem cat /etc/passwd e procure a conta que o serviço já usa. Reaproveitar conta da imagem evita o useradd no Dockerfile.

cap_drop: ALL não quebra TLS? Não. TLS não usa capacidade do kernel, usa socket comum.

Funciona igual em Kubernetes? A lógica é a mesma, com securityContext no manifesto em vez do compose. Os campos readOnlyRootFilesystem e runAsNonRoot dizem a mesma coisa.

Por que o cap_drop parece redundante com o user? Parece, e na prática ele é a rede de segurança. Se um dia o user ficar de fora por engano, o cap_drop ainda segura.

Fontes

Guia de segurança do Docker, referência de serviços do Compose e capabilities(7), do manual do Linux. Para a outra ponta da mesma ideia, fora do contêiner, o SSH em vez de senha mostra o que muda quando a identidade deixa de ser root.

Leia também

// Por e-mail

Os textos publicados aqui também vão por e-mail. O envio ainda não começou: quem deixar o endereço agora entra na primeira lista.

Guardo só o seu e-mail, a data e o registro desta autorização. Sem cookies, sem rastreador e nada compartilhado com terceiros. Detalhes na política de privacidade.