Empacotar aplicações Go com Docker é uma habilidade essencial para desenvolvimento e deploy em ambientes modernos. Nesta aula, vamos explorar como criar imagens Docker eficientes para aplicações Go, aproveitando as características únicas da linguagem, como a compilação estática e a ausência de dependências em tempo de execução. Você aprenderá a usar multi-stage builds, imagens mínimas e boas práticas para produzir imagens pequenas, seguras e fáceis de manter.

O Docker permite isolar sua aplicação em um ambiente consistente, mas sem as técnicas certas, as imagens podem ficar grandes e vulneráveis. Com Go, temos a vantagem de gerar binários estáticos que não dependem de bibliotecas externas, o que nos permite usar imagens mínimas como scratch ou alpine. Vamos detalhar cada etapa do processo, desde a compilação até a execução final.

Multi-stage build

O multi-stage build é um recurso do Docker que permite usar múltiplas imagens em um mesmo Dockerfile, copiando apenas o que é necessário para a fase final. Isso é extremamente útil para Go, pois podemos usar uma imagem grande com todas as ferramentas de compilação na primeira etapa, e depois copiar apenas o binário compilado para uma imagem final enxuta.

Na primeira etapa, usamos a imagem oficial do Go (golang:1.22 ou similar) que contém o compilador e todas as ferramentas necessárias. Compilamos o código e geramos o binário. Na segunda etapa, copiamos esse binário para uma imagem mínima, como scratch ou alpine, e configuramos o comando de execução. Isso reduz drasticamente o tamanho da imagem final, pois não inclui o compilador, bibliotecas de desenvolvimento ou outros arquivos desnecessários.

Vamos ver um exemplo básico de Dockerfile com multi-stage build:

# Etapa 1: compilação
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/meuapp cmd/main.go

# Etapa 2: imagem final
FROM scratch
COPY --from=builder /app/meuapp /meuapp
ENTRYPOINT ["/meuapp"]

Neste exemplo, a primeira etapa usa a imagem golang:1.22 para compilar o código. A segunda etapa usa scratch, que é uma imagem vazia, e copia o binário compilado. O resultado é uma imagem extremamente pequena, contendo apenas o executável e nenhum sistema operacional ou bibliotecas.

Se sua aplicação precisar de certificados SSL ou de arquivos de configuração, você pode copiá-los para a imagem final. Por exemplo, para SSL, você pode copiar os certificados CA da imagem alpine:

FROM alpine:latest AS certs
RUN apk --no-cache add ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/meuapp /meuapp
ENTRYPOINT ["/meuapp"]

Imagens mínimas

Imagens mínimas são aquelas que contêm apenas o essencial para executar sua aplicação. Para Go, as opções mais comuns são scratch e alpine. A imagem scratch é literalmente vazia, não possui shell, bibliotecas ou utilitários. Já alpine é uma distribuição Linux mínima, com cerca de 5 MB, que inclui o gerenciador de pacotes apk e alguns utilitários básicos.

Usar scratch é ideal quando seu binário Go é totalmente estático e não precisa de nenhum recurso do sistema. No entanto, se você precisar de resolução de DNS (para fazer requisições HTTP a domínios, por exemplo), é necessário incluir os certificados CA e o arquivo /etc/passwd para o usuário não-root. A imagem alpine já vem com esses arquivos, facilitando a configuração.

Aqui está um exemplo usando alpine como imagem final:

FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/meuapp cmd/main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/meuapp /usr/local/bin/meuapp
ENTRYPOINT ["meuapp"]

A imagem alpine é útil quando você precisa de um shell para depuração ou de utilitários como curl para health checks. No entanto, lembre-se de que ela adiciona alguns megabytes à imagem final, o que pode ser aceitável dependendo do contexto.

Binário estático

Go compila para binários estáticos por padrão, o que significa que o executável não depende de bibliotecas dinâmicas do sistema, como glibc. Isso é uma grande vantagem para Docker, pois podemos copiar o binário para qualquer imagem, mesmo scratch, e ele funcionará sem problemas.

No entanto, em alguns casos, especialmente ao usar o pacote net ou os/user, o Go pode usar a biblioteca cgo para acessar funcionalidades do sistema, resultando em binários dinamicamente vinculados. Para garantir um binário totalmente estático, compile com a variável de ambiente CGO_ENABLED=0. Isso desativa o cgo e usa implementações puras em Go, como o resolvedor DNS nativo.

Veja como compilar um binário estático:

CGO_ENABLED=0 go build -o meuapp cmd/main.go

No Dockerfile, você pode definir essa variável na etapa de build:

FROM golang:1.22 AS builder
ENV CGO_ENABLED=0
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/meuapp cmd/main.go

Com CGO_ENABLED=0, o binário será estático e poderá ser executado em imagens como scratch sem problemas. Teste localmente com file meuapp para verificar se está estático: a saída deve indicar statically linked.

Outra vantagem é que binários estáticos são mais fáceis de transferir e executar em qualquer ambiente, sem se preocupar com dependências do sistema. Isso torna o Docker o veículo perfeito para distribuir aplicações Go.

Boas práticas

Ao empacotar Go com Docker, algumas boas práticas ajudam a melhorar a segurança, o desempenho e a manutenção das imagens. Primeiro, sempre use uma imagem base oficial e específica, como golang:1.22 em vez de golang:latest, para garantir consistência e evitar quebras por mudanças inesperadas.

