Skip to main content

Imagens de contêiner simplificadas com Ko

Escrito por
feature building go images

10 de outubro de 2022

0 minutos de leitura

Em um artigo anterior, escrevi sobre como — e por que — você pode querer usar a ferramenta Jib, do grupo Google Open Source, para criar imagens de contêiner para suas aplicações Java. O Jib cria imagens enxutas, baseadas em JVM e compatíveis com OCI, que seguem as práticas recomendadas sem precisar de um runtime de contêiner como o Docker, além de eliminar a necessidade de escrever e manter Dockerfiles. Mas e se você estiver criando aplicações em Go? Existe outra ferramenta de código aberto para Go que funciona de maneira semelhante: o Ko.

Observação: Durante a elaboração deste artigo, o projeto Ko foi migrado de um repositório do GitHub pertencente ao Google para sua própria organização de nível superior, “ko-build”. Atualizamos o texto desta publicação para refletir essa mudança, mas referências a “Google Ko” ainda podem ser encontradas online e em nomes de módulos por algum tempo. Pode ter certeza: é o mesmo projeto. Parabéns à equipe do Ko pelo sucesso que tornou essa migração possível!

Neste artigo, vamos ver como usar o Ko para criar imagens de contêiner sem Dockerfiles, gerar SBOMs e integrar com o Kubernetes.

O que é o Ko?

Ko é uma ferramenta de linha de comando distribuída em um único binário, projetada para ser usada no seu processo de desenvolvimento no lugar em que você executa o compilador go hoje. Além de compilar sua aplicação, ela também gera uma imagem de contêiner ultraleve com a aplicação instalada. Assim como o Jib, o Ko envia a imagem para um registry ou a coloca no cache local de imagens do Docker, conforme a configuração e a forma de execução.

O Ko também traz alguns recursos adicionais para a criação de listas de materiais de software (SBOMs) e a integração com o Kubernetes, simplificando bastante os processos iterativos de desenvolvimento e implantação.

Que problemas o Ko busca resolver?

Muitos programadores, independentemente da linguagem com que trabalham, estão começando a criar contêineres e costumam ter várias dúvidas sobre a criação de imagens, como:

  • Qual imagem base devo usar? Ela está de acordo com as políticas da minha organização?

  • Qual é a melhor forma de combinar comandos para minimizar o excesso de camadas?

  • Quanto da minha aplicação devo copiar para a imagem para executá-la?

  • Quais ferramentas preciso aprender para criar a imagem? (Docker? Buildah? BuildKit?)

  • Há anotações padrão que preciso incluir para atender aos requisitos da minha organização?

Arquitetos e líderes de aplicações também querem facilitar a adoção de governança e padrões pelas equipes, mas manter práticas uniformes pode ser um desafio quando cada equipe cria seus próprios padrões de Dockerfile de forma independente.

As equipes de segurança também se preocupam bastante com o que entra em uma imagem e com as ferramentas usadas para criá-la. Por exemplo, expor o mecanismo do Docker — ou o docker.sock da máquina host — para um nó de build de CI pode conceder níveis elevados de acesso aos ambientes de build nesses nós.

“O daemon [do Docker]… tem muito mais recursos além de criar imagens e interagir com registries. Sem ferramentas de segurança adicionais, qualquer usuário que possa iniciar um docker build nesta máquina também pode executar um docker run para rodar qualquer comando que quiser… Além de poder executar qualquer comando, se usar esse privilégio para realizar uma ação maliciosa, será difícil descobrir quem foi o responsável.”

— Container Security, de Liz Rice, capítulo 6

Por outro lado, introduzir novas ferramentas de build pode ser complexo e aumentar a curva de aprendizado para quem cuida dos sistemas de build. O Ko busca resolver cada um desses pontos e, ao mesmo tempo, oferecer uma experiência melhor para os desenvolvedores que o utilizam.

Criando imagens com e sem o Ko

