Go + Docker: Como criar as melhores imagens Docker para aplicações Golang

Rafael Pazini - Aug 14 - - Dev Community

Quem nunca teve dúvida se estava criando a imagem Docker correta para sua aplicação? Pois é, eu já tive várias vezes essa dúvida e quase sempre não sabia se estava fazendo o certo ou errado.

Então neste artigo, exploraremos práticas avançadas para criar imagens Docker eficientes e otimizadas para suas aplicações em Go. Vamos comparar diferentes abordagens, como o uso das imagens base alpine e scratch, e discutir os benefícios de cada uma, com exemplos de código e análises de desempenho.

Estrutura do projeto

Primeiro, vamos estabelecer uma estrutura de projeto típica para uma aplicação Go containerizada.

Como exemplo, estou utilizando essa aplicação que é um encurtador de URL:

url_shortener
├── cmd
│   └── main.go
├── internal
│   ├── monitoring
│   │   └── prometheus.go
│   ├── server
│   │   └── server.go
│   └── shortener
│       ├── model.go
│       ├── repository.go
│       ├── service.go
│       └── service_test.go
├── pkg
│   ├── api
│   │   └── shortener
│   │       ├── handler.go
│   │       └── handler_integration_test.go
│   └── utils
│       └── base62
│           ├── hash.go
│           └── hash_test.go
├── Dockerfile
├── Dockerfile.alpine
├── Dockerfile.golang
├── README.md
├── compose.yml
├── go.mod
├── go.sum
└── prometheus.yml
Enter fullscreen mode Exit fullscreen mode

1. Escrevendo o Dockerfile

O que pouca gente sabe, é que não precisamos de uma imagem "completa" rodando em produção. Por exemplo, um ubuntu com todos os pacotes, fontes, extensões e um SDK da nossa linguagem. Podemos simplesmente fazer o build de nosso app dentro de um sistema com SDK e logo em seguida copiar o build para uma imagem menor e otimizada que vai apenas rodar aquele build. E ai que entra o Multi-Stage.

Multi-Stage Builds

No Dockerfile, você pode definir múltiplos estágios de construção, cada um começando com uma instrução FROM. O primeiro estágio pode ser usado para compilar o código, instalar dependências, rodar testes, etc. Em estágios subsequentes, você pode copiar apenas os artefatos necessários (como binários compilados) para a imagem final, descartando tudo o que não é necessário para a execução da aplicação.

# syntax=docker/dockerfile:1

# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o url-shortener ./cmd

# Final stage
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/url-shortener .
CMD ["./url-shortener"]
Enter fullscreen mode Exit fullscreen mode

Caramba Rafa, mas o que você está fazendo com "essas duas imagens"? Primeiro uso uma imagem como builder, que é onde criamos o executável da aplicação:

Primeiro Estágio (Build stage):

  • Usa golang:1.22-alpine como imagem base.
  • Copia o código-fonte para o contêiner.
  • Baixa as dependências e compila o binário da aplicação Go.

Logo em seguida, uso uma imagem menor que irá apenas executar esse executável que geramos no primeiro passo:

Segundo Estágio (Final Stage):

  • Usa alpine:latest como imagem base, que é muito menor e só precisa ter as ferramentas necessárias para executar o binário.
  • Copia o binário compilado do estágio anterior (builder) para a nova imagem.
  • Define o comando de execução da aplicação.

Caso quisermos confirmar o que realmente acontece, dentro do Docker Desktop existe a possibilidade de analisarmos as hierarquia de uma imagem. Lá dentro podemos ver que usa:

Docker image view inside Docker Desktop App

Agora podemos também analisar o tamanho dessa imagem que acabamos de gerar, no caso a url-shortener:alpine que ficou com ~30mb:

$ docker images
REPOSITORY            TAG         IMAGE ID       CREATED              SIZE
url-shortener                 alpine          aa99d6a2c028   3 minutes ago    29.9MB

Enter fullscreen mode Exit fullscreen mode

Mas o que é o Alpine?

O Alpine é uma distribuição Linux minimalista, segura e leve, amplamente utilizada em ambientes de containers por sua eficiência e simplicidade. Ele oferece uma base sólida para construir aplicações escaláveis sem o overhead de outras distribuições Linux mais pesadas.

Algumas das vantagens de usar Alpine em nosso app estão principalmente ligadas a esses 3 pilares:

  • Flexibilidade: A imagem alpine (~5MB) é pequena e inclui um gerenciador de pacotes apk, permitindo a instalação de dependências adicionais necessárias.
  • Compatibilidade: Suporte para bibliotecas dinâmicas, tornando-a adequada para aplicações que dependem de bibliotecas externas.
  • Segurança: Regularmente atualizada, a alpine inclui patches de segurança, reduzindo o risco de vulnerabilidades.

Beleza, mas e se eu usar a versão do SDK mesmo, no caso a golang:1.22-alpine. Qual é o tamanho da minha aplicação?

REPOSITORY                    TAG             IMAGE ID       CREATED         SIZE
url-shortener                 golang-alpine   d615d75c3aff   25 minutes ago   251MB

Enter fullscreen mode Exit fullscreen mode

Bom, nesse caso chegamos a uma imagem com ~250mb... Enquanto em comparação com a alpine, puramente fomos pra ~30mb já é uma grande diferença. E da pra melhorar ainda mais?

A resposta é SIM, e vamos aos detalhes

2. Otimizações Avançadas

2.1 Scratch

O Scratch é uma imagem especial e muito minimalista no Docker. É, na verdade, a imagem de base mais simples e vazia possível que você pode usar. Ela não contém absolutamente nada: sem sistema operacional, sem bibliotecas, sem ferramentas — é literalmente um contêiner vazio.

Essa abordagem minimalista traz benefícios significativos, especialmente em termos de segurança. Ao usar Scratch, você minimiza drasticamente a superfície de ataque, já que não há pacotes ou ferramentas adicionais que possam introduzir vulnerabilidades. Seu contêiner contém apenas o essencial para a execução do aplicativo, garantindo um ambiente imutável e previsível em qualquer situação.

# syntax=docker/dockerfile:1

# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o url-shortener ./cmd

# Final stage with Scratch
FROM scratch
WORKDIR /app
COPY --from=builder /app/url-shortener .
CMD ["./url-shortener"]
Enter fullscreen mode Exit fullscreen mode

E o resultado após criar as 3 imagens, nosso ranking de menor imagem ficou assim:

  1. url-shortener:scratch - 21.1MB
  2. url-shortener:alpine - 29.9MB
  3. url-shortener:golang-alpine - 251MB

O Scratch conseguiu baixar mais alguns megas no tamanho final de nossa aplicação. Isso impacta no tamanho de arquivos transferidos pela rede, hoje algumas empresas cobram pela banda que trafegamos dentro dos servidores, e também pode influenciar em nosso Horizontal Scaling da aplicação.

Deixei os 3 Dockerfiles dentro do repositório do github caso você queira testar em seu próprio pc 😁

Quando devo usar o scratch

A resposta mais tranquila para essa é "quase sempre", uma dica de cara é: ele vai muito bem com linguagens como Go, Rust, ou C/C++. Mas qui estão alguns pontos para levar em consideração na hora de escolher se deve ou não usar o scratch:

  • Aplicações Statically Linked: Se sua aplicação não depende de bibliotecas dinâmicas e pode ser compilada de forma estática, Scratch é uma excelente escolha.
  • Segurança: Quando a segurança é uma prioridade e você quer minimizar a quantidade de software no contêiner.
  • Eficiência: Para criar imagens Docker extremamente pequenas e eficientes.

2.2 Reduzindo o Tempo de Build (Cache)

Usar o cache do Docker para otimizar o tempo de build é uma técnica essencial para evitar recompilar ou baixar dependências desnecessariamente em cada build. O Docker armazena em cache as camadas de cada etapa do Dockerfile, reutilizando-as sempre que possível.

Em projetos Go, baixar dependências com go mod download pode ser um processo demorado, especialmente se houver muitas dependências. Se você recompilar todas as dependências em cada build, isso aumenta significativamente o tempo de build.

Como arrumar isso?

Ao copiar apenas os arquivos go.mod e go.sum em uma etapa separada antes de copiar o código-fonte completo, você permite que o Docker use o cache dessa etapa se os arquivos go.mod e go.sum não tiverem mudado. Veja como fica nosso Docker file com as mudanças:

# syntax=docker/dockerfile:1

# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o url-shortener ./cmd

