Dockerfile: Hardening e boas práticas
Postado em 16 February, 2026 | 29 minutosObs.: Originalmente publicado no computando-arte dia 28Jan2026
Dockerfile é tudo igual? Se a imagem está sendo buildada e a aplicação está rodando, posso mandar bala? Vamos ver nesse texto que não. Existem outras considerações a serem feitas, principalmente de segurança. Vamos nos debruçar para encontrar o Dockerfile mínimo contendo apenas o necessário para rodar nosso app em Python.

Foto de S. Laiba Ali, unsplash
Nosso exemplo: API em Python com Flask
Nesse texto vamos explorar bastante esse exemplo, que retorna um “Hello world” simples, e pra fazer uma graça um endpoint que retorna de volta o que foi enviado no caminho da url.
from flask import Flask
app = Flask(__name__)
@app.route("/hello")
def hello_default():
return hello("")
@app.route("/hello/<string:hellomsg>")
def hello(hellomsg: str) -> str:
res = "hello world!"
if len(hellomsg) > 0:
res = hellomsg
return res
if __name__ == "__main__":
app.run(host="0.0.0.0")Pra quem quiser brincar com eles, todos os códigos e arquivos utilizados nesse texto estão disponíveis no seguinte repositório gitlab.com/caioau/caioau.gitlab.io
Junto do Dockerfile temos o nosso requirements.txt:
|
|
Para quem não está familiarizado com a notação ~= estamos usando apontando para versão compatível, como as bibliotecas utilizam versionamento semântico (semver.org) vamos utilizar versões mais novas mas dentro do mesmo menor (minor), mas sem mudanças que quebrem as funcionalidades atuais. A linha Flask~=3.1.2 equivale a Flask>=3.1.2,Flask==3.1.*
Dockerfile v0
Com o código-fonte do nosso app, podemos seguir com o Dockerfile.
A fim de ajudar no debug do app, vamos incluir o vim e curl nas imagens.
|
|
Dockerfile-v0
O que está acontecendo? Utilizando uma imagem base do Debian, instalamos o Python com outros pacotes que fazem a compilação (build-essential e python3-dev). E daí na próxima instrução deletamos o índice do apt-get.
Mudamos a pasta de trabalho (WORKDIR) e copiamos o código-fonte da aplicação lá, instalamos as dependências da aplicação e similarmente com os índices do apt-get deletamos o cache do pip.
Por fim, configuramos o comando que sobe a aplicação, utilizando o gunicorn como servidor web Python.
Quando colocamos para rodar a imagem, a aplicação funciona \o/
Porém, além de outros problemas de que vamos falar logo, o tamanho da imagem: 570MB, tá bom pra você quase 600 megabytes por um simples hello world?
Fica até o final, porque vai ter bolo vamos reduzir o tamanho dessa imagem para 10x menor.
Observação: Nesse texto, quando estivermos falando do tamanho da imagem, estamos nos referindo ao tamanho sem compressão, ou seja, o valor reportado por docker image ls, ao passo que o tamanho que aparece na página WEB no dockerhub ou outros registries é o tamanho com compressão. Por exemplo, uma imagem de 522 MB sem compressão resulta em 192 MB com compressão.
Dissecando imagens com dive
A nossa primeira dica é usar o dive para “dissecar” a imagem. Uma vez carregada, conseguimos acompanhar como estão os arquivos, então podemos ir “caminhando” a partir da camada inicial até a final como os arquivos foram sendo alterados e dessa forma conseguimos mais facilmente visualizar o que está acontecendo e identificar pontos de melhoria.
Para instalar, basta baixar o binário do github: github.com/wagoodman/dive
Com ele instalado basta executar com o nome da imagem
Bora ver como o dive analisa nossa imagem? Carregando ele podemos o histórico das instruções do nosso Dockerfile, cada instrução gera uma camada e vamos caminhando nesse histórico. Como são muitos arquivos na imagem base, podemos mostrar apenas os arquivos modificados (pressionando Ctrl+U). Vamos retomar esse ponto importante, mas preste atenção na instrução COPY . . quais arquivos são copiados, além do código-fonte.
Com a imagem analisada percebemos dois erros: os índices do apt e o cache do pip não foram limpos, aumentando o tamanho da imagem final desnecessariamente. Isso aconteceu pois a deleção dos arquivos não está na mesma instrução (RUN) do que a criação deles, propagando para a próxima camada.
Repare no canto inferior esquerdo que a ferramenta dá algumas dicas de onde melhorar, para a imagem ficar mais eficiente.
Use dockerignore
O outro erro da nossa v0 foi que alguns arquivos sensíveis vazaram, em particular o arquivo .env com credenciais. Isso aconteceu pois quando copiamos os arquivos em COPY . . todos arquivos no contexto do docker são copiados.
Como resolver? Podemos colocar tudo o que podemos copiar no Docker dentro de uma pasta dedicada, mas teríamos que manter esse controle.
Uma solução mais robusta é utilizar o arquivo .dockerignore (documentação), que funciona de uma forma similar ao .gitignore, limitando a quais arquivos é permitido o acesso no contexto Docker.
|
|
.dockerignore
Acima temos um exemplo de dockerignore que resolve nosso problema, onde todos os arquivos são por padrão bloqueados, e então vamos criando exceções como arquivos de código-fonte.
Dockerfile v1
Com as lições aprendidas na versão anterior, bora arrumar os erros que encontramos?
|
|
Dockerfile-v1
O que mudamos da versão anterior? Colocamos a deleção dos arquivos desnecessários na mesma instrução (no caso do pip install utilizamos a opção –no-cache-dir).
Também usamos um ambiente virtual (venv) para melhor lidar com as dependências Python.
E por fim, para não rodar como root, criamos um usuário não root e o utilizamos sempre que possível. Se atente que o pip install foi executado como não root.
E o tamanho? Apesar de só termos mexido no cache do pip e os índices, a nova imagem é tem 520MB , aproximadamente 50MB menor (~10%).
User não root basta?
Criamos um usuário não root e paramos de usar o root, mas isso basta? A resposta é não, pois caso a aplicação esteja comprometida, o ataque pode usar qualquer binário presente na imagem para realizar ações mal-intencionadas. Um excelente site explorando isso é o GTFOBins que mostra como fazer essas explorações para cada binário.
Outro argumento é o princípio do privilégio mínimo que dita que devemos apenas usar as permissões mínimas necessárias para o funcionamento dos nossos sistemas, caso contrário as permissões excedentes podem ser abusadas.
Bora para uma revisão rápida de permissão de arquivos no Linux? O octeto de permissão é definido pelas permissões (do mais significativo para menos) ler (Read), escrever (Write) e executar (eXecute). E temos esse octeto três vezes (da esquerda pra direita): Para o dono, para o grupo e para outros.
Bora para um exemplo:
- O dono vai poder ler (4), escrever (2) e executar (1) -> 4 + 2 + 1 = 7
- O grupo vai poder ler (4) e executar (1) -> 4 + 1 = 5
- Por fim outros vão poder apenas ler (4) -> 4
Resultado: Executando chmod 754 vai gerar o resultado que queremos.
Até aí tudo bem, mas além desses 3 dígitos tem um quarto dígito, normalmente omitido, à esquerda que define mais um octeto o Suid , Guid e o Sticky Bit. Se a gente setar o suid no nosso exemplo anterior, a permissão que tínhamos 754 vira 4754 (adicionando o 4 à esquerda).
E o que faz esse suid? Quando setado o arquivo quando executado não é executado por quem executa, mas por quem é dono do arquivo, que tipicamente é o root, ou seja, qualquer usuário “vira root” (o sudo funciona assim).

