Skip to main content

Boas práticas para conteinerizar aplicações Go com Docker

Escrito por
Headshot of Jekayin-Oluwa Olabemiwo

Jekayin-Oluwa Olabemiwo

blog feature docker labels

23 de março de 2022

0 minutos de leitura

Aplicações Go e contêineres foram feitos um para o outro. O tamanho reduzido dos binários de aplicações Go combina perfeitamente com as estratégias de implantação de microsserviços que o Docker e o Kubernetes oferecem. Mas essa sinergia também traz desafios. Por isso, é importante conhecer as boas práticas e os conceitos fundamentais de contêineres para evitar problemas de segurança que podem surgir facilmente nas imagens.

Neste artigo, você vai programar uma aplicação Go de exemplo e aprender a conteinerizá-la da melhor forma e executá-la com segurança.

Pré-requisitos

Para acompanhar este tutorial, você precisa ter o Docker e o Go instalados na sua máquina e conhecer o básico de ambos. Você encontra instruções introdutórias e downloads do Docker na página de orientação e configuração da documentação do Docker. Há bons tutoriais de Go na documentação oficial do Go ou, se preferir, acesse diretamente a página de instalação do Go para Linux.

Crie uma aplicação Go de exemplo

Para começar, vamos criar nossa API em Go. Primeiro, navegue até o diretório da sua máquina onde você quer armazenar a aplicação. Em seguida, crie um diretório chamado “godocker”. Nesse diretório godocker/, execute o comando a seguir para definir seu módulo Go:

go mod init godocker

Em seguida, crie um arquivo chamado “main.go” dentro do diretório “godocker”. Esse arquivo conterá o código da API. Agora, você pode adicionar a lógica para fornecer e processar a hora atual pela API.

Insira o código a seguir em godocker/main.go:

package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
)

type Time struct {
CurrentTime string `json:"current_time"`
}

func main() {
// defining router
mux := http.NewServeMux()
mux.HandleFunc("/time", getTime)

// starting server
fmt.Println("Server is running at 127.0.0.1:8080")
log.Fatal(http.ListenAndServe( "localhost:8080", mux))
}

func getTime(w http.ResponseWriter, r *http.Request) {
currentTime := []Time{
    { CurrentTime: http.TimeFormat },
}

json.NewEncoder(w).Encode(currentTime)
}

Este código fornece a hora atual pela API usando a variável TimeFormat do pacote http. Em seguida, definimos a biblioteca NewServeMux como mux para configurar o servidor HTTP. Por fim, registramos o endpoint /time para retornar a hora atual na API e codificamos a resposta no endpoint /time como JSON.

go mod init godocker

Vamos fazer um teste rápido. Execute a aplicação no terminal com o comando a seguir:

go run main.go

A saída deve ser parecida com esta:

Server is running at 127.0.0.1:8080

Agora, vamos testar a API em outra janela do terminal usando cURL. Insira o seguinte comando no terminal:

curl http://127.0.0.1:8080/time

A saída deve ser parecida com esta:

[{"current_time":"Mon, 02 Jan 2006 15:04:05 GMT"}]

Prepare o Dockerfile

Um Dockerfile contém uma série de instruções para empacotar e implantar uma aplicação como contêiner. Nesta seção, vamos criar um Dockerfile e analisar algumas instruções que podemos usar para empacotar nossa aplicação de exemplo como contêiner.

Especifique a versão da sintaxe do Docker

Primeiro, adicionamos a diretiva de sintaxe. No diretório godocker/, crie um arquivo chamado "Dockerfile" e insira o código a seguir na primeira linha:

# syntax=docker/dockerfile:1

A diretiva de sintaxe especifica o local da sintaxe do Dockerfile que usaremos para criar nosso Dockerfile. Esta linha de código define o local da sintaxe como docker/dockerfile:1, que é a versão mais recente. Antes de usar o backend Buildkit para criar o Dockerfile, o Docker verifica a versão da sintaxe.

Comente esta linha no início do arquivo. Depois da diretiva de sintaxe, a convenção é deixar uma linha em branco.

Use uma imagem base com pouco consumo de memória

Em seguida, especificamos a imagem base do Docker que queremos herdar com a instrução FROM.

