Containers e Docker
O que é um container, como ele difere de uma máquina virtual e como se constrói uma imagem pequena, rápida e segura.
Os números desta página vêm de construções reais com Docker, numa aplicação Node pequena com uma dependência de produção e duas de desenvolvimento, e numa API Go mínima. Os tamanhos variam com as versões das imagens base; as proporções são o que importa.
Um Dockerfile para Node: do ingênuo ao cuidadoso
A versão que costuma aparecer primeiro:
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
CMD npm startA versão cuidadosa:
# Estágio 1: instala só as dependências de produção
FROM node:24-alpine AS dependencias
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Estágio 2: a imagem que vai para produção
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=dependencias /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]O que medimos com as duas:
| Ingênuo | Cuidadoso | |
|---|---|---|
| Tamanho da imagem | 1,73 GB | 236 MB |
| Reconstrução depois de mudar uma linha de código | 5,4 s (reinstala as dependências) | 0,4 s (dependências do cache) |
| Processo principal (PID 1) | /bin/sh -c npm start | node server.js |
docker stop | 10,2 s: o sinal não chegou, e o container foi morto à força | 0,1 s: a aplicação recebeu o SIGTERM e encerrou |
| Usuário | root | node |
Cada linha da tabela vem de uma decisão do Dockerfile:
| Decisão | Efeito |
|---|---|
node:24-alpine em vez de node:24 | A maior parte da redução de tamanho |
npm ci --omit=dev | Sem TypeScript, Vitest e outras dependências de desenvolvimento na imagem |
Copiar package*.json e instalar antes de COPY . . | A instalação vem do cache quando só o código muda |
CMD em forma de lista | O Node é o PID 1 e recebe os sinais |
USER node | O processo roda sem privilégios. A imagem oficial do Node já traz esse usuário |
Para TypeScript, acrescente um estágio que instala todas as dependências e roda a compilação, e copie para a imagem final só a pasta gerada.
Um Dockerfile para Go: binário estático
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /api .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /api /api
EXPOSE 3000
ENTRYPOINT ["/api"]CGO_ENABLED=0 gera um binário que não depende de nenhuma biblioteca do sistema, e -ldflags="-s -w" remove informações de depuração. A imagem final ficou com 14 MB e roda como um usuário sem privilégios, já definido pela variante nonroot da base. Não há shell: uma tentativa de executar sh dentro do container falhou.
Para as outras linguagens, a estrutura é a mesma, com estas peças:
| Linguagem | Estágio de construção | Imagem final típica |
|---|---|---|
| Python | python:3.13-slim, criando um ambiente virtual com pip install | python:3.13-slim, copiando o ambiente virtual |
| Java | eclipse-temurin:21-jdk com Maven ou Gradle | eclipse-temurin:21-jre, só com o JAR |
| C# | mcr.microsoft.com/dotnet/sdk:9.0 com dotnet publish | mcr.microsoft.com/dotnet/aspnet:9.0 |
A aplicação preparada para container
Um container bem construído também depende de como a aplicação se comporta dentro dele. Três regras resolvem a maior parte:
- Configuração pelo ambiente: a mesma imagem roda em testes e em produção, com variáveis diferentes.
- Falhar logo se faltar configuração: melhor o container não subir do que subir e falhar no primeiro pedido.
- Logs na saída padrão: o Docker e o orquestrador coletam o que sai em
stdout. Gravar em arquivos dentro do container perde os logs a cada substituição.
O encerramento gracioso, a quarta regra, está no tópico de escalabilidade.
function obrigatoria(nome: string): string {
const valor = process.env[nome];
if (!valor) {
log("erro", `variável de ambiente ${nome} ausente`);
process.exit(1);
}
return valor;
}
function log(nivel: string, mensagem: string, extra: object = {}) {
console.log(JSON.stringify({
hora: new Date().toISOString(),
nivel,
mensagem,
...extra,
}));
}
export const config = {
porta: Number(process.env.PORT ?? 3000),
bancoUrl: obrigatoria("DATABASE_URL"),
};
log("info", "iniciando", { porta: config.porta });import json
import logging
import os
import sys
class FormatoJson(logging.Formatter):
def format(self, registro: logging.LogRecord) -> str:
return json.dumps({
"hora": self.formatTime(registro),
"nivel": registro.levelname.lower(),
"mensagem": registro.getMessage(),
}, ensure_ascii=False)
saida = logging.StreamHandler(sys.stdout)
saida.setFormatter(FormatoJson())
logging.basicConfig(level=logging.INFO, handlers=[saida])
log = logging.getLogger("api")
def obrigatoria(nome: str) -> str:
valor = os.environ.get(nome)
if not valor:
log.error("variável de ambiente %s ausente", nome)
sys.exit(1)
return valor
PORTA = int(os.environ.get("PORT", "3000"))
BANCO_URL = obrigatoria("DATABASE_URL")
log.info("iniciando na porta %s", PORTA)package main
import (
"log/slog"
"os"
)
type Config struct {
Porta string
BancoURL string
}
func carregarConfig(log *slog.Logger) Config {
banco := os.Getenv("DATABASE_URL")
if banco == "" {
log.Error("variável de ambiente ausente", "nome", "DATABASE_URL")
os.Exit(1)
}
porta := os.Getenv("PORT")
if porta == "" {
porta = "3000"
}
return Config{Porta: porta, BancoURL: banco}
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
config := carregarConfig(log)
log.Info("iniciando", "porta", config.Porta)
// ... iniciar o servidor
}import java.time.Instant;
public class Config {
public final int porta;
public final String bancoUrl;
private Config(int porta, String bancoUrl) {
this.porta = porta;
this.bancoUrl = bancoUrl;
}
public static Config doAmbiente() {
String banco = System.getenv("DATABASE_URL");
if (banco == null || banco.isBlank()) {
log("erro", "variável de ambiente DATABASE_URL ausente");
System.exit(1);
}
String porta = System.getenv().getOrDefault("PORT", "3000");
return new Config(Integer.parseInt(porta), banco);
}
// Em projetos reais, use o Logback ou o Log4j2 com um codificador JSON
static void log(String nivel, String mensagem) {
System.out.printf("{\"hora\":\"%s\",\"nivel\":\"%s\",\"mensagem\":\"%s\"}%n",
Instant.now(), nivel, mensagem.replace("\"", "\\\""));
}
}var builder = WebApplication.CreateBuilder(args);
// Logs em JSON na saída padrão
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
// As variáveis de ambiente já fazem parte da configuração do ASP.NET
var bancoUrl = builder.Configuration["DATABASE_URL"]
?? throw new InvalidOperationException("variável de ambiente DATABASE_URL ausente");
var porta = builder.Configuration["PORT"] ?? "3000";
builder.WebHost.UseUrls($"http://0.0.0.0:{porta}");
var app = builder.Build();
app.Logger.LogInformation("iniciando na porta {Porta}", porta);
app.Run();O que muda de uma linguagem para outra
| Logs em JSON na saída padrão | Cuidado no container | |
|---|---|---|
| Node | console.log(JSON.stringify(...)), ou Pino | Nada especial; o Node já escreve na saída padrão sem atraso |
| Python | Um Formatter próprio, ou python-json-logger | Defina PYTHONUNBUFFERED=1, ou os logs podem demorar a aparecer |
| Go | slog com NewJSONHandler, da biblioteca padrão | Nenhum |
| Java | Logback ou Log4j2 com codificador JSON | A JVM respeita os limites de memória do container nas versões atuais; confira -XX:MaxRAMPercentage |
| C# | AddJsonConsole(), já incluído no ASP.NET | Nenhum |
Um detalhe que pega muita gente: a aplicação dentro do container precisa escutar em 0.0.0.0, e não em localhost. Escutando em localhost, ela só aceita conexões vindas de dentro do próprio container, e o mapeamento de porta do Docker não funciona.
Ambiente local com Docker Compose
Para desenvolver, o Docker Compose sobe a aplicação e as dependências com um comando:
# compose.yaml
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://loja:loja@banco:5432/loja
REDIS_URL: redis://cache:6379
depends_on:
banco:
condition: service_healthy
banco:
image: postgres:17-alpine
environment:
POSTGRES_USER: loja
POSTGRES_PASSWORD: loja
volumes:
- dados:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "loja"]
interval: 2s
retries: 15
cache:
image: redis:7-alpine
volumes:
dados:docker compose up --buildTrês detalhes do arquivo:
- Os serviços se encontram pelo nome: a aplicação conecta em
banco:5432, e não emlocalhost. O Compose cria uma rede com DNS interno. - O
healthcheckcomcondition: service_healthyfaz a aplicação esperar o banco aceitar conexões, e não apenas o container do banco iniciar. - O volume
dadosguarda os dados do PostgreSQL fora do container, e eles sobrevivem a umdocker compose down. Comdocker compose down -v, o volume é apagado.
As senhas do exemplo servem só para desenvolvimento local. Em produção, segredos vêm do orquestrador ou de um cofre de segredos, nunca de arquivos versionados.
Lista de revisão
Construção
- A imagem usa construção em estágios, sem ferramentas de construção na imagem final?
- As dependências são instaladas antes da cópia do código, com o arquivo de trava?
- Existe um
.dockerignoreque exclui.git, dependências locais, testes e.env? - A imagem base tem versão fixada?
- Nenhum segredo está em nenhuma camada da imagem?
Execução
- O processo roda com um usuário sem privilégios?
- O
CMDouENTRYPOINTestá em forma de lista, ou o script termina comexec? - A aplicação escuta em
0.0.0.0? - A configuração vem de variáveis de ambiente, e a aplicação falha logo se faltar alguma?
- Os logs vão para a saída padrão?
- Dados que precisam sobreviver ficam fora do container?
Publicação
- Cada imagem tem uma etiqueta imutável, por versão ou commit?
- As imagens passam por varredura de vulnerabilidades?
- Existe uma rotina para reconstruir com a base atualizada?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Do Dockerfile ingênuo ao de produção
Quero transformar um Dockerfile simples num Dockerfile de produção, medindo cada melhoria. Pergunte qual linguagem e framework eu uso. Vamos em etapas. Em cada uma, me passe os comandos para medir e explique o resultado: 1. Escreva comigo a versão mais simples, que funciona. 2. Meça o tamanho da imagem, o tempo de reconstrução depois de mudar uma linha e o tempo do docker stop. 3. Reordene as instruções para aproveitar o cache e meça de novo. 4. Troque a imagem base e acrescente construção em estágios, e meça. 5. Acrescente o .dockerignore e o usuário sem privilégios. 6. Corrija o CMD para a forma de lista e meça o docker stop de novo. 7. Rode uma varredura de vulnerabilidades nas duas versões e compare. No final, monte comigo uma tabela com a evolução de cada número.
Ambiente local com Docker Compose
Quero montar o ambiente de desenvolvimento da minha aplicação com Docker Compose. Pergunte qual linguagem eu uso e de quais serviços a aplicação depende (banco, cache, fila). Monte comigo o compose.yaml, explicando cada parte: 1. O serviço da aplicação, construído a partir do Dockerfile. 2. Os serviços de dependência, com versões fixadas. 3. A rede interna e por que a aplicação conecta pelo nome do serviço. 4. Verificações de saúde e a espera pela dependência pronta, e não só iniciada. 5. Volumes para os dados do banco. 6. Recarregamento automático do código durante o desenvolvimento, montando a pasta do projeto. Explique também a diferença entre usar o Compose para desenvolver e para produção.
Preparar a aplicação para rodar em container
Quero adaptar a minha aplicação para se comportar bem dentro de um container. Pergunte qual linguagem e framework eu uso. Explique, com código para o meu caso: 1. Como ler toda a configuração de variáveis de ambiente e falhar logo se faltar alguma obrigatória. 2. Como escrever logs estruturados em JSON na saída padrão. 3. Por que escutar em 0.0.0.0, e não em localhost. 4. Como respeitar os limites de memória e CPU do container no meu runtime. 5. Como tratar o SIGTERM para encerrar sem derrubar pedidos. 6. Onde guardar arquivos que precisam sobreviver ao container. Termine com uma lista de verificação para eu conferir a aplicação antes do primeiro deploy em container.