Docker & Kubernetes

O que Todo Dev Deve Saber sobre Docker Multi-stage Builds: Reduzindo Imagens de GB para MB na Prática

10 min de leitura

O que Todo Dev Deve Saber sobre Docker Multi-stage Builds: Reduzindo Imagens de GB para MB na Prática

O Problema: Por Que Nossas Imagens Docker São Tão Grandes? Quando você constrói uma imagem Docker tradicional, está incluindo tudo na camada final: dependências de compilação, ferramentas de build, arquivos temporários, caches de gerenciadores de pacotes. Uma aplicação Go simples que deveria pesar 10 MB pode facilmente ocupar 500 MB ou mais. Uma aplicação Node.js fica ainda pior — você herda todas as dependências do , ferramentas de build como webpack, e tudo que foi instalado durante o desenvolvimento. O impacto é real: imagens gigantescas significam mais tempo para fazer push/pull em registries, mais espaço em disco nos servidores, deploys mais lentos e custos maiores de armazenamento em plataformas cloud. Se você trabalha em ambientes com restrições de banda ou com centenas de deploys por dia, esse problema é um gargalo significativo. Entendendo Multi-stage Builds: O Conceito Multi-stage builds permitem usar múltiplos em um único Dockerfile. Cada estágio é independente e você copia apenas os artefatos necessários do estágio anterior

<h2>O Problema: Por Que Nossas Imagens Docker São Tão Grandes?</h2>

<p>Quando você constrói uma imagem Docker tradicional, está incluindo tudo na camada final: dependências de compilação, ferramentas de build, arquivos temporários, caches de gerenciadores de pacotes. Uma aplicação Go simples que deveria pesar 10 MB pode facilmente ocupar 500 MB ou mais. Uma aplicação Node.js fica ainda pior — você herda todas as dependências do <code>node_modules</code>, ferramentas de build como webpack, e tudo que foi instalado durante o desenvolvimento.</p>

<p>O impacto é real: imagens gigantescas significam mais tempo para fazer push/pull em registries, mais espaço em disco nos servidores, deploys mais lentos e custos maiores de armazenamento em plataformas cloud. Se você trabalha em ambientes com restrições de banda ou com centenas de deploys por dia, esse problema é um gargalo significativo.</p>

<h2>Entendendo Multi-stage Builds: O Conceito</h2>

<p>Multi-stage builds permitem usar múltiplos <code>FROM</code> em um único Dockerfile. Cada estágio é independente e você copia apenas os artefatos necessários do estágio anterior para o próximo. A imagem final contém apenas o estágio final — tudo que ficou nas etapas anteriores é descartado.</p>

<p>A ideia é separar o <strong>ambiente de build</strong> (pesado, com compiladores e ferramentas) do <strong>ambiente de runtime</strong> (leve, apenas o necessário para executar). Um paralelo na engenharia tradicional: você não distribui toda a fábrica junto com o produto final, apenas o produto.</p>

<h3>Como Funciona Internamente</h3>

<p>Quando Docker executa um multi-stage build, ele cria uma imagem intermediária para cada estágio. As camadas dos estágios anteriores não aparecem na imagem final — apenas as camadas do último <code>FROM</code> são preservadas. Você controla explicitamente o que passa de um estágio para outro via <code>COPY --from=&lt;stage&gt;</code>.</p>

<p>O resultado é uma redução dramática de tamanho porque você deixa para trás compiladores, dependências de desenvolvimento e outros artefatos intermediários. Uma aplicação Go compilada pode ir de 800 MB para 15 MB; uma aplicação Python empacotada de 1.2 GB para 200 MB.</p>

<h2>Exemplo Prático 1: Aplicação Go</h2>

<h3>Sem Multi-stage (O Problema)</h3>

<pre><code class="language-dockerfile">FROM golang:1.21-alpine

WORKDIR /app

COPY . .

RUN go build -o myapp main.go

EXPOSE 8080

CMD [&quot;./myapp&quot;]</code></pre>

<p>Se você fizer o build: <code>docker build -t myapp:v1 .</code>, a imagem terá aproximadamente <strong>400 MB</strong> porque herda toda a imagem base <code>golang:1.21-alpine</code> (que inclui compilador, ferramentas de build, git, etc).</p>

