Existe uma maneira ingênua de pensar containers: "é tipo uma máquina virtual, só que mais leve". A frase não está errada — é só superficial. Containers e VMs resolvem o mesmo problema (isolamento de aplicações) por mecanismos radicalmente diferentes, e essa diferença mecânica tem consequências operacionais enormes — em segurança, performance, debugging e arquitetura. Quem trata containers como "VMs leves" eventualmente esbarra em situações onde a abstração vaza, e nessa hora não tem para onde fugir: precisa entender o que tem embaixo.
A boa notícia é que o que tem embaixo é compreensível. Containers em Linux são, basicamente, processos comuns com isolamento adicional aplicado via duas tecnologias do kernel: namespaces (que isolam o que o processo vê) e cgroups (que limitam o que ele consome). É isso. Docker, Podman, containerd, runc, Kubernetes — tudo o que parece magia é orquestração ao redor desses dois mecanismos. Quando você entende namespaces e cgroups, todo o resto faz sentido.
O modelo: processo + isolamento
Quando você roda docker run nginx, o que acontece no host
Linux é, em essência, simples:
- Um processo é criado via
fork() + exec()— é o nginx. - Antes de exec, esse processo recebe um conjunto de namespaces novos: vê seu próprio sistema de arquivos, sua própria lista de processos, sua própria rede, etc.
- O processo é colocado em cgroups que limitam quanto pode consumir de CPU, memória e I/O.
- O sistema de arquivos é montado a partir de uma imagem (camadas de read-only mais uma camada writable em cima).
Pronto. É um processo nginx normal — você consegue ver no ps
do host (com PID alto), pode kill dele, ele aparece no
top. A única diferença para um nginx "normal" é que, do ponto
de vista dele próprio, ele acha que é PID 1 num mundo onde só
existe ele e seus filhos.
Compare com VMs: uma VM tem kernel próprio, um sistema operacional completo, hardware emulado. Boot leva segundos a dezenas de segundos. Memória mínima na casa de centenas de MB. Sandbox via hypervisor (KVM, Hyper-V) — isolamento muito mais forte, custo proporcionalmente maior. Container starta em milissegundos, consome dezenas de MB, mas compartilha o kernel do host — se há um exploit no kernel, ele atinge todos os containers ao mesmo tempo.
Namespaces — isolamento de visão
Linux suporta sete tipos de namespaces. Cada um isola um aspecto diferente do que o processo enxerga:
- PID namespace: lista de processos. Processo dentro é PID 1, não vê processos do host.
- Network namespace: interfaces de rede, tabelas de roteamento, regras de firewall.
- Mount namespace: pontos de montagem do filesystem. Container vê seu próprio rootfs.
- UTS namespace: hostname e domínio. Container pode ter hostname próprio.
- IPC namespace: comunicação entre processos (semáforos, message queues, shared memory POSIX).
- User namespace: mapeamento de UIDs/GIDs. Permite "root no container" ser um usuário comum no host.
- Cgroup namespace: vista da hierarquia de cgroups.
- Time namespace (Linux 5.6+): clock próprio.
Cada container típico criado por Docker tem um namespace de cada tipo. O
processo principal e seus filhos compartilham esses namespaces; processos
fora deles não os veem. Você pode "entrar" num namespace existente via
nsenter — é o que docker exec faz por baixo.
Experimento revelador: nsenter -t <pid_do_processo_no_container>
-m -p -n -- /bin/bash te coloca dentro dos namespaces do container,
sem usar Docker. Você vai ver o filesystem dele, os processos dele, a rede
dele. É a prova de que Docker não é magia — é uma ferramenta sobre
primitivas do kernel.
Cgroups — limitação de consumo
Namespaces controlam o que o processo vê; cgroups controlam o que ele pode consumir. Cada cgroup pode ter limites em:
- CPU: quotas (em ms por janela de 100ms), peso relativo (shares), CPUs específicas.
- Memória: limite hard (OOM kill ao atingir), soft (preferência sob pressão), swap.
- I/O em block devices: throughput em bytes/s ou IOPS.
- PIDs: número máximo de processos.
- Network: priorização (não throughput direto — isso requer tc).
Em Kubernetes, resources.requests e resources.limits
no manifesto do pod viram cgroups via runc. requests dão
prioridade no scheduling; limits são os limites hard. App que
tenta passar do limite de memória é OOMKilled imediatamente; app que
tenta passar do limite de CPU é simplesmente desacelerado (throttled).
CPU throttling é silencioso. Seu app em Kubernetes com
limits.cpu: 500m não recebe erro quando atinge o limite —
ele simplesmente espera a próxima janela. O sintoma é latência alta com
load aparentemente baixo. Métrica container_cpu_throttled_periods_total
é o que mostra. Muitos times só descobrem isso quando aprendem a
olhá-la.
Imagens OCI — o formato
Imagens de container seguem o padrão OCI (Open Container Initiative), uma especificação aberta mantida pela Linux Foundation. Uma imagem é essencialmente:
- Um manifest: metadados (qual arquitetura, qual SO).
- Um config: variáveis de ambiente, comando default, exposed ports, layers.
- Um conjunto de layers: arquivos tar.gz que, sobrepostos via union filesystem (overlay2), formam o rootfs.
O detalhe interessante é o sistema de camadas. Cada instrução
RUN, COPY, ADD num Dockerfile cria
uma nova camada. Camadas são imutáveis e identificadas por hash de
conteúdo — duas imagens que compartilham camadas iniciais (mesma base,
mesmas dependências) literalmente compartilham os bytes em disco e em
transferência. Isso é o que torna pulls de imagens novas frequentemente
rápidas: só camadas novas vêm.
Boas práticas de Dockerfile
Não é foco do conceito, mas vale o catálogo mínimo:
- Multi-stage builds: imagem final só com runtime, build em estágio separado. Reduz tamanho dramaticamente.
-
Order matters: instruções que mudam menos no topo,
que mudam mais embaixo. Cache de camadas é por hash da instrução +
contexto — coloque
COPY package.jsonantes deCOPY .para reaproveitar layer de install. -
Imagens base mínimas:
alpine,distroless, ou imagens "scratch" para Go estático. Menor superfície = menos vulnerabilidades. -
Não rodar como root:
USER 1000ou similar. Combinado com user namespaces, mitiga muitos vetores de ataque. - HEALTHCHECK: define como o orquestrador sabe se o container está saudável. Em Kubernetes, isso vira liveness/readiness probes.
O problema do PID 1
Quando o processo principal de um container é PID 1, ele assume responsabilidades
especiais que normalmente são do init do sistema:
- Reapear processos zumbis (filhos órfãos cujo pai morreu).
- Forwardear sinais corretamente (SIGTERM ao container vira SIGTERM ao PID 1).
Aplicações típicas (Node.js, Python, Java) não foram desenhadas para serem
PID 1. Resultado: zumbis acumulam quando a aplicação cria sub-processos
que terminam, e SIGTERM pode ser ignorado se a app não capturar
explicitamente. A solução canônica é usar um init mínimo: tini,
dumb-init, ou o --init do Docker. Em Kubernetes,
o runtime moderno frequentemente já injeta um init.
Networking de containers
Cada container Docker num host típico tem sua própria network namespace,
conectada ao host via veth pair (cabo virtual com duas pontas) e
uma bridge (docker0 default). DNS interno resolve nomes de
containers; NAT na bridge permite acessar internet.
Em Kubernetes, o modelo é diferente: cada pod tem network namespace, e todos os containers do mesmo pod compartilham essa namespace (podem conversar via localhost). Pods em hosts diferentes se conectam via overlay networks providas por CNI plugins (Calico, Cilium, Flannel) — geralmente VXLAN ou roteamento direto.
O que isso significa operacionalmente: containers podem fazer tudo que fazem com rede normal — abrir portas, conectar, escutar — mas dentro de uma rede virtual que é mapeada para a real via vários níveis de indireção. Quando algo falha em rede de container, debug envolve entender em qual camada (host, bridge, overlay, ingress) o pacote se perdeu.
Containers nas três linguagens da formação
Containerização é mais sobre o ecossistema do que sobre a linguagem, mas cada uma tem peculiaridades que valem nota:
# Multi-stage build com SDK + runtime mínimo
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["app/app.csproj", "app/"]
RUN dotnet restore "app/app.csproj"
COPY . .
WORKDIR /src/app
RUN dotnet publish -c Release -o /publish
# Imagem final: chiseled (Microsoft) — minimalista, sem shell
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /publish .
USER app
ENTRYPOINT ["./app"]
Microsoft mantém imagens "chiseled": só o necessário para o runtime,
sem shell, sem package manager. Reduz superfície de ataque
dramaticamente. Para AOT (.NET 8+ Native AOT), pode usar
distroless ou scratch.
# Multi-stage com venv para isolar dependências
FROM python:3.13-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /venv && /venv/bin/pip install -r requirements.txt
FROM python:3.13-slim
WORKDIR /app
COPY --from=build /venv /venv
COPY . .
ENV PATH="/venv/bin:$PATH" PYTHONUNBUFFERED=1
USER 1000
ENTRYPOINT ["python", "-m", "app"]
PYTHONUNBUFFERED=1 é praticamente obrigatório em
containers — sem isso, logs ficam em buffer e somem em crashes.
python:3.13-slim é um bom default; distroless
serve para apps maduros.
# Build estático em multi-stage
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app -ldflags="-s -w" ./cmd/server
# Imagem final: scratch — vazia
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
USER 1000
ENTRYPOINT ["/app"]
Go gera binário estático, então a imagem final pode ser literalmente
vazia (scratch). Resultado: imagens de 10-20MB,
superfície de ataque mínima. Lembre de copiar
ca-certificates se o app fizer HTTPS.
Quando container não é a resposta
Containers viraram default da indústria, mas há cenários onde não cabem ou cabem mal:
- Workloads com isolamento de segurança forte: multi-tenant executando código não-confiável (ex: sandboxes de CI/CD que rodam código de usuários). Aqui você quer microVMs (Firecracker, Kata Containers) ou VMs reais.
- Apps stateful com requisitos específicos de hardware: bancos de dados de alta performance frequentemente preferem rodar em hosts dedicados, sem a abstração extra. Não é proibido em containers, mas tuning fica mais complexo.
- Apps com kernel modules específicos: nada disso é possível dentro de container — kernel modules são do host.
- Workloads que dependem de syscalls bloqueadas por seccomp: runtimes de container aplicam um perfil seccomp que bloqueia syscalls perigosas. Algumas apps legadas usam syscalls que estão na blocklist.
Como praticar
-
Crie um container do zero. Usando só
unshareechroot, crie um "container manual": um processo bash com namespace de PID, mount, network próprios. Você vai entender concretamente o que Docker faz. -
Inspecione a anatomia de uma imagem.
docker save nginx:alpine -o nginx.tar, descomprima, navegue. Vejamanifest.json,config.json, e cada layer. Cada camada é um tar.gz com diff de filesystem. -
Provoque OOMKill. Rode um container com
--memory 100me dentro dele um processo que aloca 200MB. Veja o kernel matar viadmesg | grep -i kill. Veja como o container reaparece (se gerenciado por orquestrador) ou morre permanentemente.
Referências para aprofundar
- livro Container Security — Liz Rice (2020).
- livro Kubernetes in Action (2nd ed.) — Marko Lukša (2024).
- livro The Linux Programming Interface — Michael Kerrisk.
- artigo Containers from Scratch — Liz Rice (palestra).
- artigo Linux Containers in 500 Lines of Code — Lizzie Dixon.
- artigo What Is a Container? — Jérôme Petazzoni.
- artigo What every SRE should know about GNU/Linux memory management, OOMKiller and cgroups — Tanel Põder.
- docs OCI Image Specification.
- docs Linux man pages — namespaces(7), cgroups(7).
- docs Docker Engine Documentation.
- vídeo Cgroups, namespaces and beyond — Jérôme Petazzoni.
- paper Borg, Omega, and Kubernetes — Burns et al. (Google, 2016).