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 queADD
además permite descargar contenido remoto- Cuando trabajamos con ficheros comprimidos
COPY
solo transporta el contenido mientras queADD
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.
- Guardamos el dato sensible en un fichero:
echo 'this-is-sensitive-data' > mysecret.txt
- 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
#...
- 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.
- 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
#...
- 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
#...