# 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](../plantillas/platform-templates.md) (`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`](https://gitea.cruzcloud.net/devops/platform-templates/src/branch/main/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 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, cambiar `metadata.name` dentro 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 `null` explícito** — para sacar el `capabilities: {drop: [ALL]}` del base (bug #2 de arriba), ni omitir el campo ni un objeto vacío `{}` alcanzan; solo `capabilities: null` en 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` → `200` - `shop.cruzcloud.net` → `200` - `commerce.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.