Con el runner corriendo 1 job a la vez (capacity: 1), cuando 2+ pipelines comparten el mismo commit disparador, el primero en terminar deja su propio commit de GitOps en main -- los siguientes veian 'main ya no es igual a mi commit' y se saltaban su propia promocion, aunque era seguro promocionar. Visto en vivo: Frontend construyo, escaneo y firmo v1.0.119 pero el manifiesto seguia en v1.0.104 (Promocion GitOps: false). Fix en los 3 workflows: git merge-base --is-ancestor en vez de comparacion exacta de SHA. Bump manual de frontend a v1.0.119 (la version que ya estaba lista, sin desplegar). Documentado el limite conocido de este fix en el playbook de la migracion. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011FqYP3Wf1W63Qmh7qgXAAT
8.4 KiB
Migración a platform-templates: docs-portal, frontend, medusa (2026-08)
| Ventana | 2026-08-17 |
| Servicios migrados | docs-portal, ecommerce-frontend (frontend-deploy), commerce-backend (medusa-deploy) |
| Impacto en producción | Cero downtime en las 3 — cada rollout mantuvo el pod viejo sirviendo tráfico hasta que el nuevo estuvo sano |
Qué cambió
Las 3 apps pasaron de manifiestos Kustomize sueltos y propios de cada
una a un overlay sobre el base común de
platform-templates
(k8s-manifests/base/, referenciado por URL de git remota, no una
copia local). El base agrega por default lo que ninguna de las 3 tenía
completo: readinessProbe/livenessProbe (docs-portal y frontend no
tenían ninguna) y securityContext a nivel pod y contenedor.
Ningún nombre de recurso cambió (docs-portal-deployment,
frontend-deploy, medusa-deploy, sus Services, todo se mantuvo
idéntico) — importante para no romper el tracking de Argo CD ni la
resolución DNS interna de otros servicios que dependen de esos nombres
(ej. MEDUSA_BACKEND_URL: http://medusa-svc:9000 en el frontend).
Por qué: 3 bugs reales encontrados aplicando esto en producción
Los 3 se encontraron con el pod viejo todavía sirviendo tráfico
(maxUnavailable: 0 en la estrategia de rollout, y Kubernetes excluye
automáticamente del Service cualquier pod que no pase su
readinessProbe) — cero impacto real en ningún momento, pero sí
bugs de verdad, no hipotéticos.
1. runAsNonRoot: true contra una imagen que corre como root (docs-portal)
docs-portal usa nginx:1.27-alpine sin ningún USER en su
Dockerfile — corre como root. Aplicar el default del base
(runAsNonRoot: true) rompió el pod al instante:
CreateContainerConfigError. Fix: override explícito
runAsNonRoot: false para este container puntual, documentado como
temporal hasta que docs-portal adopte
dockerfiles/static-nginx.Dockerfile
(nginxinc/nginx-unprivileged, no-root de verdad) — cambio de imagen
aparte, no hecho todavía.
2. capabilities: drop: [ALL] rompe el propio arranque de nginx (docs-portal)
Con runAsNonRoot: false ya resuelto, el pod siguió fallando:
2026/08/17 05:30:33 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
El entrypoint oficial de la imagen de nginx hace un chown() sobre su
propio directorio de cache en cada arranque — necesita CAP_CHOWN.
El base saca todas las capabilities por default; incluso corriendo
como root, sin CAP_CHOWN ese chown() falla igual. Fix: sacar la
restricción de capabilities entera para este container
(capabilities: null en el patch — es la única forma real de anular
un drop: [ALL] heredado, ver nota técnica más abajo). No es peor que
el comportamiento actual en producción, solo no está endurecido
todavía — se resuelve del todo con el mismo cambio de imagen del punto
1.
3. runAsNonRoot: true sin runAsUser numérico (frontend)
ecommerce-frontend sí corre no-root (USER nextjs en su Dockerfile)
— pero el pod nuevo falló igual:
Error: container has runAsNonRoot and image has non-numeric user (nextjs),
cannot verify user is non-root
El Dockerfile define el usuario por nombre (useradd ... nextjs),
no por UID numérico. Kubernetes no puede confirmar que ese nombre
resuelve a un UID distinto de 0 sin arrancar el contenedor, así que por
seguridad se niega a intentarlo. Fix: runAsUser: 1001 explícito
(el UID real del useradd --uid 1001 del Dockerfile).
Aplicado ya con este aprendizaje: para medusa-deploy, se confirmó
el UID real del usuario node de la imagen con docker run --rm <imagen> id node (→ 1000) antes de escribir el overlay, en vez de
asumirlo — la migración de Medusa no tuvo ningún bug, arrancó sana en
el primer intento, initContainers (wait-for-postgres, migrations)
incluidos.
Nota técnica: 2 gotchas de Kustomize (no de Kubernetes) encontrados armando esto
- Renombrar un recurso vía patch de merge normal no funciona —
aunque uses
target:para seleccionar el recurso del base por su nombre original, cambiarmetadata.namedentro del contenido del patch no lo renombra en el resultado final. Hace falta un patch JSON6902 aparte (op: replace, path: /metadata/name). - Anular un campo heredado necesita
nullexplícito — para sacar elcapabilities: {drop: [ALL]}del base (bug #2 de arriba), ni omitir el campo ni un objeto vacío{}alcanzan; solocapabilities: nullen el patch realmente lo borra.
Ambos confirmados corriendo kubectl kustomize antes de aplicar nada
al cluster — no son suposiciones.
Validación
Cada una de las 3 migraciones se confirmó con un curl real al dominio
público correspondiente después del rollout:
docs.cruzcloud.net→200shop.cruzcloud.net→200commerce.cruzcloud.net/app→200
Y con el estado del pod nuevo (kubectl get pods, sin reinicios,
1/1 Ready) antes de dar cada migración por cerrada.
Actualización (mismo día): 2 bugs más, esta vez en CI, no en K8s
El primer push real después de migrar las 3 apps (justo el commit que agregó las secciones de arriba a este playbook) expuso dos problemas más — ninguno de los dos en Kubernetes esta vez, los dos en los propios pipelines de Gitea Actions.
4. MANIFEST_FILE apuntando a una ruta que ya no existía
Mover cada deployment.yaml suelto a .../patches/deployment.yaml
(necesario para el patrón base+overlay) rompió en silencio el paso de
auto-actualización GitOps de los 3 pipelines (build.yaml,
build-medusa.yaml, deploy-docs.yaml) — cada uno tiene su propio
MANIFEST_FILE hardcodeado como variable de entorno, y los 3 seguían
apuntando a las rutas viejas. No se notó hasta el primer build real
post-migración: la imagen se construyó, escaneó y firmó bien, pero el
último paso falló con sed: can't read ...: No such file or directory,
dejando una imagen firmada y verificada sin desplegar. Fix: actualizar
las 3 rutas de MANIFEST_FILE.
Para la próxima vez que se mueva un manifiesto: grep -rn MANIFEST_FILE .gitea/workflows/ antes de asumir que un git mv dentro
de workloads/ es solo un problema del propio manifiesto — los
pipelines de CI referencian esas rutas exactas en un archivo aparte.
5. Condición de carrera en el chequeo de "promoción segura"
El runner de Gitea de este lab corre un solo job a la vez
(capacity: 1 en su config). Cuando el fix del punto 4 disparó los 3
pipelines a la vez para el mismo commit, se ejecutaron en serie. El
chequeo de "promoción segura" de cada uno compara el commit que lo
disparó contra el HEAD actual de main (if [ "$CURRENT_SHA" = "$REMOTE_SHA" ]) para evitar que una corrida vieja pise un manifiesto
más nuevo. Problema real: el primer pipeline en terminar (Medusa) dejó
su propio commit automático en main — para cuando les tocó el turno
a Frontend y Docs Portal, main ya no coincidía con el commit que los
había disparado, así que ambos se saltaron la actualización del
manifiesto (Promoción GitOps: false) aunque era perfectamente seguro
promocionar. Frontend quedó con una imagen nueva construida, escaneada
y firmada (v1.0.119) pero sin desplegar — el manifiesto seguía en
v1.0.104.
Fix aplicado en los 3 workflows: cambiar la comparación exacta de SHA
por git merge-base --is-ancestor "$CURRENT_SHA" origin/main — permite
promocionar mientras el commit disparador siga siendo ancestro de
main (tolera que otro pipeline haya commiteado en el medio), y solo
bloquea si de verdad hubo un rebase/force-push que lo descartó.
!!! warning "Límite conocido de este fix, aceptado a propósito"
is-ancestor es más permisivo que la comparación exacta en un
caso borde distinto: si 2 commits al mismo app quedaran en cola
y el más viejo terminara después del más nuevo, ahora sí
promocionaría igual — pisando la versión más nueva con la más
vieja. Con un solo mantenedor en este lab, 2 commits casi
simultáneos al mismo app es muy poco probable; se aceptó el
trade-off en vez de una solución más compleja (ignorar commits
[skip ci] del propio bot al calcular "el último commit real").
Revisar si esto deja de ser cierto (más de una persona commiteando,
o automatización que dispare builds más seguido).