Skip to main content

3.1 Creando Imágenes

Para crear una imagen siempre partiremos de un Dockerfile e iremos sumando instrucciones hasta lograr nuestra imagen ideal. Es importante remarcar la importancia de la inmutabilidad y la influencia que librerías de terceros pueden tener sobre la calidad de nuestra imagen final.

3.1.1 Elegir la imagen base

Como norma general siempre partiremos de una imagen base e iremos añadiendo nuestras capas hasta lograr el resultado final.

Elegir la imagen base es todo un reto, pudiendo ir desde lo más minimalista como Scratch hasta otras mucho más extendidas como Alpine. A la hora de construir una imagen siempre buscaremos que sea lo más compacta y simple posible. Todos los consejos del capítulo 2.2.1 se aplicarán en este caso.

info

Existe también la posibilidad de utilizar distroless como imagen base:

"Distroless" images contain only your application and its runtime dependencies. They do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution. GoogleContainerTools/distroless

Y aunque son prometedoras no han crecido en popularidad debido a que en realidad su uso tiene más sentido cuando intentamos estandarizar entornos de trabajo

Aquí un ejemplo con Node.js:

FROM node:18 AS build-env
COPY . /app
WORKDIR /app

RUN npm ci --omit=dev

FROM gcr.io/distroless/nodejs:18
COPY --from=build-env /app /app
WORKDIR /app
CMD ["hello.js"]

Preferentemente usaremos tags bien definidos para garantizar mayor inmutabilidad, aunque luego será nuestra responsabilidad ir actualizando nuestra versión a lo largo del tiempo. Esto es especialmente útil si la imagen que usamos de base no cuenta con soporte a versionamiento semántico

3.1.2 Privilegios

En el capítulo 2.1.3 hablamos de las implicaciones que tiene ser root en los contenedores, por ello debemos dar un paso extra y asegurar que nuestro Dockerfile se preocupa de ello.

Podemos hacer uso del comando groupadd para añadir nuestro usuario y luego gestionar sus permisos chmod sobre la carpeta del proyecto dentro del contenedor, para finalmente definir a nuestro usuario como el USER a cargo del contenedor.

FROM ubuntu 
RUN mkdir /app
RUN groupadd -r container && useradd -r -s /bin/false -g container container
WORKDIR /app
COPY . /app
RUN chown -R container:container /app
USER container
CMD node index.js
Información

Un ejemplo donde esto se hace es en Node.js donde las imágenes Alpine (node:18.4.0-alpine3.16) ya cuentan con un usuario node de tipo genérico. Ver ejemplo

3.1.3 Inmutabilidad en dependencias

Es común ver en imágenes de Docker que se use el comando apt-get para instalar algunas dependencias a nivel de sistema operativo. La recomendación en este caso es evitar hacer apt-get upgrade porque actualizará todas las dependencias a la versión disponible en ese momento, lo que claramente es mutable. Por lo que deberemos relegar esa tarea a la imagen de la que partimos FROM.

A la hora de instalar dependencias podemos hacer uso de binarios directamente (usando versionado) o de apt-get install teniendo en cuenta que si hacemos apt-get update este comando ya no es inmutable.

Consejo

Además apt-get update y apt-get install debería de ejecutarse en el mismo comando de RUN para evitar que apt-get update quede cacheado y no se actualice.

# From https://github.com/docker-library/golang
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \

De la misma forma si tenemos que instalar dependencias para un lenguaje específico como Golang, Python, etc. deberíamos garantizar que definimos las versiones completas para intentar mantener la inmutabilidad lo más posible.

3.1.4 Integridad de dependencias

Siempre que instalemos dependencias o descarguemos recursos de la red deberemos de verificar la integridad de los datos.

En muchos casos basta con hacer un simple checksum o validación de firma para prevenir un Man In The Middle (MITM) y similares.

3.1.5 COPY o ADD

Existen dos formas de mover ficheros y carpetas a un contenedor cuando definimos la imagen de Docker: COPY y ADD

Existen varias diferencias entre ambos comandos que a nivel de seguridad nos hace inclinar la balanza hacia COPY ya que ADD tiene ciertos comportamientos implícitos que pueden suponer vulnerabilidades potenciales si no se comprenden:

  • COPY solo permite copiar ficheros de la máquina host (llamados locales), mientras que ADD además permite descargar contenido remoto
  • Cuando trabajamos con ficheros comprimidos COPY solo transporta el contenido mientras que ADD realiza el paso extra de descomprimir el contenido.
  • Cuando usamos imágenes multi-stage (capítulo 3.1.6) no podremos pasarnos contenido entre las imágenes con ADD

En el caso de querer descargar recursos remotos es preferible hacer uso de RUN que de ADD ya que esto es una declaración explícita.

3.1.6 Multi-stage

Otra forma de alcanzar el minimalismo es hacer uso de imágenes multi-stage donde la imagen resultante es infinitamente más óptima:

FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]

