Saltar al contenido
Development

Desenredando la dependencia de Colima en los deploys de Kamal

Por Victor Da Luz
kamaldockercigithub-actionsdev-logblog-manager

Cada vez que necesitaba lanzar un deploy de producción para blog-manager, tenía que acordarme de una cosa: ¿está corriendo Colima? Si no, kamal deploy simplemente se quedaba colgado. El daemon local de Docker de mi Mac era una pieza estructural del camino de deploy a producción, y se me seguía olvidando.

La causa raíz: config/deploy.yml tenía builder.remote: ssh://kamal@blog-manager.internal. Kamal se conectaba por SSH al LXC de producción, pero necesitaba un daemon de Docker local para el contexto de buildx. Sin Colima, no había build, ni deploy.

Staging tenía el mismo problema, solo que mejor escondido. El workflow de deploy de staging corría en un runner autoalojado dentro del LXC de staging y llamaba a bin/kamal deploy -d staging, que usaba builder.remote: ssh://kamal@blog-manager-staging.internal para construir la imagen contra sí mismo. Funcionaba, pero cada merge producía una imagen recién construida que nunca se probaba en un runner de GitHub Actions, y que se volvía a construir otra vez en la máquina de desarrollo al hacer deploy a producción. “Pasó staging” era, técnicamente, una señal de confianza sin ningún significado real.

La solución es el patrón estándar build-once-deploy-many de Kamal: CI construye y sube la imagen una sola vez, y cada entorno hace pull y deploy de esa misma imagen.

El plan

Tres piezas:

  1. Un nuevo workflow build.yml que construye y sube gitea.example.net/vic/blog-manager:<sha> en cada push a main. Los PR obtienen una corrida solo de build (sin push) para detectar Dockerfiles rotos antes.
  2. Un deploy-staging.yml reescrito que se encadena a build.yml vía workflow_run y hace deploy con --skip-push --version=<sha> en vez de construir localmente.
  3. Quitar builder.remote de ambas configuraciones de deploy.

Para producción: bin/kamal deploy --skip-push --version=<sha> desde cualquier máquina. Sin Colima. Sin daemon de Docker local. Solo acceso SSH al host.

Tres cosas en las que la investigación se equivocó

La descripción original del issue decía que había que hacer push a registry.internal/blog-manager; en realidad eso es un caché pull-through de Docker Hub, no un destino de push. El registro real es la instancia de Gitea (contenedor 1009 en Proxmox).

La investigación también decía que Gitea era accesible públicamente vía Cloudflare. No lo es. dig +short gitea.example.net @1.1.1.1 devuelve 192.168.x.x, una dirección RFC1918 interna. El runner ubuntu-latest alojado por GitHub no puede alcanzarla. Lo descubrí de la peor manera, cuando el primer intento de build se agotó por timeout tratando de iniciar sesión en el registro. La solución: correr el job de build en el runner [self-hosted, blog-manager-staging], que está dentro del homelab.

La tercera cosa: kamal deploy --skip-push valida que la imagen tenga un label service que coincida con el nombre del servicio en config/deploy.yml. kamal build push lo agrega automáticamente. docker/build-push-action no. El deploy hizo pull de la imagen sin problema, y después la rechazó:

Image gitea.../blog-manager:<sha> is missing the 'service' label

Una línea en el workflow lo arregló:

labels: service=blog_manager

La trampa de dotenv, otra vez

Hay una arruga más con los secretos de Kamal en CI, la misma que ya había complicado la configuración de staging. Kamal evalúa .kamal/secrets-common usando Dotenv.parse, no un subproceso de bash. Eso significa que las variables de entorno de CI no están disponibles dentro de la expansión de parámetros ${VAR:-fallback}, solo en subprocesos $(cmd). La solución alternativa: escribir config/master.key explícitamente desde el secreto de CI antes de correr cualquier comando kamal.

La trampa del caché de capas

El plan pedía un caché type=registry,mode=max; es la respuesta “correcta” para cachear capas de Docker en un registro autoalojado. Excepto que está roto contra Gitea (gitea#28973, el registro de Gitea devuelve un error en PATCH durante el push del caché). type=gha funciona correctamente y no necesita ningún soporte del registro.

Cómo se ve el pipeline ahora

push to main
  └─ Build and push image  (self-hosted, blog-manager-staging)
       └─ Deploy staging    (self-hosted, blog-manager-staging, --skip-push)

Deploy de producción: bin/kamal deploy --skip-push --version=<sha>. Staging y producción ahora corren exactamente la misma imagen.

Lo que queda pendiente es el auto-deploy a producción con un gate de aprobación de GitHub Environments. Eso requiere un runner autoalojado en el LXC de producción (1027), la misma configuración que staging pero en el otro host. Planeado para un follow-up.

Lecturas relacionadas