Para mostrar como o Ko resolve os desafios listados acima, vamos primeiro ver um exemplo de como ele se compara às etapas tradicionais de build com Go e Docker.

Criando a imagem sem o Ko

Neste exemplo, vamos criar a clássica aplicação do tutorial de Go: um aplicativo web “Hello World”.

1. Em um diretório vazio, crie o arquivo hello.go com o seguinte conteúdo:

package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", HelloWebServer)
http.ListenAndServe(":8080", nil)
}

func HelloWebServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}

2. Vamos compilar o binário como parte do nosso Dockerfile, já que esse é um padrão comum:

FROM golang AS build
WORKDIR /go/src
COPY . .
RUN GOOS=linux go build -ldflags "-linkmode external -extldflags -static" -a hello.go

FROM gcr.io/distroless/static:nonroot
USER nonroot:nonroot
COPY --from=build /go/src/hello .
EXPOSE 8080
CMD ["./hello"]

Este é um build em vários estágios. O primeiro estágio, chamado build, usa como base a imagem oficial golang. Nesse estágio, copiamos o conteúdo da aplicação (por enquanto, apenas o arquivo hello.go) para a imagem, em /go/src, e executamos a ferramenta go build com os parâmetros necessários para gerar um binário estático.

No segundo estágio, começamos com a imagem base static:nonroot do projeto Google Distroless, definimos o usuário padrão como nonroot, copiamos o binário hello do estágio build para o diretório raiz, definimos alguns metadados sobre a porta que queremos expor e configuramos ./hello para ser executado por padrão quando o contêiner iniciar.

Por que usar dois estágios?

É comum compilar a aplicação no Dockerfile, pois isso garante o uso da versão correta do compilador tanto na estação de trabalho do desenvolvedor quanto em qualquer build automatizado. Um erro frequente, porém, é implantar a mesma imagem usada no build. Isso representa um risco de segurança, porque você acaba incluindo na imagem implantada não só ferramentas desnecessárias, como o compilador, mas também o código-fonte da aplicação. Se um invasor conseguir explorar uma vulnerabilidade ou obter uma cópia da imagem, terá muitas informações e ferramentas à disposição para ampliar o ataque. Ao partir da imagem base distroless/static:nonroot, temos um sistema de arquivos extremamente minimalista e um usuário padrão sem privilégios de root.

3. Com o Dockerfile pronto, podemos usar o comando docker build para criar uma imagem no cache local e atribuir uma tag:

$ docker build -t localhost:5000/hello-go:1.2.3 .
[+] Building 5.6s (12/12) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 298B
 => [internal] load .dockerignore
 => => transferring context: 73B
 => [internal] load metadata for gcr.io/distroless/static:nonroot
 => [internal] load metadata for docker.io/library/golang:latest
 => [stage-1 1/2] FROM gcr.io/distroless/static:nonroot
 => CACHED [build 1/4] FROM docker.io/library/golang
 => [internal] load build context
 => => transferring context: 5.15kB
 => [build 2/4] WORKDIR /go/src
 => [build 3/4] COPY . .
 => [build 4/4] RUN GOOS=linux go build -ldflags "-linkmode external -extldflags -static" -a hello.go
 => [stage-1 2/2] COPY --from=build /go/src/hello .
 => exporting to image
 => => exporting layers
 => => writing image sha256:eb9735e61e1dec63bd557dd5c61d8789733f2f4456a0d01687816bd0d135a7bc
 => => naming to localhost:5000/hello-go:1.2.3

$ docker images
REPOSITORY               TAG    IMAGE ID      CREATED         SIZE
localhost:5000/hello-go  1.2.3  eb9735e61e1d  11 minutes ago  9.23MB

Observação: A saída pode variar um pouco dependendo da versão do Docker que você está usando e de ter ou não os aprimoramentos do BuildKit ativados.

