Introdução: O Que São Layers e Por Que Importam
Docker revolucionou a forma como empacotamos e entregamos aplicações. Mas por trás da simplicidade de um comando docker run existe uma arquitetura sofisticada baseada em layers e union filesystem. Compreender esses conceitos é essencial para escrever Dockerfiles eficientes, otimizar tamanho de imagens e debugar problemas de construção. Neste artigo, vamos desconstruir como Docker realmente funciona por trás dos bastidores.
Uma imagem Docker não é um arquivo monolítico. Ela é, na verdade, uma pilha de camadas imutáveis (layers), cada uma representando um conjunto de mudanças no sistema de arquivos. Quando você cria um container a partir de uma imagem, Docker adiciona uma camada gravável no topo, permitindo que o container faça alterações sem modificar a imagem original. Esse é o segredo da eficiência do Docker.
Compreendendo Layers: A Arquitetura em Camadas
O Que é uma Layer?
Uma layer é um arquivo contendo as mudanças (delta) em relação à camada anterior. Quando você escreve um Dockerfile com múltiplos comandos RUN, COPY, ADD ou ENV, cada um desses comandos gera uma nova layer. Essa abordagem permite reutilização: se duas imagens compartilham as mesmas camadas iniciais, elas reutilizam o mesmo espaço em disco.
Vamos visualizar isso com um exemplo prático. Imagine um Dockerfile simples:
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3
RUN pip3 install flask
COPY app.py /app/
WORKDIR /app
CMD ["python3", "app.py"]
Cada linha que modifica o sistema de arquivos cria uma layer separada. A imagem final é a composição dessas camadas. Se você mudar apenas a linha COPY app.py /app/, as três primeiras layers podem ser reutilizadas do cache de build anterior, acelerando significativamente a construção.
Inspecionando Layers com Docker History
Você pode examinar as layers de qualquer imagem usando docker history. Este comando mostra o histórico de construção e o tamanho de cada layer:
docker history ubuntu:22.04
Saída esperada:
IMAGE CREATED CREATED BY SIZE
baasf8d92b3 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
a1b2c3d4e5f6 2 weeks ago /bin/sh -c mkdir -p /run/systemd && echo 'do… 16.8MB
g7h8i9j0k1l2 2 weeks ago /bin/sh -c #(nop) ADD file:1234... 77.8MB
Cada linha representa uma layer. O SIZE mostra quanto essa camada contribui para o tamanho total. Camadas com size 0B geralmente são metadados (como CMD ou ENV).
Para uma inspeção mais detalhada, você pode usar docker inspect com a flag de imagem:
docker inspect ubuntu:22.04 | grep -A 20 "RootFS"
Isso mostra os digests criptográficos (SHA256) de cada layer, permitindo verificar exatamente quais camadas uma imagem contém.
Union Filesystem: Como Docker Monta as Camadas
O Mecanismo Por Trás da Montagem
Docker usa um union filesystem para sobrepor camadas de forma eficiente. Um union filesystem permite que múltiplos diretórios sejam montados no mesmo ponto, criando uma visão unificada do sistema de arquivos. O driver mais comum atualmente é o overlay2, que substituiu o antigo AUFS.
Quando um container inicia, Docker monta as layers em ordem:
- A layer base (geralmente uma distribuição Linux como Ubuntu ou Alpine)
- Camadas intermediárias (instalação de pacotes, adição de arquivos)
- Uma camada gravável no topo (exclusive para esse container)
Qualquer leitura de arquivo passa pelas camadas de cima para baixo até encontrar o arquivo. Escritas sempre vão para a camada gravável do topo. Se você modifica um arquivo que existe em uma camada inferior, Docker copia o arquivo para a camada do container (copy-on-write) antes de modificá-lo. Isso preserva a integridade da imagem original.
Visualizando o Union Filesystem
Você pode inspecionar a estrutura de layers de um container usando:
docker inspect <container_id> | grep -A 10 "GraphDriver"
Saída tipicamente mostrará algo como:
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/layer1:/var/lib/docker/overlay2/layer2:/var/lib/docker/overlay2/layer3",
"MergedDir": "/var/lib/docker/overlay2/merged",
"UpperDir": "/var/lib/docker/overlay2/layer4/diff",
"WorkDir": "/var/lib/docker/overlay2/layer4/work"
},
"Name": "overlay2"
}
- LowerDir: As layers de somente leitura (imagem)
- UpperDir: A layer gravável (específica do container)
- MergedDir: A visão unificada (onde o container enxerga o filesystem)
- WorkDir: Diretório temporário para operações do filesystem
Como o Docker Build Realmente Funciona
O Processo Passo a Passo
Quando você executa docker build, Docker passa por um processo determinístico:
- Parsing do Dockerfile: Docker lê e valida a sintaxe
- Identificação de Cache: Para cada instrução, Docker verifica se existe uma layer anterior com aquela combinação de comando e seus contextos
- Execução: Se houver cache, reutiliza; caso contrário, cria um container temporário, executa a instrução e gera uma nova layer
- Remoção do Container Temporário: O container intermediário é descartado, mas sua layer é preservada
Vamos criar um exemplo realista de um Dockerfile para uma aplicação Python:
FROM python:3.11-slim
# Layer 1: Metadados
LABEL maintainer="seu-email@example.com"
ENV PYTHONUNBUFFERED=1
# Layer 2: Dependências do sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Layer 3: Código-fonte
WORKDIR /app
COPY requirements.txt .
# Layer 4: Dependências Python
RUN pip install --no-cache-dir -r requirements.txt
# Layer 5: Aplicação
COPY . .
# Metadados (não cria layer)
EXPOSE 5000
CMD ["python", "app.py"]
Vamos construir essa imagem e entender o que acontece:
docker build -t minha-app:1.0 .
Saída esperada:
Sending build context to Docker daemon 15.36kB
Step 1/8 : FROM python:3.11-slim
---> a1b2c3d4e5f6
Step 2/8 : LABEL maintainer="seu-email@example.com"
---> Running in temporary_container_abc123
---> a2b3c4d5e6f7
Step 3/8 : ENV PYTHONUNBUFFERED=1
---> Running in temporary_container_def456
---> b3c4d5e6f7g8
Step 4/8 : RUN apt-get update && apt-get install -y --no-install-recommends...
---> Running in temporary_container_ghi789
---> c4d5e6f7g8h9
Step 5/8 : WORKDIR /app
---> Running in temporary_container_jkl012
---> d5e6f7g8h9i0
Step 6/8 : COPY requirements.txt .
---> e6f7g8h9i0j1
Step 7/8 : RUN pip install --no-cache-dir -r requirements.txt
---> Running in temporary_container_mno345
---> f7g8h9i0j1k2
Step 8/8 : COPY . .
---> g8h9i0j1k2l3
Successfully built g8h9i0j1k2l3
Otimizando o Build com Cache
O cache é um tópico crítico. Docker usa uma estratégia de hash para determinar se pode reutilizar uma layer. Se você mudar o requirements.txt mas não a instrução RUN apt-get update, Docker pode reutilizar o resultado anterior dessa instrução se o comando e o contexto de build forem idênticos.
Porém, há um detalhe importante: a ordem importa. Se você coloca COPY . . antes de instalar dependências, qualquer mudança no código fonte invalida o cache de tudo que vem depois. Por isso, a ordem recomendada é:
# ✅ BOM: Dependências mudam raramente
FROM python:3.11-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
# ✅ BOM: Código muda frequentemente e fica por último
COPY . .
Não faça assim:
# ❌ RUIM: Código antes de dependências invalida cache facilmente
FROM python:3.11-slim
COPY . .
RUN pip install -r requirements.txt
Você também pode desabilitar completamente o cache usando a flag --no-cache:
docker build --no-cache -t minha-app:1.0 .
Inspecionando Layers Específicas
Para entender exatamente o que mudou em cada layer, você pode criar um container a partir de uma imagem intermediária:
# Usando o hash intermediário do build
docker run -it c4d5e6f7g8h9 bash
Isso abre um shell dentro daquela layer específica, permitindo inspecionar exatamente qual foi o resultado da execução.
Exemplo Prático: Otimizando Uma Imagem Real
Vamos demonstrar um caso de uso real: reduzir o tamanho de uma imagem de aplicação Node.js. Primeiro, um Dockerfile ingênuo:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "index.js"]
Este Dockerfile tem vários problemas:
- Usa a imagem completa do Node (>900MB)
- Instala dependências desnecessárias de desenvolvimento
- Não aproveita adequadamente o cache
Aqui está a versão otimizada:
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
# Copiar apenas as dependências instaladas do stage anterior
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
As melhorias aqui são:
- Alpine: Reduz de 900MB para ~170MB
- Multi-stage build: Apenas a imagem final inclui
node_modulesrelevantes - npm ci: Mais determinístico que
npm install - Separação clara: O builder tem tudo; o runtime apenas o necessário
Verifique o tamanho:
# Versão ingênua
docker build -t app-ingénua:1.0 .
docker images app-ingénua
# Versão otimizada
docker build -t app-otimizada:1.0 .
docker images app-otimizada
Esperamos uma redução de 50-70% no tamanho da imagem.
Conclusão
Aprendemos que imagens Docker são construídas como pilhas de camadas imutáveis, onde cada instrução no Dockerfile gera uma layer separada. O union filesystem (overlay2) permite montar essas camadas de forma eficiente, criando uma visão unificada do filesystem enquanto preserva a integridade da imagem original através do mecanismo copy-on-write.
Compreender o processo de build e cache é fundamental: Docker compara hashes de instruções e contextos para reutilizar layers, economizando tempo e espaço. A ordem das instruções no Dockerfile é crítica — colocar mudanças frequentes por último maximiza o cache. Finalmente, técnicas como multi-stage builds e uso de imagens Alpine reduzem drasticamente o tamanho da imagem sem sacrificar funcionalidade.
Com esse conhecimento, você está equipado para construir imagens Docker eficientes, entender por que builds falham, debugar problemas de cache e otimizar o tempo de deployment de suas aplicações.