Una actualización de Docker Engine en un servidor de producción activo
Actualizar Docker Engine en un servidor que ejecuta activamente más de cien contenedores exige evitar la improvisación. Cuando llegó el momento de pasar de Docker Engine 28.1 a 28.2 y de Docker Compose 2.35 a 2.36 en nuestro servidor de producción de Hetzner, aplicamos la misma disciplina que en una migración de base de datos: una lista de preparación por escrito, un procedimiento de reversión probado, una secuencia de ejecución precisa y una fase de verificación posterior antes de dar por terminada la ventana de mantenimiento.
A continuación explicamos en detalle cómo lo abordamos: el motivo de cada paso, los comandos que ejecutamos y las precauciones necesarias cuando el número de contenedores es tal que una política de reinicio mal configurada puede desencadenar una recuperación difícil.
Por qué esta actualización requería un procedimiento operativo formal
Docker Engine 28.2 añadió opciones de red bridge y una opción del demonio para el enrutamiento directo a contenedores, y eliminó la compatibilidad con la descarga de imágenes heredadas schema 1. El plugin de Compose pasó de 2.35.1 a 2.36.0 en la misma ventana de mantenimiento. Es una versión menor, pero conviene probar cualquier cambio en el arranque de un stack, en particular los bloques depends_on con condition: service_healthy.
En un servidor donde un solo docker-compose.yml puede gestionar doce servicios interdependientes —bases de datos, proxies inversos, contenedores de aplicaciones y workers en segundo plano—, un cambio silencioso en la resolución de dependencias es precisamente el tipo de problema que no aparece de inmediato. Aparece a las 03:00, cuando una comprobación de estado nunca se ejecuta y un contenedor dependiente entra en un bucle indefinido.
La respuesta práctica es prepararse, no actuar con cautela porque sí.
Lista de preparación
Antes de modificar un solo paquete, repasamos la siguiente lista. Cada punto tiene un motivo.
1. Inventariar todos los contenedores en ejecución y sus políticas de reinicio.
docker ps --format "table {{.Names}} {{.Status}} {{.Image}}" | sort
docker inspect $(docker ps -q) --format '{{.Name}} restart={{.HostConfig.RestartPolicy.Name}}' | sort
Esto proporciona una línea de base. Los contenedores con restart: always intentarán volver a arrancar automáticamente tras el reinicio del servicio de Docker: es lo deseable, pero solo si los archivos Compose y las imágenes correspondientes están en un estado conocido y correcto. Cualquier contenedor que esté en un bucle de fallos durante la actualización seguirá estándolo después; conviene saberlo de antemano.
2. Validar todos los archivos Compose antes de actualizar.
docker compose config --quiet 2>&1 | grep -i warning
Ejecute este comando en cada directorio que contenga un docker-compose.yml. Se utiliza la versión de Compose instalada, por lo que detecta las advertencias existentes, pero no demuestra la compatibilidad con la versión de destino; para ello, pruebe esa versión en un entorno de preproducción. Las versiones actuales de Compose advierten sobre la clave obsoleta version: al principio de los archivos Compose (se ignora), y la directiva antigua links: ya no es necesaria, puesto que los servicios de una red compartida pueden comunicarse por nombre. Las advertencias pueden convertirse en errores en versiones posteriores; conviene resolverlas ahora.
3. Comprobar el espacio disponible en disco.
df -h /var/lib/docker
docker system df
La actualización descargará un nuevo shim de containerd y sustituirá el binario de Docker Engine. El binario anterior y sus dependencias no siempre se eliminan automáticamente. En un servidor que lleva un año funcionando, docker system df suele mostrar varios gigabytes de capas de imágenes que se pueden recuperar. Elimínelas antes de la actualización, no durante ella.
docker system prune -f --volumes
Use --volumes solo si tiene la certeza de que ningún volumen anónimo contiene datos que necesite. En nuestro servidor, todos los datos persistentes se montan desde volúmenes con nombre declarados explícitamente, por lo que era seguro hacerlo.
4. Exportar las versiones actuales de Docker Engine y Compose a un archivo de referencia.
docker version > /root/docker-pre-upgrade.txt
docker compose version >> /root/docker-pre-upgrade.txt
docker ps -a >> /root/docker-pre-upgrade.txt
dpkg-query -W docker-ce docker-ce-cli containerd.io docker-compose-plugin >> /root/docker-pre-upgrade.txt
Esto lleva diez segundos y proporciona una referencia inequívoca para la reversión si algo falla y necesita identificar qué contenedores estaban en ejecución al actualizar.
5. Avisar a los sistemas que dependen del servidor.
Nuestro servidor ejecuta un stack de Supabase autoalojado y varias instancias de automatización de flujos de trabajo con n8n. Cualquier webhook que se active durante el ciclo de reinicio de un contenedor fallará y, si el emisor no vuelve a intentarlo, el evento puede perderse sin que nadie lo advierta. Configuramos una ventana de mantenimiento en nuestra herramienta de monitoreo de disponibilidad y desactivamos los webhooks entrantes de n8n durante ese período.
El plan de reversión
Un plan de reversión solo resulta útil si se ha decidido de antemano qué condición lo activa. La nuestra era sencilla: si algún contenedor que funcionaba antes de actualizar no está en ejecución diez minutos después de completar la actualización y no se puede recuperar con docker compose up -d, revertimos el binario de Docker Engine.
Revertir Docker Engine en un sistema basado en Debian implica fijar la versión anterior del paquete. Anotamos la cadena exacta de versión antes de empezar:
apt-cache policy docker-ce docker-ce-cli containerd.io docker-compose-plugin
Salida de nuestro servidor antes de la actualización:
docker-ce:
Installed: 5:28.1.1-1~debian.12~bookworm
Candidate: 5:28.2.2-1~debian.12~bookworm
docker-compose-plugin:
Installed: 2.35.1-1~debian.12~bookworm
Candidate: 2.36.0-1~debian.12~bookworm
El comando de reversión, por si fuera necesario:
CONTAINERD_PREV=$(awk '$1 == "containerd.io" {print $2}' /root/docker-pre-upgrade.txt)
apt-get install -y --allow-downgrades docker-ce=5:28.1.1-1~debian.12~bookworm docker-ce-cli=5:28.1.1-1~debian.12~bookworm containerd.io="${CONTAINERD_PREV}" docker-compose-plugin=2.35.1-1~debian.12~bookworm
El comando también fija containerd.io en la versión guardada en el archivo de referencia en el paso 4 y pasa --allow-downgrades, ya que apt-get -y se interrumpe en lugar de instalar versiones anteriores de los paquetes de forma no interactiva.
Guardamos este comando en un archivo de texto en el servidor, en /root/docker-rollback.sh, con permisos de ejecución, listo para ejecutarlo sin tener que escribirlo bajo presión.
El proceso de actualización
La actualización en sí es sencilla una vez completada la preparación. El orden importa: primero actualizar el índice de paquetes, después comprobar qué se instalará y, por último, instalar.
Paso 1: actualizar el índice de apt únicamente para el repositorio de Docker.
apt-get update -o Dir::Etc::sourcelist="sources.list.d/docker.list" -o Dir::Etc::sourceparts="-" -o APT::Get::List-Cleanup="0"
Esto limita la actualización al repositorio de Docker en lugar de actualizar todas las fuentes. En un servidor de producción, una actualización involuntaria de un paquete de otra fuente durante la ventana de mantenimiento de Docker es una variable innecesaria.
Paso 2: comprobar las versiones candidatas.
apt-cache policy docker-ce docker-ce-cli containerd.io docker-compose-plugin
Compruebe que la versión candidata coincide con la prevista antes de continuar. Si aparece una versión más reciente que la probada en el entorno de preproducción, deténgase y evalúela.
Paso 3: instalar.
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
Esto detendrá el servicio de Docker, sustituirá los binarios y reiniciará el servicio. Los contenedores con restart: always volverán a arrancar al reiniciarse el servicio. El período durante el que los contenedores no están en ejecución suele ser inferior a treinta segundos en un servidor de capacidad razonable, aunque depende del tiempo que tarde containerd en inicializarse.
Observe el reinicio en una segunda terminal:
watch -n 2 'docker ps --format "table {{.Names}} {{.Status}}" | sort'
Paso 4: comprobar las versiones instaladas.
docker version
docker compose version
En nuestro servidor, la salida confirmó lo siguiente:
Docker Engine: 28.2.2
Docker Compose: v2.36.0
Docker Compose tiene un versionado independiente de Docker Engine: la versión del paquete docker-compose-plugin (aquí, 2.36.0) es la versión de Compose y es la que registra el índice de apt.
Verificación posterior a la actualización
Diez minutos después de actualizar, realizamos una verificación estructurada. No es opcional: el reinicio del servicio arrancará los contenedores, pero no garantiza que aquellos que dependen de comprobaciones de estado las hayan superado.
Comprobar que todos los contenedores están en el estado previsto.
docker ps -a --format "table {{.Names}} {{.Status}} {{.RunningFor}}" | sort
Cualquier contenedor que muestre Restarting o Exited requiere una investigación inmediata. En nuestro servidor, un contenedor —un servicio de analítica de Supabase— mostraba Restarting (1). Ya sufría un bucle de fallos de baja frecuencia antes de la actualización; esta no lo causó, pero lo hizo más visible en la revisión posterior. Este es precisamente el tipo de problema previo que el inventario inicial permite detectar.
Comprobar que se superan las comprobaciones de estado.
docker inspect $(docker ps -q) --format '{{.Name}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' | grep -v "health=none" | sort
Los contenedores sin comprobaciones de estado explícitas no tienen un estado de salud; según la versión de Docker, un {{.State.Health.Status}} sin comprobación muestra <no value> o interrumpe todo el comando con un error de plantilla. Por eso la plantilla comprueba primero .State.Health, y grep descarta los contenedores que no lo tienen. Los que importan son aquellos de los que dependen otros contenedores mediante condition: service_healthy.
Validar la configuración de Compose en todos los stacks.
for dir in /opt/*/; do
if [ -f "${dir}docker-compose.yml" ]; then
echo "--- $dir ---"
docker compose -f "${dir}docker-compose.yml" config --quiet 2>&1
fi
done
Esto detecta las directivas de los archivos Compose que la nueva versión de Compose considera advertencias o errores. En nuestro servidor, tres stacks emitieron la advertencia de obsolescencia de la clave version. Son problemas cosméticos, pero conviene resolverlos en el siguiente ciclo de mantenimiento.
Revisar los registros del servicio de Docker en busca de errores.
journalctl -u docker --since "1 hour ago" | grep -i -E "error|warn|fatal"
Una actualización correcta no genera errores en el registro del servicio. Conviene anotar las advertencias sobre configuraciones obsoletas, aunque no sea necesario actuar de inmediato.
Confirmar la conectividad de red entre contenedores.
docker network ls
docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{end}}'
El segundo comando muestra únicamente la red bridge predeterminada. Repítalo para cada red personalizada que aparezca en docker network ls para confirmar que las redes bridge personalizadas siguen teniendo los contenedores previstos después de reiniciar el servicio, y pruebe al menos una conexión real entre servicios dependientes.
Lo que observamos
La actualización se completó en menos de dos minutos. Todos los contenedores que estaban en ejecución antes de actualizar también lo estaban después. El cambio de Compose que advertimos primero fue un formato de salida de --dry-run más claro: la nueva versión genera una salida estructurada y con colores que facilita revisar qué hará realmente docker compose up antes de ejecutarlo.
El orden de las comprobaciones de estado de depends_on se comportó exactamente igual que en Compose 2.35 en nuestros stacks. Era lo previsto, puesto que habíamos validado los archivos Compose de antemano, pero confirmarlo nos dio tranquilidad.
Un hallazgo relevante: Compose no permite combinar el campo container_name con scale, porque los nombres de los contenedores deben ser únicos. Teníamos un stack que usaba ambos, un vestigio de antes de adoptar réplicas correctamente. Compose emitió un error claro al ejecutar docker compose up para ese stack. La solución fue eliminar el campo redundante container_name, un cambio de una sola línea.
Artículos relacionados
- Despliegue de aplicaciones React en producción: configuración completa de Docker con el proxy inverso Traefik
- Crear un stack de desarrollo multiinquilino con Docker
- Proxy inverso Traefik: guía completa de autoalojamiento para automatizar HTTPS y SSL
- Autoalojamiento de n8n en Hetzner Cloud: tutorial completo de configuración con Docker