4. Para que outras pessoas usem essa imagem, precisamos enviá-la a um repositório de registry. Neste exemplo, estou executando um repositório local na porta 5000 da minha estação de trabalho, usando a imagem registry:2 do DockerHub. (Por isso, atribuí à imagem a tag com o prefixo localhost:5000/.)

Enviar uma imagem para um registry com o Docker é simples: basta usar o comando docker push:

$ docker push localhost:5000/hello-go:1.2.3
The push refers to repository [localhost:5000/hello-go]
0ba424468cb9: Pushed
ca623f32e759: Pushed
1.2.3: digest: sha256:ddcabff499d90fdf6850ef0b7addb33db7def…

Observação: Se fosse um registry gerenciado, eu precisaria me autenticar primeiro com docker login.

5. Por fim, para executar nossa imagem, poderíamos usar docker run ou uma implantação adequada com kubectl. Para simplificar, vamos usar a primeira opção:

$ docker run --rm -d -p 8080:8080 localhost:5000/hello-go:1.2.3
Unable to find image 'localhost:5000/hello-go:1.2.3' locally
1.2.3: Pulling from hello-go
45e68f4d0d8c: Already exists
ce1a03145a01: Already exists
Digest: sha256:ddcabff499d90fdf6850ef0b7addb33db7defe4669f9af1079894e84ad407199
Status: Downloaded newer image for localhost:5000/hello-go:1.2.3
2ef3c3dc0363ad10e9bf21baa7e78c63ca4df904bcba1fdab3af7732fce3857d

e podemos testar a aplicação com um simples comando curl:

$ curl http://localhost:8080/Patch
Hello, Patch!

Se visualizássemos as etapas que acabamos de executar, elas poderiam se parecer com isto:

Diagrama que mostra o fluxo de trabalho com Go e Docker: da compilação do Go e do Dockerfile à imagem Docker, ao envio para o registro e à execução em um contêiner ou no Kubernetes.

Agora, vamos voltar ao início e ver como fazer isso com o Ko.

Criando a imagem com o Ko

1. Vamos começar com o mesmo arquivo hello.go, em um diretório vazio:

package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", HelloWebServer)
http.ListenAndServe(":8080", nil)
}

func HelloWebServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}

2. Em seguida, definimos o registry da imagem em uma variável de ambiente e executamos ko build:

$ export KO_DOCKER_REPO=localhost:5000
$ ko build hello.go
2022/09/19 14:12:37 No matching credentials were found, falling back on anonymous
2022/09/19 14:12:40 Using base gcr.io/distroless/static:nonroot@sha256:2a9e2b4fa771d31fe3346a873be845bfc2159695b9f90ca08e950497006ccc2e for hello.go
2022/09/19 14:12:40 Building hello.go for linux/amd64
2022/09/19 14:12:44 Publishing localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest
2022/09/19 14:12:44 existing blob: sha256:2952e4f69ebf4bea5cc557f73626f95649cb546424fd998481ba690a08d9db7f
2022/09/19 14:12:44 existing blob: sha256:a706af0bb599ee120bd57c0e6abca55f66fd714f9e74706d9c97a583fc79d37e
2022/09/19 14:12:44 localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:sha256-3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c.sbom: digest: sha256:7d444debc3cd2d5545e88606dc529fa7ed90b1bb581ff8ac30b0f475b68d4ec0 size: 367
2022/09/19 14:12:44 Published SBOM localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:sha256-3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c.sbom
2022/09/19 14:12:44 existing blob: sha256:5ec5232d47ab0ad088792a191b672fe6ec27db63b19daebd7322ad64a2cd8676
2022/09/19 14:12:44 existing blob: sha256:250c06f7c38e52dc77e5c7586c3e40280dc7ff9bb9007c396e06d96736cf8542
2022/09/19 14:12:45 pushed blob: sha256:2d23903e55394a021ba4936cfad8ccbec6998413164416fcae4f6bf888665fce
2022/09/19 14:12:45 pushed blob: sha256:1cd0595314a53d179ddaf68761c9f40c4d9d1bcd3f692d1c005938dac2993db6
2022/09/19 14:12:45 localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest: digest: sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c size: 750
2022/09/19 14:12:45 Published localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7@sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c
localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7@sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c