<h3>Com Multi-stage (A Solução)</h3>

<pre><code class="language-dockerfile"># Estágio 1: Build

FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY . .

RUN go build -o myapp main.go

Estágio 2: Runtime

FROM alpine:latest

WORKDIR /app

COPY --from=builder /app/myapp .

EXPOSE 8080

CMD [&quot;./myapp&quot;]</code></pre>

<p>Agora a imagem final terá apenas <strong>~15 MB</strong>. O estágio <code>builder</code> é descartado, levando consigo o compilador Go e todas as dependências de build. Apenas o binário compilado (<code>myapp</code>) é copiado para a imagem final baseada em <code>alpine:latest</code> (uma imagem mínima de ~7 MB).</p>

<p>Você constrói assim: <code>docker build -t myapp:v1 .</code> e obtém uma imagem pronta para produção, leve e segura.</p>

<h2>Exemplo Prático 2: Aplicação Node.js com Build</h2>

<h3>Sem Multi-stage</h3>

<pre><code class="language-dockerfile">FROM node:18-alpine

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 3000

CMD [&quot;node&quot;, &quot;dist/index.js&quot;]</code></pre>

<p>Imagem final: <strong>~800 MB</strong> (todos os devDependencies, cache do npm, ferramentas de build).</p>

<h3>Com Multi-stage</h3>

<pre><code class="language-dockerfile"># Estágio 1: Dependências e Build

FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production &amp;&amp; \

npm ci &amp;&amp; \

npm run build

Estágio 2: Runtime

FROM node:18-alpine

WORKDIR /app

COPY --from=builder /app/node_modules ./node_modules

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/package.json .

EXPOSE 3000

CMD [&quot;node&quot;, &quot;dist/index.js&quot;]</code></pre>

<p>Imagem final: <strong>~180 MB</strong>. A diferença aqui é menor porque Node.js exige o runtime mesmo em produção, mas ainda eliminamos devDependencies e artefatos de build temporários.</p>

<p><strong>Versão ainda mais otimizada</strong> (usando distroless):</p>

<pre><code class="language-dockerfile">FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production &amp;&amp; \

npm ci &amp;&amp; \

npm run build

Usar distroless node para reduzir ainda mais

FROM gcr.io/distroless/nodejs18-debian11

WORKDIR /app

COPY --from=builder /app/node_modules ./node_modules

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/package.json .

EXPOSE 3000

CMD [&quot;dist/index.js&quot;]</code></pre>

<p>Com distroless: <strong>~150 MB</strong> (não inclui shell, apt, ou qualquer ferrramenta desnecessária).</p>

<h2>Boas Práticas e Otimizações Avançadas</h2>

<h3>Nomenclatura de Estágios</h3>

<p>Use nomes descritivos em vez de índices numéricos. Fica mais legível e manutenível:</p>

<pre><code class="language-dockerfile">FROM golang:1.21-alpine AS builder

... build steps ...

FROM alpine:latest AS runtime

... runtime setup ...</code></pre>

<p>Quando você copia, faz: <code>COPY --from=builder</code> em vez de <code>COPY --from=0</code>.</p>

<h3>Otimizando Layers em Cada Estágio</h3>

<p>Combine comandos RUN quando possível para reduzir camadas:</p>

<pre><code class="language-dockerfile"># Ruim: múltiplas camadas

RUN apt-get update

RUN apt-get install -y curl git

RUN apt-get clean

Bom: uma camada

RUN apt-get update &amp;&amp; \

apt-get install -y curl git &amp;&amp; \

apt-get clean &amp;&amp; \