En este ejemplo de la documentación oficial de Docker vemos como primero usamos la imagen de golang:1.16 para compilar la aplicación de Go y que posteriormente usamos alpine:latest para ejecutar el binario que se generó y recuperamos de la imagen anterior a la que llamamos builder.

Sería imposible compilar la aplicación de Go directamente con alpine:latest ya que no cuenta con el compilador.

3.1.7 Metadata

Los metadatos son importantes en nuestras imágenes porque nos aportan información adicional que en ocasiones es muy difícil de obtener si no se incluye de forma explícita. A su vez, estos metadatos pueden ser consumidos indistintamente por máquinas y personas, haciendo que esta información sea versátil y ayude en la toma de decisiones, especialmente en escenarios de integración continua como veremos en el capítulo 4.12.

Para incluir metadatos solo necesitamos hacer uso de la palabra reservada LABEL en nuestro Dockerfile, funciona igual que el resto de instrucciones y sigue una estructura de diccionario:

LABEL maintainer="[email protected]"

Tenemos la posibilidad de incluir mucha información relevante relacionada con la propia build. Una forma de estructurar los metadatos es hacer uso de Label Schema Convention ya que nos ofrece un marco de trabajo muy determinado.

LABEL maintainer="[email protected]"
LABEL org.label-schema.schema-version="0.1.0"
LABEL org.label-schema.description="Dockerizando un hello world!"
LABEL org.label-schema.url="https://ulisesgascon.com"
#...

3.1.8 Buildkit

Desde la versión 18.09 de Docker contamos con Buildkit que introduce una serie de mejoras que hacen la experiencia de construcción de las imágenes mucho más rápida, pero también incluye algunas mejoras en la seguridad que podemos usar en nuestros proyectos.

Habilitar

Para poder hacer uso del Buildkit tendremos que habilitarlo específicamente usando la variable de entorno DOCKER_BUILDKIT de la siguiente forma:

DOCKER_BUILDKIT=1 docker build .

Si queremos hacerlo de forma permanente podemos modificar /etc/docker/daemon.json, sin olvidarnos de reiniciar el demonio

{ "features": { "buildkit": true } }

Compartiendo secretos

Buildkit introduce la posibilidad de usar el argumento --secret a la hora de hacer la build junto con el uso de volúmenes de type=secret conseguimos así gestionar información sensible de una forma más correcta, evitando que los secretos sean guardados en la imagen final.

  1. Guardamos el dato sensible en un fichero:
echo 'this-is-sensitive-data' > mysecret.txt
  1. Referenciamos el volumen en el Dockerfile
# syntax=docker/dockerfile:1.2
FROM alpine
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret
RUN --mount=type=secret,id=secret_token,dst=/foobar cat /foobar
#...
  1. Pasamos el secreto en tiempo de build y referenciamos el id y nuestro fichero
DOCKER_BUILDKIT=1 docker build --secret id=mysecret,src=mysecret.txt .

Usando SSH de forma segura en la build

Es bastante común tener que acceder a recursos externos vía SSH para descargarnos archivos. En este ejemplo veremos como clonarnos el repositorio privado y ficticio ulisesgascon/secret-project en tiempo de build usando SSH para conectarnos, evitando exponer nuestra llave SSH.

  1. Referenciamos el volumen en el Dockerfile usando el type=ssh
# syntax=docker/dockerfile:1
FROM alpine

# Instalamos el cliente ssh y git
RUN apk add --no-cache openssh-client git

# Descargamos y guardamos la llave publica de github.com
RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts

# Clonamos el repositorio
RUN --mount=type=ssh git clone [email protected]:ulisesgascon/secret-project.git secret-project

#...
  1. Pasamos la referencia de la llave SSH usando --ssh y usamos como argumento el perfil ssh que queremos usar (default en este caso) en tiempo de build.
DOCKER_BUILDKIT=1 docker build --ssh default .

3.1.9 Health checks

Como vimos en el capítulo 2.2.5 sobre la limitación de recursos Docker tienen la capacidad de detectar si está teniendo algún problema y el contenedor ha dejado de funcionar gracias a los código de salida que genera el proceso donde se ejecuta el contenedor.

Aunque esta referencia en ocasiones es más que suficiente no lo es en escenarios más complejos donde contamos con procesos que son más resilientes al fallo absoluto como un servidor web pero que están presentando algún tipo de anomalía que le impide su correcto funcionamiento.

En el caso de servidores web, podemos encontrar en muchas ocasiones que se incluye un endpoint que nos confirma si el servidor está funcionando correctamente o teniendo problemas, gracias a los códigos de error http.

docker run --health-interval=4s \
--health-retries=8 \
--health-timeout=13s \
--health-cmd='curl -sS http://127.0.0.1:3001/health || exit 1' \    
myorg/mywebserver

Por supuesto este tipo de comprobaciones pueden añadirse al Dockerfile ofreciendo una forma adicional para saber cuando nuestra aplicación está dejando de funcionar de forma que el Demonio de Docker puede actuar en consecuencia

#...
HEALTHCHECK --interval=4s --timeout=13s --retries=8 CMD curl -sS http://127.0.0.1:3001/health || exit 1
#...