Boas Práticas de Imagens Docker: Layers, Union Filesystem e Como o Build Realmente Funciona para Times Ágeis Já leu

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 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

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:

  1. A layer base (geralmente uma distribuição Linux como Ubuntu ou Alpine)
  2. Camadas intermediárias (instalação de pacotes, adição de arquivos)
  3. 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:

  1. Parsing do Dockerfile: Docker lê e valida a sintaxe
  2. Identificação de Cache: Para cada instrução, Docker verifica se existe uma layer anterior com aquela combinação de comando e seus contextos
  3. Execução: Se houver cache, reutiliza; caso contrário, cria um container temporário, executa a instrução e gera uma nova layer
  4. 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:

  1. Usa a imagem completa do Node (>900MB)
  2. Instala dependências desnecessárias de desenvolvimento
  3. 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_modules relevantes
  • 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.

Referências


Artigos relacionados