O que um Dockerfile mal escrito custa
| |
Quatro linhas, e cada uma custa alguma coisa. O docker build roda npm install de novo a cada mudança no código, porque COPY . . quebra o cache de camadas antes que o passo de instalação tenha chance de ser reaproveitado. A imagem final carrega toda a base Debian, a árvore inteira de node_modules incluindo as dependências de desenvolvimento, e qualquer ferramenta de build que o npm install tenha baixado para compilar módulos nativos. Nada é descartado. O container roda como root, porque nada diz o contrário, e node:latest significa que a imagem base pode mudar de uma build para outra sem deixar registro do que você realmente publicou.
Nada disso aparece como erro. A imagem constrói, o container sobe, a aplicação responde. O custo chega depois: uma build de CI de cinco minutos porque cada passo recomeça do zero, e uma imagem de 1,1 GB para uma aplicação cujo próprio código pesa algumas centenas de kilobytes. Cada correção abaixo é pequena. Juntas, elas mudam o que um Dockerfile custa no seu dia a dia.
Como o cache de camadas do Docker decide o que reconstruir
O Docker constrói uma imagem instrução por instrução e guarda em cache o resultado de cada uma como uma camada. Na build seguinte, ele percorre o Dockerfile de novo e reaproveita uma camada em cache enquanto a instrução for idêntica e a camada anterior na cadeia não tiver mudado. No momento em que uma instrução perde o cache, todas as que vêm depois também rodam de novo. Cache hits só estendem uma cadeia a partir do topo. Se imagens, camadas e containers ainda são conceitos vagos, o que é Docker e como os containers funcionam cobre a base que este artigo já dá como conhecida.
COPY e ADD invalidam pelo conteúdo, não só pelo texto da instrução: se algum arquivo copiado mudou, o cache daquela camada some, e com ele tudo que vem depois. COPY . . copia todo o contexto de build, então invalida a qualquer mudança em qualquer parte do projeto, até um comentário reescrito num arquivo que não tem nada a ver com dependências. Coloque RUN npm install logo depois desse COPY e ele roda de novo a cada build.
Como ordenar as instruções do Dockerfile para acertar o cache
Copie só o que uma instrução precisa, na ordem em que as coisas realmente mudam: manifestos de dependências raramente, código-fonte o tempo todo.
| |
package.json e package-lock.json mudam quando você adiciona ou atualiza uma dependência. RUN npm ci agora só depende do cache desses dois arquivos. Edite um arquivo-fonte e reconstrua: o Docker reaproveita a camada de instalação em cache e roda de novo só o COPY final e o que vem depois. O passo mais lento da build só roda quando precisa.
npm ci em vez de npm install é deliberado. Ele instala exatamente o que está no lockfile e falha se o lockfile e o package.json divergirem, em vez de resolver silenciosamente versões novas numa build que devia ser reproduzível.
A mesma ordem vale fora do Node: um projeto Python copia requirements.txt e roda pip install antes do resto do código-fonte, um projeto Go copia go.mod/go.sum e roda go mod download primeiro.
Como multi-stage builds reduzem a imagem final
Reordenar resolve o cache. Não muda nada numa imagem que continua carregando dependências de desenvolvimento, ferramentas de build e tudo que o npm ci baixou para compilar módulos nativos — nada disso é necessário para a aplicação rodar. Um multi-stage build separa o que é preciso para construir a aplicação do que é preciso para executá-la.
| |
Duas linhas FROM, dois stages. O primeiro, chamado build, instala todas as dependências (incluindo as só de desenvolvimento, como um bundler ou TypeScript) e produz dist/. O segundo começa do zero a partir da mesma imagem base, instala só as dependências de produção, e traz um diretório do primeiro stage com COPY --from=build. O compilador TypeScript, os arquivos-fonte .ts, o cache do npm: nada disso chega à imagem final, porque o stage de build é descartado assim que a build termina.
A diferença de tamanho é o ponto central. node:latest, a imagem completa baseada em Debian, fica perto de 1,1 GB antes mesmo de adicionar uma única dependência. node:24-slim fica abaixo de 300 MB, e node:24-alpine abaixo de 200 MB se suas dependências não precisarem de glibc. Multiplique isso por cada serviço que você roda e cada runner de CI que baixa a imagem, e a diferença vira armazenamento real e minutos reais.
O efeito é ainda maior numa linguagem compilada, onde o binário não precisa de nada além de si mesmo.
| |
Sem shell, sem gerenciador de pacotes, sem toolchain do Go na imagem final: só o binário estático mais os certificados CA e os dados de fuso horário que o distroless/static fornece. Um atacante que consegue execução nesse container não tem sh para rodar nem apt para instalar um.
O que colocar no .dockerignore
COPY . . manda todo o contexto de build para o daemon do Docker antes mesmo de a build começar, e esse contexto inclui arquivos que você nunca quis publicar: .git, um node_modules local, arquivos .env com credenciais reais, configuração do editor. Um .dockerignore ao lado do Dockerfile os exclui, usando a mesma sintaxe de padrões do .gitignore.
| |
Excluir node_modules vai além do tamanho. Se uma instalação local na sua máquina tem módulos nativos compilados para macOS e arm64, COPY . . leva esses binários para dentro de um container Linux onde eles não vão carregar. Deixe o container instalar suas próprias dependências.
Como fazer um container rodar como usuário não-root
Nada num Dockerfile básico impede um container de rodar como root, então por padrão é isso que acontece. Se um atacante consegue execução de código dentro dele, por uma vulnerabilidade numa dependência ou um bug de deserialização, root no container está a apenas um bug de kernel ou um mount mal feito de virar root no host. A maioria das imagens oficiais já vem com um usuário para o qual você pode trocar.
| |
A imagem node cria um usuário node, então USER node é a única linha que você adiciona. Quando uma imagem base não traz nenhum, crie um antes de trocar:
| |
O --chown importa tanto quanto o USER. Sem ele, os arquivos ficam com dono root enquanto o processo que os lê roda como app, e qualquer coisa que a aplicação escreva em runtime falha com um erro de permissão que você só descobre em produção. O mesmo vale para um volume Docker montado no container: o conteúdo dele precisa ser gravável pelo UID para o qual você trocou, ou a primeira escrita falha logo na inicialização.
Como fixar a tag ou o digest da imagem base
FROM node:latest resolve para o que latest estiver apontando no dia em que você constrói. Reconstrua o mesmo Dockerfile no mês seguinte e você pode acabar numa major diferente, numa release do Debian diferente, com pacotes de sistema diferentes, sem nenhum diff no seu repositório mostrando o que mudou. Fixe a versão:
| |
Para uma build que precisa ser reproduzível byte a byte, fixe o digest também. Isso amarra a build a uma única imagem imutável, não importa o que aconteça com a tag:
| |
Pegue o digest com docker pull node:24.9.0-slim seguido de docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Fixar a tag cobre a maioria dos projetos. Fixar o digest é para pipelines onde “o que exatamente publicamos” precisa ter resposta meses depois.
Quando combinar vários RUN numa só camada
Cada RUN produz uma camada, e uma camada só cresce. Apagar um arquivo numa camada posterior não diminui a imagem: ela só esconde o arquivo atrás de um whiteout marker enquanto a camada anterior continua carregando os bytes. É por isso que separar instalação e limpeza em dois RUN distintos não economiza nada:
| |
Coloque a limpeza na mesma camada da instalação:
| |
Isso vale para instalações que deixam arquivos para trás: caches de gerenciadores de pacotes, arquivos baixados, artefatos de build já copiados para outro lugar. Não é um argumento para juntar todos os RUN do arquivo. Um punhado de camadas legíveis é mais fácil de depurar do que uma linha de 400 caracteres, e o número de camadas sozinho não é o que custa tamanho.
Quando essas práticas não valem o esforço
Um script pontual que você roda localmente com docker build -t scratch . && docker run --rm scratch não precisa nem de multi-stage build nem de digest fixado. A cerimônia custa mais do que o risco que evita.
O tamanho menor do Alpine vem da musl libc no lugar da glibc, o que quebra módulos nativos de Node e Python compilados contra glibc. Segfaults ou erros de símbolo ausente depois de trocar para -alpine costumam ser isso. Use -slim quando não tiver certeza.
E fixar versões é uma escolha com um custo: uma imagem base não fixada recebe patches de segurança no próximo docker build --pull sem você fazer nada. Se você quer isso, tome a decisão de propósito, sabendo que uma build pode começar a se comportar diferente por motivos que não estão no seu diff. Deixar a tag solta por descuido não é a mesma decisão.
Como medir tamanho de imagem e tempo de build
Construa as duas versões da mesma aplicação e compare:
| |
docker images mostra a diferença de tamanho. docker history mostra qual instrução produziu qual camada e quanto ela pesa, o jeito mais rápido de achar o RUN que ainda está carregando algo que não deveria. Nada disso muda se sua aplicação é construída pelo Docker Compose: um serviço com build: . usa o mesmo Dockerfile, o mesmo cache e o mesmo contexto de build, então docker compose build ganha os mesmos benefícios.
Herdou um Dockerfile de antes de tudo isso? Construa ele uma vez do jeito que está, rode docker history, e corrija a camada que mais surpreender. Essa única camada costuma ser a maior parte da diferença.