rm -rf /var/lib/apt/lists/*</code></pre>

<p>Cada <code>RUN</code> cria uma camada. Mesmo que você delete arquivos em um <code>RUN</code> subsequente, as camadas anteriores ocupam espaço. Por isso combine e limpe na mesma instrução.</p>

<h3>Selecionando a Imagem Base Correta</h3>

<p>Para runtime, escolha a menor imagem que satisfaz suas necessidades:</p>

<ul>

<li><strong><code>alpine</code></strong>: ~7 MB, minimalista, sem muitas ferramentas</li>

<li><strong><code>slim</code></strong>: ~150 MB para linguagens como Python/Node, mais compatível</li>

<li><strong><code>distroless</code></strong>: ~70-100 MB, otimizado para produção, sem shell</li>

<li><strong><code>scratch</code></strong>: ~0 MB, apenas para binários estáticos (Go)</li>

</ul>

<pre><code class="language-dockerfile"># Para Go: pode usar scratch se compilar estaticamente

FROM scratch

COPY --from=builder /app/myapp .

CMD [&quot;./myapp&quot;]</code></pre>

<p>Uma aplicação Go em <code>scratch</code> pode ter apenas <strong>~20 MB</strong> se tiver assets estáticos, ou até menos com apenas o binário.</p>

<h3>Cacheamento Inteligente</h3>

<p>Ordene as instruções do mais estável para o menos estável. Mudanças em <code>package.json</code> invalidam o cache, mas mudanças no código-fonte não deveriam invalidar a instalação de dependências:</p>

<pre><code class="language-dockerfile">FROM node:18-alpine AS builder

WORKDIR /app

Copiar dependências primeiro (muda raramente)

COPY package*.json ./

RUN npm ci

Copiar código depois (muda frequentemente)

COPY . .

RUN npm run build</code></pre>

<p>Assim, se você apenas mudar o código, o Docker reutiliza a camada de <code>npm ci</code> do cache.</p>

<h2>Conclusão</h2>

<p>Multi-stage builds são fundamentais para containerização profissional. Você reduz imagens de gigabytes para megabytes — e isso não é apenas um número, é a diferença entre deploys rápidos e lentos, entre sistemas escaláveis e sobrecarregados. A técnica é simples: use múltiplos <code>FROM</code>, construa em um estágio pesado e copie apenas o necessário para um estágio leve de runtime.</p>

<p>O segundo aprendizado crítico é que essa redução não sacrifica funcionalidade — seu aplicativo roda exatamente igual, mas mais rápido, com menos consumo de recursos e menor superfície de ataque (menos ferramentas = menos vulnerabilidades).</p>

<p>Por fim, combine multi-stage com imagens base apropriadas (alpine, distroless, scratch) e boas práticas de cache para alcançar o máximo de eficiência. Aplique isso hoje e você verá o impacto imediato em seus pipelines de CI/CD.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://docs.docker.com/build/building/multi-stage/" target="_blank" rel="noopener noreferrer">Docker Official Documentation: Multi-stage builds</a></li>

<li><a href="https://github.com/GoogleContainerTools/distroless" target="_blank" rel="noopener noreferrer">Google Distroless Images</a></li>

<li><a href="https://kubernetes.io/docs/concepts/configuration/overview/#container-images" target="_blank" rel="noopener noreferrer">Kubernetes Best Practices: Container Images</a></li>

<li><a href="https://www.freecodecamp.org/news/the-docker-handbook/" target="_blank" rel="noopener noreferrer">The Docker Handbook - Farhan Hasin Chowdhury</a></li>

<li><a href="https://alpinelinux.org/about/" target="_blank" rel="noopener noreferrer">Alpine Linux Official Documentation</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Docker & Kubernetes

Service Mesh: Conceitos, Sidecar Proxy e Por que Usar na Prática
Service Mesh: Conceitos, Sidecar Proxy e Por que Usar na Prática

Service Mesh: O que é e por que existe Um Service Mesh é uma camada de infrae...

O que Todo Dev Deve Saber sobre Gateway API em Kubernetes: O Futuro do Ingress
O que Todo Dev Deve Saber sobre Gateway API em Kubernetes: O Futuro do Ingress

O Que é Gateway API e Por Que Ela é o Futuro A Gateway API é uma evolução sig...

O que Todo Dev Deve Saber sobre Progressive Delivery com Argo Rollouts: Canary e Blue-Green Automatizados
O que Todo Dev Deve Saber sobre Progressive Delivery com Argo Rollouts: Canary e Blue-Green Automatizados

O que é Progressive Delivery? Progressive Delivery é um modelo de implantação...