Segundo, aproveite o cache do Docker. No Dockerfile, copie os arquivos go.mod e go.sum antes do código-fonte, e execute go mod download em uma camada separada. Assim, o Docker pode reutilizar essa camada quando as dependências não mudarem, acelerando builds subsequentes.

Terceiro, execute a aplicação como um usuário não-root na imagem final. Isso aumenta a segurança, pois limita o impacto de uma possível vulnerabilidade. Em scratch, você precisa criar um usuário e grupo, ou usar alpine e o comando adduser. Veja um exemplo com alpine:

FROM alpine:latest
RUN apk --no-cache add ca-certificates && adduser -D appuser
USER appuser
COPY --from=builder /app/meuapp /usr/local/bin/meuapp
ENTRYPOINT ["meuapp"]

Quarto, minimize o número de camadas. Combine comandos relacionados em um único RUN quando possível, e use --from para copiar apenas o necessário. Isso reduz o tamanho da imagem e melhora a performance.

Quinto, use o comando docker scan ou docker scout para analisar a imagem em busca de vulnerabilidades. Imagens mínimas como scratch não têm pacotes para escanear, mas alpine pode ter vulnerabilidades em suas bibliotecas. Manter a imagem atualizada é fundamental.

Por fim, considere usar o .dockerignore para excluir arquivos desnecessários do contexto de build, como .git, node_modules (se houver) e arquivos temporários. Isso acelera o build e evita que segredos sejam copiados para a imagem.

Referências

Exercícios

  1. Crie um Dockerfile multi-stage para uma aplicação Go simples que imprime "Hello, Docker!" na saída padrão. Use golang:1.22 na primeira etapa e scratch na segunda. Certifique-se de que o binário seja estático.

    ✓ Resposta:
    FROM golang:1.22 AS builder
    ENV CGO_ENABLED=0
    WORKDIR /app
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN go build -o /app/hello .
    
    FROM scratch
    COPY --from=builder /app/hello /hello
    ENTRYPOINT ["/hello"]
    
  2. Explique a diferença entre usar scratch e alpine como imagem final para uma aplicação Go. Em que situações você escolheria uma ou outra?

    ✓ Resposta: scratch é uma imagem vazia, ideal para binários totalmente estáticos que não precisam de recursos do sistema. É a menor imagem possível, mas não possui shell, certificados CA ou utilitários. alpine é uma distribuição Linux mínima (~5 MB) que inclui um shell, gerenciador de pacotes e certificados CA, facilitando a configuração de rede e a adição de ferramentas. Escolha scratch para máxima minimalismo e segurança, mas se precisar de suporte a DNS, certificados SSL ou ferramentas de debug, use alpine.
  3. Escreva um Dockerfile que compile uma aplicação Go com CGO_ENABLED=0 e copie os certificados CA para a imagem final scratch. Use a imagem alpine como fonte dos certificados.

    ✓ Resposta:
    FROM golang:1.22 AS builder
    ENV CGO_ENABLED=0
    WORKDIR /app
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN go build -o /app/meuapp .
    
    FROM alpine:latest AS certs
    RUN apk --no-cache add ca-certificates
    
    FROM scratch
    COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
    COPY --from=builder /app/meuapp /meuapp
    ENTRYPOINT ["/meuapp"]
    
  4. Descreva três boas práticas para otimizar o build de uma imagem Docker para Go. Explique como cada uma melhora o processo.

    ✓ Resposta: 1. **Copiar go.mod e go.sum antes do código-fonte**: Isso permite que o Docker use o cache das camadas de dependências. Se as dependências não mudarem, o `go mod download` não será reexecutado, acelerando builds. 2. **Usar multi-stage build**: Separa a compilação da imagem final, reduzindo o tamanho da imagem ao não incluir ferramentas de desenvolvimento. 3. **Executar como usuário não-root**: Aumenta a segurança, limitando privilégios dentro do contêiner. 4. **Usar .dockerignore**: Exclui arquivos desnecessários do contexto de build, acelerando o envio e evitando que segredos sejam copiados.
  5. Modifique o Dockerfile do exercício 1 para executar a aplicação como um usuário não-root. Use alpine como imagem final e crie um usuário chamado appuser.

    ✓ Resposta:
    FROM golang:1.22 AS builder
    ENV CGO_ENABLED=0
    WORKDIR /app
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN go build -o /app/hello .
    
    FROM alpine:latest
    RUN apk --no-cache add ca-certificates && adduser -D appuser
    USER appuser
    COPY --from=builder /app/hello /hello
    ENTRYPOINT ["/hello"]
    

Boas práticas e observações finais

Nesta aula, você aprendeu a empacotar aplicações Go com Docker de forma eficiente. Lembre-se de sempre testar suas imagens localmente e usar ferramentas como docker build e docker run para validar. A combinação de multi-stage builds, imagens mínimas e binários estáticos resulta em imagens pequenas e seguras, ideais para produção.

Além disso, considere automatizar o processo de build com CI/CD e usar registries privados para armazenar suas imagens. O Docker é uma ferramenta poderosa, e com Go você pode aproveitar ao máximo seus recursos para criar deploys rápidos e confiáveis.