O que é, na prática
Um container não é uma máquina virtual. É um processo isolado que roda sobre o kernel do host, com namespaces para separação de visão do sistema e cgroups para limitação de recursos. A ideia é empacotar uma aplicação com todas as suas dependências num formato que seja reproduzível em qualquer ambiente onde um runtime de containers esteja disponível. O Docker é o mais comum, mas existem alternativas como podman, nerdctl e containerd que funcionam de forma similar. O ponto mais ignorado por quem começa: o Dockerfile não define um container. Ele define uma imagem. O container em si é a instância executada a partir dessa imagem. Entender essa distinção evita muitos erros de configuração, especialmente quando você precisa ajustar variáveis de ambiente, volumes ou rede no momento do deploy.
Dockerfile: o mínimo que funciona
Aqui está um exemplo base funcional para uma aplicação Node.js:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
As linhas são simples, mas cada decisão tem consequências. Usar node:20-slim em vez de node:20 reduz a imagem de cerca de 900 MB para 200 MB. O npm ci substitui o npm install porque ele lê o lock file de forma estrita, garantindo que as versões sejam idênticas entre builds. Isso é especialmente relevante quando o projeto precisa ser reconstruído em CI/CD sem surpresas. O WORKDIR é obrigatório antes do COPY. Se você pular isso, os arquivos vão para o diretório raiz, o que quebra caminhos relativos no código e confunde qualquer pessoa que precise debugar o container rodando em modo interativo.
O projeto de um container: estrutura prática
O projeto de um container envolve mais do que escrever um Dockerfile. Você precisa decidir onde os dados persistem, como os segredos são injetados, qual a política de rede e como o processo de build vai funcionar. Uma estrutura mínima que funciona na maioria dos casos:
- Dockerfile — definição da imagem base
- .dockerignore — arquivos que não devem ser incluídos no contexto de build
- docker-compose.yml — orquestração local ou de staging
- Dockerfile.prod — override de produção se necessário
O .dockerignore é tão importante quanto o Dockerfile. Sem ele, o contexto de build pode ficar com gigabytes de dados desnecessários — node_modules, pastas de teste, arquivos de logs. Um .dockerignore básico inclui node_modules, .git, logs, .env e pastas de build locais.
Camadas e cache: o que ninguém explica direito
Cada instrução no Dockerfile cria uma camada. O Docker faz cache por camada, o que significa que se você alterar apenas a última linha, as camadas anteriores são reutilizadas. Esse comportamento é útil mas armadilha fácil: colocar COPY . . antes de RUN npm ci invalida o cache do install toda vez que qualquer arquivo muda, mesmo que o package.json permaneça intocado. A ordem correta — copiar package.json primeiro, instalar dependências, depois copiar o resto — pode reduzir um rebuild de 4 minutos para 30 segundos em projetos grandes. O cache é armazenado localmente. Em servidores de CI, se o build anterior foi deletado ou a máquina foi provisionada do zero, o cache some e o rebuild volta a levar o tempo completo. Planejar isso evita surpresas no pipeline.
Otimização de tamanho: números reais
Aqui estão medições práticas que fiz em projetos recentes: Imagem base Ubuntu com Node.js instalado via apt: 850 MB. Com node:20-slim: 210 MB. Multiplicação de imagem multi-stage para uma API Python com SQLAlchemy e Uvicorn: imagem final caiu de 1.2 GB para 180 MB.
O truque dos multi-stage builds consiste em usar uma imagem pesada apenas para compilar ou instalar dependências de build, e copiar apenas os artefatos finais para uma imagem runtime enxuta. No caso do Python, o stage de build usa uma imagem completa com GCC e headers, o stage final usa Alpine ou slim com apenas o interpretador e os arquivos compilados. Outro ponto: evitar installar pacotes que não são usados na runtime. Bibliotecas de desenvolvimento, documentação, exemplos — tudo isso ocupa espaço e aumenta a superfície de ataque. Use flags como --no-install-recommends no apt e --production no npm para evitar isso.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Segurança básica que funciona
Executar container como root é o erro mais comum. Adicione USER node (ou o usuário apropriado) antes do CMD no Dockerfile. Isso limita o dano se houver uma vulnerabilidade que permita escalada de privilégio dentro do container. Scanner de imagens é útil mas gera muitos falsos positivos. O Trivy é gratuito e cobre CVEs conhecidos. O problema é que ele também reporta vulnerabilidades em bibliotecas que você não importa — ou seja, que não estão no seu código. Filtrar por caminho de importação elimina a maior parte do ruído.
Atualizar imagens base regularmente é mais importante do que muitos pensam. Imagens antigas acumulam CVEs conhecidos que já foram corrigidos em versões mais recentes. Configurar renovate bot ou dependabot para atualizar tags de imagem automaticamente resolve isso sem esforço manual.
Problema real que encontrei e solução
Em um projeto de microserviço com Spring Boot, a aplicação iniciava em 4 segundos em desenvolvimento local mas levava 45 segundos dentro do container. A causa não era o Java em si — era o DNS. O container herdava a configuração de DNS do daemon Docker, que em algumas máquinas resolve lentidão para domínios internos. A solução foi adicionar "dns": ["8.8.8.8", "8.8.4.4"] no daemon.json ou passar --dns direto no docker run. O tempo de inicialização caiu para 5 segundos, praticamente igual ao local. Outro caso: volumes montados em containers Linux que perderam permissões após atualização do kernel. Arquivos criados como root dentro do container não eram acessíveis pelo usuário da aplicação. A correção foi garantir que o entrypoint ajustasse permissões com chown antes de iniciar a aplicação, ou usar um volume named em vez de bind mount.
Limitações que o projeto de um container enfrenta
Containers não são perfeitos e é importante saber onde eles falham antes de confiar neles em produção. O estado é efêmero por padrão. Se você precisa de persistência de dados além de volumes, precisa projetar isso explicitamente. Bancos de dados dentro de containers exigem configuração de volume e backup automatizado. Sem isso, uma reinicialização perde tudo.
Rede entre containers depende de uma rede definida. Containers na rede padrão do Docker têm resolução DNS automática, mas containers em redes diferentes precisam de configuração explícita. Isso parece óbvio até acontecer em produção. Performance de I/O em volumes bind-mount é significativamente menor que em volumes nomeados no Linux. Se o projeto exige alta throughput de leitura/escrita, evite bind mounts para dados quentes.
Orquestração manual com docker run fica inviável acima de meia dúzia de serviços. docker-compose funciona para desenvolvimento e ambientes pequenos, mas para produção em escala, Kubernetes ou uma solução gerenciada como ECS ou Run:Pod são mais adequados.
Quando não usar container
Aplicações que dependem de drivers de hardware específicos, GPUs com configuração complexa fora do padrão, ou sistemas embarcados com kernel modificado geralmente se beneficiam mais de VMs tradicionais. Containers compartilham o kernel do host — se o host não oferece o que a aplicação precisa, o container não resolve o problema. Também não faz sentido containerizar scripts simples que rodam em cron ou tarefas pontuais. overhead de infraestrutura não compensa o benefício de isolamento.
Checklist de deploy
Antes de subir para produção, verifique: Imagem com tag específica, nunca latest. User não-root configurado. Healthcheck definido no compose ou no orchestration. Logs roteados para stdout/stderr, não para arquivo. Variáveis de ambiente injetadas via secret manager, nunca hardcoded. Resource limits definidos para CPU e memória. Rede restrita ao necessário — sem acesso à rede do host.
Testar o container em um ambiente limpo, sem cache e sem volumes antigos, antes de considerar o deploy confiável. A maioria dos problemas de produção que parecem misteriosos desaparece quando você replica o build exatamente como ele será executado.