Clase 3 / 3
DOCKER Clase 3

Docker profesional

Limitar recursos, adelgazar imágenes con multi-stage y distroless, y dejar un contenedor listo para producción

🎬
VER VIDEO YOUTUBE - CLASE 3 Recursos, multi-stage, distroless y producción

1. Controlar los recursos de un contenedor (CPU, memoria)

Por defecto, un contenedor puede usar toda la CPU y toda la memoria de la máquina. El problema es evidente: un solo contenedor que se desmadre —una fuga de memoria, un proceso disparado— puede tumbar el servidor entero y llevarse por delante a los demás. En un servidor de verdad esto no vale, así que les ponemos límites.

Los dos que usarás casi siempre van en el docker run:

ParámetroPara qué sirve
--memory (-m)Memoria máxima que puede usar el contenedor.
--cpusNúcleos (o fracción) que puede usar.

Por ejemplo, un contenedor limitado a dos núcleos y 256 MB de RAM:

docker run -d --name web --memory 256m --cpus 2 httpd

Los dos no se comportan igual: si un contenedor se pasa de memoria, Docker lo mata (código 137); por otro lado, si se pasa de CPU, solo lo frena.

Para ver el consumo en vivo de lo que está corriendo:

docker stats

Nos muestra, contenedor a contenedor, cuánta CPU y memoria gasta en tiempo real. Es lo primero que mirarás cuando algo vaya lento.

docker stats mostrando el consumo en vivo

Existe una imagen en DockerHub llamada polinux/stress, pensada justo para forzar a un contenedor a consumir recursos. Por ejemplo:

docker run --rm -it --cpus 1 polinux/stress stress --cpu 4

Lo que estamos haciendo aquí es pedirle dos cosas contrarias: con --cpus 1 le decimos que solo puede usar un núcleo, y con --cpu 4 le pedimos que intente quemar cuatro. El primero lo aplica Docker y el segundo lo ejecuta el propio stress, ya dentro del contenedor.

Contenedor cortado por el límite de memoria (OOM)

Los mismos límites en Docker Compose

Si en vez de docker run usamos un fichero compose.yaml, los límites se declaran igual pero dentro de una clave deploy.resources.limits:

services:
  my-container:
    image: nginx
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 512m

Reservas: garantizar un mínimo

Hasta ahora hemos puesto topes. Pero también podemos hacer lo contrario: apartarle a un contenedor una cantidad de recursos que nadie le va a quitar, para que siempre tenga con qué funcionar aunque la máquina esté cargada de trabajo.

Con la memoria basta el docker run, usando --memory-reservation:

docker run -d --name my-container --memory-reservation 256m nginx

Con la CPU no se puede desde docker run; hay que pasar por Compose y añadir un bloque reservations al lado del de limits:

services:
  my-container:
    image: nginx
    deploy:
      resources:
        limits:
          cpus: '4'
          memory: 512m
        reservations:
          cpus: '1'
          memory: 256m

Cuando indicamos una reserva no estamos diciendo lo máximo que puede gastar, sino lo mínimo que siempre va a tener disponible. El mínimo sería 256 MB y el máximo que puede gastar serían 512 MB.

Cambiar los límites en caliente

Si el contenedor ya está corriendo y queremos ajustar sus límites sin recrearlo, usamos docker update:

docker update --memory 1g my-container
docker update --cpus 2 my-container
docker update cambiando límites en caliente

Si la memoria nos da error es porque tiene un límite de swap fijo y Docker exige que swap sea siempre mayor o igual que la memoria.

2. Reducir el tamaño de las imágenes con Multi-stage

Cuando construimos una imagen, muchas veces necesitamos herramientas para preparar la aplicación —compiladores, librerías de desarrollo, paquetes que solo hacen falta durante la construcción— que después, para ejecutarla, no usamos para nada. Si lo dejamos todo en la misma imagen, acabamos arrastrando cientos de megas de cosas que en producción sobran.

El multi-stage lo resuelve partiendo el Dockerfile en dos fases:

  1. En una primera fase, con todas las herramientas, construimos la aplicación.
  2. En una segunda fase, que empieza limpia y vacía, copiamos únicamente el producto ya terminado de la primera. Todo lo que hizo falta para construir se queda fuera de la imagen final.

La clave que lo hace posible es que podemos tener varios FROM en un mismo Dockerfile; cada uno abre una etapa.

FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN go build -o miapp

FROM alpine
COPY --from=build /app/miapp /miapp
CMD ["/miapp"]

La primera etapa la llamamos build con el AS — ese nombre nos hará falta enseguida. Partimos de la imagen oficial de Go, que trae todo el kit de compilación (cientos de megas), metemos nuestro código con COPY . . y lo compilamos: el resultado es el binario miapp.

El segundo FROM abre una etapa nueva y vacía, y con COPY --from=build traemos de la anterior únicamente ese binario. El compilador, el código fuente y todo lo demás se quedan atrás y se descartan. La imagen final es un alpine diminuto con un solo fichero dentro.

Construcción multi-stage de una imagen

El resultado lo vemos al mirar docker images: la misma aplicación que pesaba X ha reducido considerablemente su peso.

Comparación de tamaños con docker images

Un detalle práctico de multi-stage: no estás obligado a construir hasta el final. Con --target puedes parar en la etapa que quieras:

docker build --target build -t miapp-dev .