Bora mostrar na prática?
|
|
Dockerfile-setuid
No dockerfile acima criamos um novo bash chamado bash-uid, com a permissão suid, agora mesmo que estejamos rodando como um usuário não root, se invocado esse bash especial escalamos o privilégio para root.
Para desarmar essa possibilidade, suba o container com a opção no-new-privileges que efetivamente protege contra a escalação de privilégio.

demonstrando o setuid
Repare que quando executamos o bash especial efetivamente se tornamos root, porém com a opção no-new-privileges habilitada resolvemos o problema.
Para outras boas práticas de segurança para containers docker consulte a excelente cartilha da OWASP (comunidade que publica diversas boas práticas de segurança para aplicações web): OWASP Docker Security Cheat Sheet
Dockerfile v2: introduzindo multistage
Até então estamos instalando compiladores necessários para o pip install, utilizando o pacote build-essential, porém em tempo de execução os mesmos não são necessários, esse é o cenário que a funcionalidade do multi-stage do docker resolve.
O multi-stage acontece quando utilizamos a instrução FROM múltiplas vezes, criando estágios separados, tipicamente para o build e um estágio final utilizado em tempo de execução, dessa forma os artefatos resultantes do build são passados pro estágio final, e os compiladores e outras ferramentas do build são instalados apenas no estágio de build, resultando uma imagem final menor.
|
|
Dockerfile-v2
Dessa vez optamos por usar as imagens oficiais do python, então não precisamos instalar o python em si e os compiladores que precisamos em tempo de build. Agora o grande pulo do gato do multi-stage, as imagens de build e runtime são diferentes, a de build (python:3.13) têm 1.1GB e a de runtime (python:3.13-slim) aproximadamente 10% do seu tamanho com 120MB.
Outra mudança na imagem base, até então estávamos usando a release bullseye do Debian, mas consultando o endoflife.date podemos ver que essa release perdeu suporte principal, agora com a imagem python a tag 3.13 aponta para a release corrente do stable do Debian. Além de usarmos a variante slim da imagem do debian, sempre que possível opte por essa opção.
Uma dica valiosa para sempre manter suas dependências atualizadas é utilizar o dependabot ou similares como o renovate, que periodicamente atualiza as dependências automaticamente abrindo um pull request com a atualização.
Como ficou o tamanho? Essa versão ficou com 130 MB, um quarto da nossa primeira versão.
Fazendo nosso app em Golang
Até agora estávamos usando Python no nosso app, porém para o próximo conceito de imagens distroless, usar uma linguagem compilada, em particular Go, pois ele permite a compilação estática, ou seja toda a aplicação fica “empacotada” em apenas um binário, sem precisar de outros arquivos/pacotes para que a nossa app funcione.
|
|
app.go
Nosso app continua igual à versão em Python, mas agora temos um endpoint a mais que faz um request externo para o site wttr.in obter a previsão do tempo.
E como fica o nosso Dockerfile? Como Golang é uma linguagem compilada vamos mostrar nesse exemplo a funcionalidade do multi-stage ao extremo: Criando um container com apenas o binário da nossa app.
|
|
golang/Dockerfile-scratch
Como podemos ver, compilamos nosso app no estágio de build, usando uma imagem que contém os compiladores e outras ferramentas para gerar o nosso binário, e como estamos compilando estaticamente (usando opção CGO_ENABLED=0) apenas o nosso binário é necessário para a aplicação funcionar. E depois do estágio de build, o estágio final não está utilizando nenhuma imagem base (SCRATCH), ou seja, nossa imagem final vai conter apenas o binário do nosso app.
Para quem ficou curioso, essa imagem tem apenas 6 MB, ao passo que a imagem base de build (golang:1.25) tem 850MB.
Quem quiser se aprofundar em diminuir o tamanho do binário ainda mais dá pra usar o upx (github.com/upx/upx) que faz compressão de executáveis, que reduziu o tamanho para ~2MB. Porém, acho que não vale a pena adotar essa abordagem, pois o Docker já faz essa compressão quando faz o download das imagens, então usar o upx só vai fazer seu app demorar mais para inicializar.
Se subirmos o container, vemos que os endpoints “hello world” funcionam, mas o endpoint de previsão de tempo não, com o erro: “tls: failed to verify certificate: x509: certificate signed by unknown authority”. Não temos a cadeia de certificados
Essa foi a nossa deixa para falarmos do conceito de imagens distroless.
Imagens distroless (github.com/GoogleContainerTools/distroless) foram introduzidos pela Google, e a ideia é que as imagens base tem o mínimo necessário para rodar a aplicação, sem ter uma distribuição base como o debian por trás, ou seja não tem o apt para instalar pacotes ou shell, etc …
Quem tiver interesse no repositório oficial, tem exemplos para várias linguagens. O que é chato dessas imagens é que caso você precise adicionar algum pacote que a imagem base não tem você precisa aprender o sistema de build pouco intuitivo chamado Bazel.
No nosso caso a imagem base distroless é a gcr.io/distroless/static, ela tem apenas 2MB. Quais pacotes ela tem? Como não temos o apt, não vamos conseguir facilmente listar os pacotes.
Para resolver esse problema de identificar os pacotes vamos introduzir um conceito do Software Bill of materials (SBOM), é um “inventário” identificando todos os componentes presentes em um determinado artefato de software. Ele tem alguns formatos padrão, o mais difundido é o System Package Data Exchange (SPDX) , tipicamente representado em um arquivo json. Nesse arquivo teremos as versões de todos os componentes, suas licenças e por fim os relacionamentos (como dependências).
É uma boa prática gerar o SBOM junto ao build da imagem Docker, pois isso facilita demais auditorias de segurança, por exemplo, quando identificada uma vulnerabilidade em uma biblioteca que utilizamos, conseguimos facilmente encontrar se de fato estamos seguros ou onde precisamos atuar atualizando um pacote para uma versão não vulnerável.
Vamos gerar o nosso SBOM utilizando o syft (github.com/anchore/syft), nossa imagem tem os seguintes pacotes: a cadeia de certificados que precisamos e infos de fuso horários necessários para alguns apps.
Usando essa imagem como base no estágio final, nosso endpoint de previsão do tempo funciona \o/