Esse comando:

  • compilou nossa aplicação em um binário estático

  • colocou o binário em uma imagem bem estruturada e

  • enviou a imagem para meu registry.

Nada disso exigiu um Dockerfile ou um mecanismo de runtime de contêiner. Além disso, você vai notar referências na saída à criação e publicação de uma SBOM; voltaremos a esse assunto mais adiante.

Observação: A tag da imagem aqui contém um hash md5 que, por padrão, corresponde ao caminho de importação da sua aplicação Go. Como neste exemplo não temos um módulo Go completo, o hash é gerado apenas a partir de hello.go. Consulte a documentação do Ko para saber mais sobre tags.

3. Agora vamos executar a imagem com docker run:

$ docker run --rm -d -p 8080:8080 localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest
Unable to find image 'localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest' locally
latest: Pulling from hello.go-7a204cfb24536a350234d9132276cae7
1cd0595314a5: Pull complete
250c06f7c38e: Pull complete
5ec5232d47ab: Pull complete
Digest: sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c
Status: Downloaded newer image for localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
70877dcd53d23c66873e8e1e5a2092b46740e699b214a545e386113e5e098d7c

... e testá-la com curl:

curl http://localhost:8080/ko
Hello, ko!

A visualização do pipeline do Ko poderia se parecer com isto:

Diagrama mostrando o KO Build enviando imagens para um registry e, em seguida, o Docker ou o Kubernetes executando um contêiner com o pull implícito da imagem

Ao deixar o Ko cuidar da criação, preparação e envio da imagem, reduzimos o número de etapas, a dependência do mecanismo de runtime de contêiner durante o build e os conhecimentos e a manutenção exigidos pelo Dockerfile.

Padrões inteligentes, personalizações determinísticas

Quem usa o Ko se beneficia das práticas recomendadas definidas coletivamente pela comunidade de código aberto. Mas e se sua organização tiver padrões próprios que não estejam alinhados a elas? É aí que as opções de linha de comando do Ko e/ou o arquivo de configuração ko.yaml são úteis.

Imagem base personalizada

Por exemplo, digamos que sua empresa tenha uma imagem específica que deve servir de base para todas as aplicações Go. Basta incluir a linha defaultBaseImage: em um arquivo ko.yaml no diretório raiz e definir essa imagem como valor.

defaultBaseImage: repo.mycorp.com/myteam/corp-approved-scratch:220923

Flags do compilador Go

Para especificar explicitamente as flags -ldflags do compilador Go, como fizemos no exemplo original, basta adicionar uma entrada builds: ao mesmo arquivo ko.yaml, com as configurações adequadas conforme especificado na documentação do Ko.

builds:
 - id: hello
   dir: .
   main: hello.go
   env:
     - GOOS=linux
     - CGO_ENABLED=0
   ldflags:
     - -extldflags "-static"
     - -linkmode external

Labels do Docker

Labels do Docker — também conhecidas como anotações OCI — são uma forma de adicionar metadados a uma imagem. Elas são úteis para registrar informações como o repositório de origem, o build de CI que a criou ou quaisquer outros dados que sua equipe queira incorporar. Para saber mais sobre labels de imagem e quando usá-las, confira minha publicação Como e quando usar Docker Labels.

O Ko permite adicionar labels pela flag de linha de comando --image-label:

$ ko build hello.go --image-label foo=bar -L –image-label org.opencontainers.image.source=https://repo.mycorp.com/superteam/hello
…
ko.local/hello.go-7a204cfb24536a350234d9132276cae7:acf1dce794131205d488ed8fe4818866ebf509a61f4cf60aa07463e2b054d97d