# Final stage
FROM scratch
WORKDIR /app
COPY --from=builder /app/url-shortener .
CMD ["./url-shortener"]
Enter fullscreen mode Exit fullscreen mode

Só de fazer esta pequena mudança já ganhamos dois pontos bem interessantes quando se trata de desenvolvimento de software, que são:
Menor tempo de build: Se não houver alterações nos arquivos go.mod e go.sum, o Docker reutiliza o cache, evitando o download das dependências e economizando tempo.
Eficiência no CI/CD: Em pipelines de integração contínua, essa técnica reduz o tempo de execução dos pipelines, aumentando a eficiência do desenvolvimento e entrega.

Então bora usar isso a nosso favor no dia-a-dia :)

2.3 Melhorando a Segurança (Docker Scout)

Docker Scout é uma ferramenta da Docker integrada ao Docker Desktop que analisa suas imagens para identificar vulnerabilidades de segurança. Ele fornece insights sobre as dependências presentes em suas imagens e alerta sobre possíveis problemas, permitindo que você tome medidas corretivas antes de implantar suas aplicações.

Por que é importante? Manter suas imagens Docker seguras é fundamental para proteger suas aplicações contra ataques e exploração de vulnerabilidades. O Docker Scout automatiza o processo de análise, tornando mais fácil manter suas imagens seguras e atualizadas.

Como funciona?

O Scout funciona praticamente com 2 passos, ele examina a imagem Docker, mapeia todas as dependências incluídas na imagem e verifica essas dependências em uma base de dados de vulnerabilidades conhecidas. Por fim, classifica as vulnerabilidades encontradas por severidade e fornece recomendações para corrigir ou mitigar os problemas.

Exemplo Prático: Usando Docker Scout no Docker Desktop

  1. Acesse o Docker Desktop: Abra o Docker Desktop e vá até a aba de "Imagens" (Images). Aqui você verá uma lista de todas as imagens que você possui localmente. Nesse exemplo usei a imagem postgres:16-alpine, pois ela contém vulnerabilidades que podemos usar como exemplo. Docker desktop images section
  2. Executar a Análise de Vulnerabilidade: Selecione uma imagem que você deseja analisar. Docker Scout exibirá automaticamente as vulnerabilidades conhecidas na imagem selecionada. Você verá um ícone de status de segurança ao lado de cada imagem, indicando se há vulnerabilidades a serem tratadas. Docker Scout image analisys
  3. Visualizar Detalhes das Vulnerabilidades: Clique na imagem para ver um painel detalhado das vulnerabilidades encontradas. Isso inclui a versão do pacote afetado, a descrição do CVE, e a severidade. Você também pode ver o histórico de varreduras e alterações, ajudando a rastrear como a segurança da imagem evoluiu ao longo do tempo. Scout CVEs
  4. Aplicar Correções: Com base nas recomendações do Docker Scout, você pode decidir atualizar pacotes, reconstruir a imagem com uma base mais segura, ou realizar outras ações de mitigação. As correções podem ser aplicadas diretamente no seu Dockerfile ou no pipeline de CI/CD. Vulnerabilities

E dessa forma, teremos uma prevenção proativa que irá identificar e corrigir vulnerabilidades antes que a imagem seja implantada em produção ajuda a proteger contra possíveis explorações de segurança e ganhamos também uma eficiência operacional, o que eu quero dizer com isso? Podemos automatizar a análise de vulnerabilidades, permitindo que a equipe de DevOps ou segurança se concentre em ações corretivas ao invés de investigações manuais.

Conclusão

Com as práticas que exploramos, você agora tem um caminho claro para criar imagens Docker para suas aplicações em Go.Usando técnicas como Multi-Stage Builds, que reduzem o tamanho da imagem, escolhendo imagens base como alpine ou scratch para melhorar a segurança e eficiência, e utilizando o Docker Scout para monitorar vulnerabilidades, você pode garantir que suas aplicações estejam rodando de forma eficiente e segura.

Essas práticas não só melhoram o desempenho técnico, mas também trazem benefícios diretos para o seu dia a dia e para a empresa, economizando tempo e recursos.

Então, da próxima vez que estiver construindo uma imagem Docker, lembre-se dessas estratégias. Aplique-as e observe os resultados. 🚀

. . . . . . . . . . . . . .
Terabox Video Player