endpoint weather funcioando \o/
Debugando imagens distroless
Uma das maiores dificuldades em utilizar as imagens mínimas distroless é a de fazer o debug das aplicações, por exemplo, rodar um debugger com breakpoints para investigar algum bug.
Vamos abordar duas formas de resolver isso, a primeira é ter duas versões do Dockerfile, uma mínima utilizada em produção e uma versão “desenvolvedor” que inclui um shell, o gerenciador de pacotes como o apt e outros pacotes para esse fim.
Outra abordagem é “anexar” um container de debug junto ao container da aplicação, também chamado de container sidecar, o container de debug tem um shell e as ferramentas necessárias para fazer o debug e atua lado-a-lado da aplicação rodando.
Bora ver os pacotes da aplicação? Subi nosso app Golang com um container chamado golang, então vamos subir outro container usando o mesmo namespace de processos (pid) e na mesma rede, conforme o comando abaixo:
docker run --rm -it --privileged --pid container:golang --network container:golang nicolaka/netshoot
Observação: a imagem nicolaka/netshoot (github.com/nicolaka/netshoot) é uma imagem com diversas ferramentas de rede instaladas. Para Kubernetes a imagem bretfisher/shpod (github.com/bretfisher/shpod) é ótima com diversas ferramentas para fazer debug do cluster.
Por que o container precisa ser privilegiado? Para conseguirmos acessar os arquivos do outro arquivo, vá em /proc/1/root , e você estará no filesystem da aplicação. Onde 1 é o id do processo do app.

Antes de capturar os pacotes, bora dar um tutorial rápido de Wireshark? O wireguark é um aplicativo desktop que faz 2 coisas em uma, primeiro ele captura os pacotes passando na maquina e depois ele tem uma interface muito completa para visualizar o tráfego, vendo detalhes de cada pacote, possibilitando filtros e fazendo visualizações como diagramas de estado e estatísticas para ajudar a entender o que está acontecendo.
No nosso caso, como estamos utilizando containers, não vamos ter a interface gráfica do Wireshark, então podemos fazer de duas formas: capturar os pacotes utilizando o tcpdump (que não tem interface gráfica), salvar um arquivo pcap e visualizar no Wireshark. A outra opção é utilizar uma alternativa “terminal” do Wireshark, o termshark, com ele teremos uma experiência parecida com a original.
Para abrir o termshark precisamos especificar uma interface da qual os pacotes serão capturados, no nosso caso só temos uma interface, então não precisamos. Uma vez aberto, podemos ver que mesmo sem mandar muitos requests pro nosso app tem muitos pacotes que não são do nosso interesse, como os pacotes do protocolo ARP. Então é uma mão na roda usar filtros, podemos filtrar por IP, por porta , protocolo, etc …. Tem uma colinha dos filtros nesse link: https://medium.comhacker-toolbelt/wireshark-filters-cheat-sheet-eacdc438969c
Com o termshark aberto e filtrando apenas pacotes HTTP, no print abaixo podemos ver a aplicação rodando no terminal superior esquerdo, um request sendo feito com curl no terminal da direita e abaixo estamos rodando a versão
Fechando o parêntese do nosso tutorial 101 do wireshark, se tiver interesse em um texto aprofundado escreva pra gente :)
Para facilitar nossa vida, tem uma ferramenta que faz essa abordagem de maneira simplificada, o cdebug (github.com/iximiuz/cdebug), com uma feature adicional: fazer redirecionamento de portas não públicas (semelhante ao kubectl port-forward).
Fazendo o scan de vulnerabilidades nas imagens
Até agora o nosso intuito era diminuir o tamanho das imagens, pois dessa forma teríamos uma superfície de ataque menor, mas não medimos se de fato temos menos vulnerabilidades.
Vamos adotar o grype (github.com/anchore/grype), outra opção muito boa é o trivy (github.com/aquasecurity/trivy)
A nossa primeira versão (v1) tem 1334 CVEs :/ , das quais 6 são critical, 257 high, 894 medium, etc.
Já a nossa versão multistage (v2) tem 10x menos vulnerabilidades: com 136 CVEs , das quais 4 critical, 6 high, 36 medium.
Como pode um simples hello world ter 1300 CVEs? Em resumo, é porque imagens baseadas em Debian não atualizam por completo as versões para corrigir as CVEs, vamos falar em mais detalhes na próxima seção.
Debian: Estamos sendo justos com ele?
Vimos que a nossa imagem tem mais de mil vulnerabilidades, mas por que isso acontece? Estou de fato em risco?
A resposta é que provavelmente o problema não é tão grave quanto parece, só que a maneira que os scanners funcionam não bate bem da maneira que o Debian opera. O Debian quando tem sua versão stable declarada todos os pacotes são congelados na versão que estão e são feitos diversos testes para que tudo funcione até que seja lançada ao público a release.
Quando bugs são encontrados, de segurança ou não, o Debian muitas vezes simplesmente aplica a mudança (patch) que corrige o problema em cima da versão que saiu no stable.
Ao passo que os scanners de segurança funcionam identificando os pacotes e outros componentes de software (SBOM) e cruzar com um banco de dados de vulnerabilidades.
Ou seja, os scanners não estão nem aí pras mudanças que o Debian aplicou.
Por exemplo, nossa primeira versão de ambos o trivy e grype reportaram a CVE-2023-23914 (HSTS ignored on multiple requests) no curl, que de fato é uma vulnerabilidade crítica. Pois bora tentar reproduzir a vulnerabilidade?
Na página da CVE (curl.se/docs/CVE-2023-23914.html), basta rodar o seguinte: curl --hsts "" https://curl.se http://curl.se, quando vulnerável, o curl vai fazer o segundo request sem https, mesmo com o site esforçando HSTS. Porém, o comando falha pois a feature de HSTS nem foi introduzida na versão do curl adotada no Debian Bullseye. Ou seja, é um falso positivo.
Um excelente blog post falando sobre essa situação é Image Vulnerability Scanning and You -- linuxserver, da linuxserver, grupo que disponibiliza imagens docker para diversas aplicações open source, no blog post eles mostram como esses resultados aparentemente alarmante de scanners de vulnerabilidades, são falsos positivos. Eles deram um exemplo de uma vulnerabilidade, a CVE-2020-16156, que só pode ser explorada caso um usuário altere configurações de maneira maliciosa, ou seja, para explorar a vulnerabilidade você já teria que estar comprometido, o que é um problema muito maior.
O scanner grype tem uma opção para apenas considerar vulnerabilidades que de fato serão corrigidas (–only-fixed), rodando essa opção na nossa v2, as 130 vulnerabilidades caem para 2, uma de severidade medium e outra unknown.
Imagens chainguard
As imagens da Chainguard são imagens produzidas pela empresa de mesmo nome que, além de serem menores, fazem um grande esforço para as imagens estarem com os pacotes atualizados e com as correções de segurança aplicadas.
Outra feature legal dessas imagens é que toda vez que um pacote é instalado o SBOM dele é automaticamente gerado, criando um melhor lastro do conteúdo da imagem.
Para quem está acostumado com imagens baseadas em debian precisa se acostumar pois as imagens do chainguard são baseadas no alpine, ou seja, ao invés do gerenciador de pacotes apt é utilizado o apk, então tem uma certa “curva de aprendizado” para se acostumar, uma dica pra encontrar os pacotes equivalentes é utilizar o apk search com cmd para procurar o pacote que contém aquele comando, por exemplo para instalar o ldd, faça: apk search cmd:ldd e o pacote associado será o posix-libc-utils.
Outra diferença entre as imagens baseadas no Debian e do Alpine é o shell, nas imagens Debian o shell adotado é o bash, enquanto no Alpine é um shell mais limitado o ash (provido pelo busybox), então se seus scripts contém “bashismos” você precisa ver o que faz mais sentido entre tornar eles interoperáveis (posix-compliant) ou instalar o bash na imagem.
Apesar de ser baseada no alpine, as imagens chainguard não utilizam a biblioteca padrão musl do alpine, mas sim a implementação mais difundida da GNU a glibc, vamos retomar essa diferença mais a frente.
Bora colocar nossa app Python para usar o Chainguard?
|
|
Dockerfile-v3
A imagem base da Chainguard é o wolfi, ela é uma imagem pequena da qual instalamos os pacotes de que precisamos.
A principal diferença é que os pacotes têm outros nomes, por exemplo, o equivalente do build-essential é o build-base, e diferentemente do Debian podemos escolher qual versão do Python vamos adotar.
Já vem com um usuário não root, então basta utilizar ele.
Quem tiver curiosidade em inspecionar a imagem com o dive, os SBOMs SPDX de cada pacote estão em /var/lib/db/sbom/.
E as CVEs? É aqui que a Chainguard brilha, nenhuma CVE reportada no Trivy ^_^, porém apesar da Chainguard ter uma postura bastante proativa para manter toda cadeia de suprimento segura e atualizada, você precisa periodicamente rebuildar as imagens para usar as versões mais atuais dos pacotes e das nossas bibliotecas de que dependemos (que permitimos que sejam atualizadas para versões compatíveis).
Quem tiver interesse em aprender mais sobre as imagens chainguard eles tem excelentes guias com passo-a-passo, exemplo e cursos em edu.chainguard.dev gostei bastante da dinâmica dos cursos, com vídeos curtos e acompanhados de um texto do que é mostrado no vídeo.
Criando imagens distroless da Chainguard com Melange
No exemplo anterior utilizamos a imagem base da Chainguard para o nosso app Python, porém ela não é uma imagem distroless, visto que ela tem um shell, o apk e etc. … A Chainguard tem imagens distroless do Python e outras linguagens e projetos, porém só podemos usar gratuitamente sua tag latest
Para termos uma imagem distroless pra chamar de nossa, vamos utilizar o sistema de build da Chainguard, o Melange.