$ docker image inspect ko.local/hello.go-7a204cfb24536a350234d9132276cae7:latest --format '{{json .Config.Labels}}'
{"foo":"bar","org.opencontainers.image.source":"https://repo.mycorp.com/superteam/hello"}

Observação: Até a publicação deste artigo, não era possível especificar labels de imagem no arquivo de configuração .ko.yaml, mas há uma issue aberta para adicionar esse recurso.

Outros recursos interessantes do Ko

SBOMs de imagens

Como vimos acima, o Ko gera e envia automaticamente uma SBOM para sua nova imagem de contêiner. Por padrão, ela usa o formato SPDX, mas também é possível usar CycloneDX passando a flag --sbom=cyclonedx na linha de comando. Você também pode desativar esse recurso com --sbom=none. Esses e outros detalhes de configuração estão na documentação do Ko.

Uma discussão completa sobre SBOMs e seu papel na criação de uma cadeia de suprimentos segura está fora do escopo deste artigo. Para saber mais, confira Criando SBOMs para proteger a cadeia de suprimentos de código aberto.

Integração com Kubernetes

Se você precisa testar sua imagem em um cluster Kubernetes, certamente já enfrentou o trabalho de gerenciar um ciclo recorrente de etapas como:

  • Criar minha imagem

  • Enviá-la ao meu registry (ou carregá-la de outra forma no cluster Kubernetes)

  • Atualizar o YAML da minha implantação com a nova tag da imagem

  • Reimplantar usando kubectl

O Ko pode simplificar bastante esse processo, automatizando todas essas etapas em um único comando: ko apply. Com uma pequena alteração no YAML da implantação, substituindo a tag da imagem por uma tag especial no formato ko://, o ko apply executa automaticamente todas as etapas acima e implanta no cluster definido no contexto ativo da configuração do kubectl.

Por exemplo, imagine que você tenha um cluster Kubernetes de sandbox ou executado localmente e precise fazer implantações e testes iterativos enquanto desenvolve. Veja o manifesto de implantação do nosso aplicativo “hello”:

apiVersion: apps/v1
kind: Deployment
metadata:
 name: hello-server
spec:
 selector:
   matchLabels:
     run: hello-server
 replicas: 2
 template:
   metadata:
     labels:
       run: hello-server
   spec:
     containers:
       - name: hello-server
         imagePullPolicy: IfNotPresent
         image: ko://github.com/myteam/hello
         ports:
           - containerPort: 8080
             name: http

Você pode ver que a linha image: contém ko://, seguido pelo caminho do módulo Go da nossa aplicação no GitHub.

Agora, basta executar ko apply e passar o arquivo YAML com a flag -f, como faríamos com kubectl:

$ ko apply -f myfile.yaml
2022/09/27 14:35:51 Using base gcr.io/distroless/static:nonroot@sha256:2a9e2b4fa771d31fe3346a873be845bfc2159695b9f90ca08e950497006ccc2e for github.com/myteam/hello
2022/09/27 14:35:51 Building github.myteam/hello for linux/amd64
…
2022/09/27 14:35:56 Published myteam/golang-ea0a77f5cbe6ba6aea599ad83048ae7b@sha256:bd7eb1052cf5b8664890f23683930f07423aba0089150bb818a53e76ef2ebf68
deployment.apps/hello-server created

$ k get pods
NAME                                READY   STATUS    RESTARTS   AGE
pod/hello-server-5b5cc95db4-gc9rn   1/1     Running   0          10s
pod/hello-server-5b5cc95db4-rhwnp   1/1     Running   0          10s

Agora podemos testar nossa aplicação, fazer uma alteração e executar novamente ko apply -f myfile.yaml quantas vezes forem necessárias. O Ko cuida de todo o trabalho pesado para você!

Como você pode imaginar, há outros comandos do Kubernetes que provavelmente serão necessários, como:

  • delete para remover suas implantações; e

  • resolve para gerar YAML que você pode fornecer ao kubectl ou a outras ferramentas.

Acesse https://github.com/ko-build/ko#kubernetes-integration para consultar a documentação completa.

Desafios do uso do Ko

Falamos sobre todos os motivos pelos quais você pode querer dar o próximo passo e começar a usar o Ko nos seus fluxos de trabalho. Mas, antes de começar, vamos abordar alguns desafios que você pode encontrar ao usar o Ko.

Questões multiplataforma

Se você desenvolve em uma máquina que não usa Linux, a falta de um ambiente de execução de contêineres pode dificultar um pouco as coisas. Com uma imagem de build baseada em contêiner, você pode incluir as ferramentas de build, o sistema operacional e as bibliotecas dele em uma imagem-base comum e, assim, ignorar o fato de que a plataforma da sua estação de trabalho pode ser diferente daquela do ambiente de produção.

Por exemplo: a parte do Go+Docker do exemplo acima funciona em praticamente qualquer plataforma porque, independentemente de onde você fizer o build, a imagem-base golang fornece as bibliotecas glibc necessárias, baseadas em Debian, para que o compilador do Go crie o binário estático. No entanto, se você tentar executar o exemplo do Ko com as mesmas opções de ldflags em uma máquina com macOS, por exemplo, verá erros informando que faltam bibliotecas necessárias para fazer as vinculações estáticas.

Isso acontece porque o macOS/Darwin não inclui as bibliotecas necessárias para compilar de forma cruzada um binário estático para Linux — pelo menos, não até a data em que escrevi este texto. E não é só com a Apple: problemas semelhantes podem ocorrer com arquiteturas de CPU diferentes em estações de trabalho com Linux e Windows WSL.

Perda de habilidades

As habilidades e os conhecimentos necessários para criar e manter imagens de contêiner bem elaboradas são valiosos. O uso de ferramentas que abstraem ou eliminam essa necessidade pode reduzir a capacidade de lidar com problemas quando algo dá errado. Na verdade, quanto menos pessoas da equipe entendem de tecnologias de contêineres, maior é a dependência de quem tem a experiência necessária para solucionar problemas e fazer a manutenção em tempo de execução. Em casos extremos, isso pode levar ao esgotamento, à saída de profissionais e/ou a interrupções.

Complacência em relação à segurança

Se a criação de imagens de contêiner for totalmente automatizada e deixar de envolver os desenvolvedores, a conformidade com processos como a verificação de vulnerabilidades nas imagens pode ser prejudicada. Vulnerabilidades em imagens, pacotes, bibliotecas etc. desatualizados podem surgir e expor sua aplicação a ataques. Por isso, confira se essas verificações continuam sendo feitas por outros meios automatizados, como:

  • Scripts de build ou Makefiles

  • Hooks do Git

  • Etapas de build da CI

Se você ainda não verifica imagens, a Snyk oferece verificação gratuita de imagens de contêineres para encontrar vulnerabilidades e fornecer recomendações de correção simples e práticas.

Simplificação de imagens de contêiner

O Ko — assim como outras ferramentas alternativas de criação de imagens — pode simplificar bastante a criação de imagens de contêineres para suas aplicações em Go e ajudar a otimizar e padronizar a maneira como suas equipes criam imagens. Um dos maiores benefícios está nas ferramentas de CI: você deixa de precisar expor um ambiente de execução de contêineres ao ambiente de build, reduzindo bastante os vetores de ataque que exploram acessos privilegiados no SDLC. Independentemente de optar por uma ferramenta de build como o Ko, garanta que suas equipes conheçam bem as tecnologias de contêineres e as implicações de segurança dos processos e das ferramentas usados para criar imagens.

Comece a participar de desafios de Capture the Flag

Aprenda a resolver desafios de Capture the Flag assistindo sob demanda ao nosso workshop virtual introdutório.