Adicione o código a seguir a godocker/Dockerfil`:

FROM golang:1.17-alpine

Com essa instrução, não precisamos criar nossa própria imagem base do Docker. Em vez disso, herdamos a imagem oficial do Docker para aplicações Go, na variante Alpine Linux. A versão do Go na imagem base é 1.17. A imagem alpine é muito pequena em comparação com uma variante como a imagem ubuntu.

Agora, vamos completar o restante do Dockerfile, incluindo comentários no código para explicar brevemente a finalidade de cada linha.

Atualize godocker/Dockerfile para que contenha o seguinte código:

# syntax=docker/dockerfile:1

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

# tells Docker that the container listens on specified network ports at runtime
EXPOSE 8080

# command to be used to execute when the image is used to start a container
CMD [ "/godocker" ]

Crie a imagem

Com o Dockerfile pronto, vamos criar nossa imagem Docker a partir dele usando o comando docker build. O Docker usa o daemon do Docker para criar imagens. Usamos a opção --tag — que pode ser abreviada para -t — com o comando docker build para definir um nome personalizado para a imagem Docker.

Insira o seguinte comando no terminal:

docker build --tag godocker .

Acrescentamos . ao comando docker build para criar a imagem no diretório atual, que funciona como contexto de build. Evite usar o caminho / como contexto de build, pois isso pode transferir todo o código-fonte para o daemon do Docker.

A saída do build deve conter a linha FINISHED e ser parecida com esta:

[+] Building 6.8s (17/17) FINISHED                                                            
...
 => => writing image sha256:539bdb3e661f66d489467ef217e1b46786de9cf3c29dc9a2dd6b4e9fa763  0.0s
 => => naming to docker.io/library/godocker

Essa saída significa que a imagem Docker foi criada por completo com a tag godocker.

Para ver a lista de imagens locais, insira o seguinte comando no terminal:

docker image ls

A saída deve ser parecida com esta:

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                latest     539bdb3e661f    2 minutes ago   319MB
docker/getting-started  latest     720f449e5af2    1 hour ago      27.2MB

Na nossa saída, a imagem godocker recém-criada tem 319 MB, o que é muito para uma aplicação de API simples. Por isso, precisamos otimizar o build para criar uma imagem mais enxuta. Na próxima seção, vamos implementar o conceito de builds em vários estágios para obter um build mais leve.

Use builds em vários estágios

A abordagem de builds em vários estágios ajuda a criar imagens muito menores do que as produzidas pela abordagem de estágio único que mostramos na seção anterior. Um build em vários estágios usa uma imagem para criar componentes, que são empacotados em uma imagem menor contendo apenas o essencial para executá-los. Ao reduzir nossas imagens ao mínimo necessário para executar a aplicação, diminuímos o potencial de vulnerabilidades de segurança. Para isso, precisamos usar várias instruções FROM no Dockerfile.

Use a imagem oficial scratch

Podemos começar o build com uma imagem vazia, herdando a imagem oficial do Docker scratch. Nesta seção, vamos mostrar como usar a imagem scratch em builds de vários estágios.

Primeiro, navegue até o diretório raiz da sua aplicação. Em seguida, crie um arquivo chamado “Dockerfile.multistage” e insira o seguinte código:

# syntax=docker/dockerfile:1

##
## STEP 1 - BUILD
##

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine AS build

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

EXPOSE 8080

ENTRYPOINT ["/godocker"]

Este código especifica a imagem base herdada da imagem oficial golang:1.17-alpine, com o nome de estágio build. Em seguida, usamos outra instrução FROM para implementar o conceito de vários estágios, copiando o binário criado no primeiro estágio para a imagem vazia do segundo estágio.

Em seguida, precisamos criar uma nova imagem com o novo arquivo Dockerfile.multistage. Também precisamos dar a ela a tag “multistage”. Assim, fica mais fácil diferenciá-la da imagem criada anteriormente.

Insira o seguinte comando no terminal:

docker build -t godocker:multistage -f Dockerfile.multistage .

Depois que o build for concluído com sucesso, confira a lista de imagens inserindo o seguinte comando no terminal:

docker image ls

A saída deve ser parecida com esta:

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                multistage 192cc137f88b    9 seconds ago   6.18MB
godocker                latest     539bdb3e661f    1 hour ago      319MB

Essa saída mostra a grande diferença de tamanho entre as imagens godocker:multistage e godocker:latest. Houve uma melhora evidente: a imagem de estágio único, com 319 MB, foi reduzida para 6,1 MB na imagem de vários estágios. Como dependemos de contêineres que iniciem rapidamente, otimizações como essa são essenciais ao conteinerizar aplicações Go.

Implante o contêiner

Além de otimizar o desempenho e a eficiência, também precisamos pensar na melhor forma de implantar nossos contêineres para que sejam executados com segurança. Nesta etapa do tutorial, vamos aplicar algumas boas práticas.

Execute como usuário não root

O princípio do menor privilégio exige que limitemos o acesso aos recursos do sistema. Nossos contêineres Docker para Go são contêineres de aplicação e não precisam ser executados com privilégios de root. Por isso, devemos criar um novo usuário e grupo com acesso limitado no Dockerfile para aumentar a segurança.

Para criar um usuário não root, adicione as linhas a seguir logo depois da primeira instrução FROM em godocker/Dockerfile.multistage:

RUN useradd -u 1001 -m iamuser

Esta instrução define os argumentos USERNAME e PASSWORD com a palavra-chave ARG e, em seguida, cria um usuário com a instrução RUN adduser.

Em seguida, vamos implementar as instruções para copiar os dados do usuário do primeiro estágio e aplicá-los ao segundo.

Adicione o código em negrito no exemplo a seguir ao segundo estágio de godocker/Dockerfile.multistage, que agora deve terminar com o seguinte código:

...
##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

COPY --from=build /etc/passwd /etc/passwd

USER 1001

EXPOSE 8080

ENTRYPOINT ["/godocker"]

No Kubernetes, você pode especificar runAsuser: UID no campo securityContext. Consulte a documentação do Kubernetes para saber como definir o contexto de segurança de um Pod.

Use um sistema de arquivos raiz somente leitura

Outra forma de aumentar a segurança da aplicação é executar o contêiner com um sistema de arquivos somente leitura. Para aplicar essa restrição ao sistema de arquivos do contêiner, vamos passar a flag de somente leitura com o comando docker run.

Insira o seguinte comando no terminal:

docker run -read-only godocker

Remova ou negue recursos do Linux

Os recursos do Linux são um conjunto de privilégios que podemos ativar ou desativar ao usar o Linux. Como nosso contêiner é baseado em uma variante do Linux, também podemos usar esses recursos para aumentar a segurança da aplicação. Remover alguns deles reduz os riscos para o contêiner.

Neste tutorial, vamos remover todos os recursos, exceto setuid.

Insira o seguinte comando no terminal:

docker run --cap-drop=all --cap-add=setuid

Limite o uso de CPU e memória

O comando docker run permite definir limites de uso dos recursos da máquina host para o contêiner Docker. O Docker usa a flag --cpus.

Por exemplo, para impedir que o contêiner use mais de 50% de uma única CPU, insira o seguinte comando na CLI do Docker:

docker run -it --cpus=".5" alpine /bin/bash

Se precisar usar 2 CPUs, você pode limitar o uso com o seguinte comando na CLI do Docker:

docker run -it --cpus=2 alpine /bin/bash

Para limitar o uso de memória de um contêiner Docker a 1024 MB, você pode usar o comando docker run, assim:

docker run -m 1024m --memory-reservation=256m alpine /bin/bash

Este comando também define um limite de memória de 256 MB, aplicado quando o Docker detecta que a memória do host está ficando baixa.

Conclusão

Neste artigo, mostramos como configurar uma aplicação Go e conteinerizá-la com o Docker. Também aplicamos o conceito de builds em vários estágios para otimizar o desempenho, criamos a partir do zero usando uma imagem oficial vazia do Docker e discutimos algumas boas práticas que podem ser úteis ao criar e implantar contêineres. Estas orientações são um ponto de partida para garantir eficiência, segurança e gerenciamento de memória robustos ao conteinerizar aplicações web em Go com o Docker.

Para saber mais sobre boas práticas de segurança, acesse a central de recursos Snyk Learn.