Primeiramente precisamos empacotar nossa app python em um pacote alpine apk usando o melange, para fazer esse empacotamento teremos uma pipeline de build no melange declarada a partir do arquivo yaml abaixo:
|
|
melange.yaml
Nesse yaml a gente declara as informações do nosso pacote, como nome, versão e licença e as dependências em tempo de execução dele, no nosso caso apenas o python3.12.
Em seguida declaramos o que o nosso ambiente de build precisa: os pacotes de build, o usuário não root e os repositórios wolfi para usarmos os pacotes da chainguard (poderíamos usar o alpine se quiser).
Por fim a pipeline de build em si, ou seja uma sequência de comandos shell que builda a app e copia tudo para uma pasta em targets.destdir.
Fizemos uma pequena alteração no nosso pip install, utilizando a flag –no-compile que não gera os arquivos .pyc, dessa forma o venv fica menor. Tem um excelente blog post explicando os trade-offs dessa abordagem: Stop putting this into your Python Dockerfiles -- Aleksa Cukovic
Bora buildar nosso pacote?
Na primeira vez precisamos gerar uma chave privada para assinar o pacote:
docker run --rm -v "${PWD}":/work cgr.dev/chainguard/melange keygen
Não se preocupe com os comandos, criei um makefile no repositório que executa os comandos em sequência.
Então o build:
docker run --privileged --rm -v "${PWD}":/work cgr.dev/chainguard/melange build melange.yaml --arch amd64 --signing-key melange.rsa
Pronto! Uma vez executado, teremos nosso pacote alpine na pasta packages.
Com o nosso pacote alpine pronto, bora criar o container que faz o deploy do nosso app? É agora que surge o apko, nele declaramos o container usado em tempo de execução (runtime) contendo o pacote previamente gerado, é como se fosse um Dockerfile, mas feito de forma declarativa.
Agora o nosso apko.yaml:
|
|
apko.yaml
No nosso apko declaramos os repositórios da chainguard (ou alpine), os pacotes: no nosso caso apenas o pacote da nossa app, o usuário não root e as condições pro nosso app rodar como variaveis de ambiente e o comando entrypoint.
Para buildar nossa imagem:
docker run --rm --workdir /work -v ${PWD}:/work cgr.dev/chainguard/apko build apko.yaml hello-crud:test hello-crud-server.tar --arch host
Cuidado que a imagem não é carregada automaticamente no docker, você precisa fazer docker image load -i hello-crud-server.tar
E o resultado? A imagem ficou com 65MB \o/
Em retrospectiva a nossa primeira versão do dockerfile tinha 570MB, a versão multistage usando debian 190MB e agora quase 10x menor com 65MB, não vai embora ainda não porque vamos melhorar ainda mais.
Distroless sem o melange e apko
Na seção anterior mostrei como usar o melange e o apko para declarativamente criar imagens mínimas para sua aplicação, porém é um sistema de build dedicado que você vai ter que aprender e manter, minha intenção era mostrar que não é um bicho de sete cabeças , pelo contrário o melange e o apko são bastante amigáveis.
Porém pra quem não quiser aprender um novo sistema de build e não se importa em quebrar a cabeça fazendo um Dockerfile especializado temos essa alternativa.
A peça crítica para esse Dockerfile funcionar é que o gerenciador de pacotes apk suporta chroot, ou seja podemos instalar pacotes em outro sistema de arquivos por “fora da caixa”, sem precisarmos instalar o apk em si ou ter um shell para conseguir rodar qualquer comando durante o build.
talk is cheap, show me the code:
|
|
Dockerfile-v4
O Dockerfile tem 3 estágios: um que faz o “build” do nosso venv e da app, um que gera o ambiente chroot com as dependências de tempo de execução e o estágio final que junta os dois. Fiz dessa forma para separar a nossa “imagem base” com o runtime de python, que é desacoplado da nossa app e o build do venv que aí sim depende da nossa app, dessa forma rebuilds com cache só fazem o build o que mudou.
O grande pulo do gato é o uso das flags do apk: -R apontando para chroot, –no-commit-hooks pois os hooks não se aplicam quando usando chroots e –initdb para inicializar o database do apk, sem isso o database não será gerado e ferramentas de SBOM não funcionam.
E o resultado? O tamanho ficou muito próximo do apko (65MB), com apenas alguns KBs de diferença.
glibc vs musl: O inimigo agora é outro
Quanto comecei minha jornada com Docker e comecei a colocar meus apps Python no docker, na época pelo menos era inviável usar imagens base alpine pois não tinha os wheels pré-compilados para biblioteca padrão c musl do alpine então qualquer app que precisava do numpy o build demorava demais pra compilar, então deixei de lado as imagens alpine.
Mas agora quando tentei novamente o pypi tem os wheels pré-compilados pro musl e outras arquiteturas como o arm \o/
Porém as imagens da chainguard apesar de serem baseadas no alpine não utilizam a musl, mas sim a implementação mais difundida a glibc, tem um artigo deles bastante completo explicando o diversos aspectos por trás dessa decisão, como compatibilidade com binários dinamicamente linkados:
O dockerfile ficou igual ao dockerfile-v4, porém mudando a imagem base do wolfi para a release mais recente do alpine (alpine:3.23). E o tamanho da imagem ficou uns ~15MB menor que a do melange (50MB).
Bora fazer uma retrospectiva de como foi evoluindo o tamanho da nossa imagem Python? Podemos ver na tabela abaixo que começamos com o nosso Dockerfile v0 bem tradicional com quase 600MB e com o multistage o tamanho caiu para um quarto da v0 e metade disso usando distroless.
| Versão | Tamanho [MB] | Percentual de v0 |
|---|---|---|
| v0 | 570 | - |
| v1 (delete dos caches e índices) | 520 | 91% |
| v2 (multistage) | 130 | 22% |
| v4 (distroless chroot/melange) | 65 | 11% |
| v5 (distroless com alpine+musl) | 50 | 9% |
Observação: Omitimos a v3 pois nela o objetivo era migrar para imagens baseadas em alpine, sem o foco em otimizar seu tamanho.
Hadolint
Pra fechar o texto, a última dica é utilizar o Hadolint (github.com/hadolint/hadolint), ele dá excelentes dicas para as boas práticas de que falamos aqui, tem extensão pro VSCode e de quebra roda o outro linter excelente para shell shellcheck.net dentro das instruções RUN, então você leva dois linters dentro de um.
Conclusão
Resgatando a provocação inicial, espero a essa altura ter te convencido de que dockerfile não é tudo igual, que não posta a aplicação funcionar para termos um bom dockerfile.
O princípio que mais exploramos é tornar a imagem enxuta possível, pois além de imagens menores serem mais performáticas, elas têm uma superfície de ataque menor e dessa forma conseguimos identificar quais dependências são realmente necessárias em tempo de execução da aplicação.
Para “enxugar” as imagens vimos como o dive é uma ferramenta muito prática para dissecar camada a camada as imagens e identificar os pontos de melhoria, e o conceito de multi-stage para separar o build e o runtime.
Por fim, levando ao extremo o objetivo do multi-stage , vimos o conceito das imagens distroless e como fazer elas com o melange e o apko, porém o desafio de debugar essas imagens.
Espero que esse texto te auxilie a criar imagens Docker melhores e muito obrigado por chegar até aqui!