Con ese comando, Docker construye hasta la etapa llamada build y se detiene ahí: la imagen que obtenemos es la de esa etapa, no la del final del fichero. Del mismo Dockerfile podríamos sacar varias imágenes distintas.

3. Distroless: qué es y cuándo usar

Con alpine hemos llegado a una imagen pequeña. Se puede ir un paso más allá con distroless.

Distroless es una familia de imágenes base que publica Google (bajo gcr.io/distroless/) sin nada que no sea imprescindible para ejecutar la aplicación: no hay gestor de paquetes, ni shell, ni ls, ni cat, ni bash. Solo la aplicación y sus librerías justas. Y de ahí sus dos ventajas: son más pequeñas que alpine y, sobre todo, más seguras — si alguien externo intenta acceder dentro, se encuentra un sitio vacío, sin herramientas con las que moverse ni forma de instalar nada.

No hay una única imagen distroless, sino varias, porque cada lenguaje necesita cosas distintas: para un binario ya compilado (como el de Go de antes) vale gcr.io/distroless/static; para una app de Java hay otra que incluye su máquina virtual. Elegimos la que corresponda. Y todavía existe un paso más extremo, la imagen scratch, literalmente vacía — pero solo sirve para binarios autónomos, así que distroless suele ser lo práctico.

Tamaño de una imagen distroless

El CGO_ENABLED=0 aquí es obligatorio: distroless/static no trae librerías del sistema, así que el binario tiene que ser autónomo o no arranca.

Como vemos en el siguiente ejemplo, la imagen scratch incluso ocupa menos ya que no tiene nada.

Comparación con la imagen scratch

Ahora comprobamos los tamaños de las diferentes imágenes que hemos creado:

Comparación final de tamaños

4. Preparar un contenedor para producción

Ya sabemos limitar recursos y adelgazar la imagen. Falta el paso principal: que el contenedor se comporte como un servicio serio, profesional y seguro.

No ejecutar como root

Por defecto, el proceso dentro del contenedor corre como root. Si alguien se cuela, es root dentro del contenedor — un riesgo innecesario. En el Dockerfile podemos crear un usuario y cambiar a él con USER:

RUN useradd -r appuser
USER appuser
Contenedor ejecutándose con un usuario sin privilegios

Es importante tener en cuenta que pondremos el RUN y el USER antes del CMD.

Configuración por variables de entorno

Una imagen debe ser inmutable y reutilizable: la misma imagen tiene que valer para desarrollo, preproducción y producción. La configuración no va dentro de la imagen — se inyecta desde fuera al arrancar, con variables de entorno.

Tendremos un fichero .env donde declararemos las variables necesarias. En Compose lo enganchamos al servicio con env_file: .env, así que el fichero viaja aparte de la imagen y de nuestro código.

Esta idea es la base de los ConfigMaps y Secrets de Kubernetes.

Servicio en Compose usando env_file

Healthcheck

Que un contenedor esté "corriendo" no significa que la aplicación funcione. El proceso puede estar vivo pero la app colgada, sin responder a nadie. Docker, por defecto, solo sabe si el proceso vive, no si la aplicación responde. Un HEALTHCHECK cubre justo esa diferencia — le enseña a Docker cómo preguntarle a la app si está realmente viva. Es una instrucción más del Dockerfile, que se suele poner cerca del final, junto al CMD.

Servicio en Compose con healthcheck

La comprobación depende del tipo de servicio: a una web se le pide una página, a una base de datos se le hace un ping, y así sucesivamente:

# Web (nginx, apache, una API…)
HEALTHCHECK CMD curl -f http://localhost/ || exit 1

# BBDD (MariaDB / MySQL)
HEALTHCHECK CMD mariadb-admin ping -h localhost || exit 1

# PostgreSQL
HEALTHCHECK CMD pg_isready -U postgres || exit 1

# Monitorización (endpoint /metrics de Prometheus)
HEALTHCHECK CMD curl -f http://localhost:9090/metrics || exit 1

Con esto sabemos si el servicio está realmente vivo, no solo encendido.

Política de reinicio

Ya vimos --restart en la Clase 1; en producción es donde cobra sentido. Con --restart unless-stopped, si el contenedor se cae o reinicias el servidor, vuelve a levantarse solo. Es lo que convierte un contenedor en un servicio que no tienes que vigilar.

Además, si queremos reiniciar el contenedor bajo unas condiciones podemos hacer:

Condiciones de reinicio en Docker Compose

condition es el mismo concepto de antes. delay es la espera entre reintentos, max_attempts el máximo de intentos, y window el tiempo para confirmar que el reinicio fue un éxito.

.dockerignore

Un fichero hermano del .gitignore: lista lo que no queremos que entre en la imagen al construir. Sin él, un COPY . . se traga todo el directorio — engorda la imagen y puede colar dentro cosas que no deberían estar, como esas credenciales que justo acabamos de decir que van por fuera.

Todo junto en Compose

Así quedaría un compose.yaml con todo lo visto durante el curso:

services:
  web:
    build: .
    ports:
      - "8080:80"
    env_file: .env
    depends_on:
      - base
    networks:
      - interna
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 512M
        reservations:
          cpus: '1'
          memory: 256M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/"]
      interval: 10s
      timeout: 5s
      retries: 3

  base:
    image: postgres
    env_file: .env
    volumes:
      - datos:/var/lib/postgresql
    networks:
      - interna
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '4'
          memory: 1G
        reservations:
          cpus: '1'
          memory: 512M
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  datos:

networks:
  interna: