~sergio

ARTIGOS

Imaxes docker mínimas

05-09-2023

Cando se comezan a usar contenedores un sorpréndese do tamaño que teñen. Aínda que solo se engada a un contenedor unha simple aplicación «hello world» este acaba tendo varios centos de megas. Débese a que se usa como base unha imaxen dun sistema operativo completo, como pode ser Debian ou Ubuntu. A primeira solución que se ven á cabeza é usar unha base o máis pequena posible como pode ser Alpine, pero hai outra solución para cando a aplicación que queremos publicar se pode compilar.

A «maxia» está no compilado estático, nas «multi-stage builds» e na imaxen «scratch».

O compilado estático xenera executables que funcionan sin dependencias, como un artefacto completo. As «multi-stage builds» permiten crear imaxes usadas para compilar o binario e logo copialo a outra imaxen de menor tamaño. Por último, a imaxen «scratch» é a máis pequena, a usada como base inicial para crear contenedores.

Aplicación de exemplo

A modo de demostración, vamos a usar a seguinte aplicación:

package main

import (
    "fmt"
    "net/http"
)

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

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

Sin usar docker para o seguinte, podemos comprobar que se pode compilar estáticamente a aplicación e comprobalo con «ldd»:

go build -ldflags "-linkmode external -extldflags -static" -a main.go 
$ ldd main
	not a dynamic executable

Dockerfile

Este é un dockerfile multi-stage, tendo unha primeira fase para facer o compilado estático e unha segunda fase na que se crea a imaxen mínima basada na «scratch» e copiando o binario nela.

# Compilado
FROM golang:1.21.0 as builder

WORKDIR /
COPY . .
RUN go build -ldflags "-linkmode external -extldflags -static" -a main.go

# Execución
FROM scratch
COPY --from=builder /main /main

EXPOSE 8080
CMD ["./main"]
            

Nótese o «as builder» para poñerlle un alias a esta fase e logo poder facer o «--from=builder» á hora de copiar o binario.

Usando este dockerfile conseguiremos unha imaxen moi, moi reducida en tamaño, de apenas 8MB.

docker build -t exemplo:0.0.1 .
$ docker images | grep exemplo
exemplo           0.0.1       b9fc98ba8b2d   9 minutes ago   7.72MB

Contenedor e contido

Pola curiosidade, podemos ver qué é o que contén ese contenedor. Para elo, primeiro executamos a imaxen creando así un contenedor basado nela:

docker run -p 8080:8080 --name exemplo exemplo:0.0.1
E ver que funciona:
$ curl http://localhost:8080/manolo
Hello, manolo!

Así, pódese extraer o contido do contenedor:

docker export exemplo > /tmp/exemplo.tar

E verase que é moi sinxelo tendo apenas o binario e un par de cousiñas máis:

$ mkdir /tmp/foo && cd /tmp/foo && tar xvf ../exemplo.tar
$ tree .
.
├── dev
│   ├── console
│   ├── pts
│   └── shm
├── etc
│   ├── hostname
│   ├── hosts
│   ├── mtab -> /proc/mounts
│   └── resolv.conf
├── main
├── proc
└── sys

Queda, desta maneira, visto como crear imaxes docker mínimas. Non sempre vai ser posible, depende de que a aplicación se poida compilar estáticamente e hai veces que as dependencias non se deixan; para estes casos, sempre nos quedará basarnos en unha imaxen pequena como pode ser Alpine, instalándolle as dependencias necesarias, e aínda así quedará unha imaxen docker máis pequena que unha basada, por exemplo, en Ubuntu.