Migrar un servicio legacy a k3s¶
Procedimiento para migrar un servicio que hoy corre en Docker Compose /
Portainer (hosts lebron/steph) al cluster k3s. Referencia canónica:
Heimdall (2026-09-07, PRs gorkarevilla/homelab#67 y #68).
Flujo general (dos PRs)¶
- PR 1 — manifiestos + rutas + documentación. Despliega el servicio en el cluster (accesible solo interno). No toca Cloudflare.
- Migración de datos — copiar el estado del legacy al PVC (manual).
- PR 2 — Cloudflare IaC (DNS + túnel), solo si el servicio se expone externamente. Corte atómico tras migrar los datos.
- Retirar legacy — quitar el servicio del
docker-compose.ymlen el repogorkarevilla/portainer-compose.
Antes de nada, seguir el flujo de diseño/plan del repo: spec en
docs/specs/YYYY-MM-DD-<servicio>-design.md y plan en
docs/plans/YYYY-MM-DD-<servicio>.md.
Precondiciones¶
kubectlapuntando al cluster:export KUBECONFIG="$PWD/k3s/ansible/.kubeconfig".ghautenticado (para abrir PRs).terraform≥ 1.15.8 (solofmt/validatelocal; elplan/applylos hace el CI).- Acceso SSH a los hosts legacy (ver abajo).
Hosts legacy (SSH)¶
| Host | IP | Usuario | Notas |
|---|---|---|---|
lebron |
192.168.1.2 |
ramuh |
Portainer + casi todos los stacks. Usuario con sudo y grupo docker. |
~~steph~~ |
192.168.1.4 |
ramuh |
~~webs gorkarevilla.com y mirenygorka~~ → Vercel, VM parada (sep 2026). |
ssh ramuh@192.168.1.2 # lebron (key-based auth, sin password)
Los datos de los stacks viven en /opt/<stack>/<servicio>/config (propiedad
ramuh), p. ej. /opt/utils/heimdall/config. Para localizar el servicio y su
config exactos, ver su página en docs/legacy/<servicio>.md y el
docker-compose.yml correspondiente en el repo gorkarevilla/portainer-compose
(carpeta = stack).
PR 1 — manifiestos + rutas + docs¶
Estructura (referencia: Heimdall):
clusters/luka/apps/<svc>/application.yaml # Argo CD Application (CreateNamespace, autosync)
clusters/luka/config/<svc>/deployment.yaml
clusters/luka/config/<svc>/pvc.yaml # + annotation Prune=false,Delete=false
clusters/luka/config/<svc>/service.yaml
clusters/luka/config/traefik-routes/<svc>-httproute.yaml # interna (websecure) + externa (web)
clusters/luka/config/traefik-routes/<svc>-referencegrant.yaml
Reglas:
- Application:
path: clusters/luka/config/<svc>, namespace<svc>,CreateNamespace=true, autosyncprune+selfHeal. La descubreapp-of-apps. - PVC: SIEMPRE la anotación
argocd.argoproj.io/sync-options: Prune=false,Delete=false(el PVC es la única copia del estado; lo mismo quevaultwarden-cluster.yaml). - Deployment: imagen pinneada (no
latest); si el servicio guarda estado en un SQLite/archivo sobre el PVC →strategy: Recreate+replicas: 1. MantenerPUID/PGID/TZdel legacy ysecurityContext.fsGroupcoherente. - HTTPRoute: dos rutas si se expone externo — interna
<svc>.local.gorkarevilla.com(websecure, cert wildcard) y externa<svc>.gorkarevilla.com(web, HTTP plano para el túnel). La externa queda inerte hasta el PR 2. - ReferenceGrant en el ns
<svc>(Traefik → Service<svc>).
Documentación obligatoria (AGENTS.md), en el mismo PR 1:
docs/services/<svc>.md(plantilladocs/services/_template.md).- Actualizar
docs/services/index.md(tabla Aplicaciones). - Añadir al
navdemkdocs.yml(sección Aplicaciones). - Marcar
docs/legacy/<svc>.md,docs/legacy/index.md(mapa de migración) ydocs/legacy/roadmap.mdcomo migrado. - Actualizar el roadmap del
README.md.
Secrets: si el servicio legacy usa variables de entorno (se ven en la UI de Portainer, no se commitean), darlas de alta por Vaultwarden → VKS (ver
docs/runbooks/vaultwarden.mdy AGENTS.md), no Sealed Secret salvo bootstrap.
Commit con convención feat(<svc>): ... y PR contra main (squash).
Migración de datos¶
Con el servicio desplegado (PR 1 mergeado) y antes del PR 2. El legacy queda parado (no se rearranca) para evitar divergencia; la URL externa caerá brevemente hasta el corte.
# 1. Parar legacy (restart=no) + checksum + empaquetar (fuente quiesced)
ssh ramuh@192.168.1.2 'docker update --restart=no <svc> && docker stop <svc> && \
sha256sum /opt/<stack>/<svc>/config/<ruta-estado> | tee /tmp/<svc>-sha.txt && \
tar -C /opt/<stack>/<svc> -czf /tmp/<svc>-config.tgz config'
# 2. Traer a local
scp ramuh@192.168.1.2:/tmp/<svc>-config.tgz /tmp/<svc>-config.tgz
scp ramuh@192.168.1.2:/tmp/<svc>-sha.txt /tmp/<svc>-sha.txt
# 3. Escalar a 0 (sin escritor) y restaurar con pod temporal que monta el PVC
export KUBECONFIG="$PWD/k3s/ansible/.kubeconfig"
kubectl -n <svc> scale deploy <svc> --replicas=0
kubectl -n <svc> apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: <svc>-restore
namespace: <svc>
spec:
restartPolicy: Never
containers:
- name: restore
image: busybox:1.36
command: ["sleep", "600"]
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
persistentVolumeClaim:
claimName: <svc>-config
EOF
kubectl -n <svc> wait --for=condition=Ready pod/<svc>-restore --timeout=120s
kubectl -n <svc> cp /tmp/<svc>-config.tgz <svc>-restore:/tmp/<svc>-config.tgz
kubectl -n <svc> exec <svc>-restore -- tar -xzf /tmp/<svc>-config.tgz -C /
# 4. Verificar checksum EN el pod temporal (busybox trae sha256sum) y borrarlo
kubectl -n <svc> exec <svc>-restore -- sha256sum /config/<ruta-estado>
cat /tmp/<svc>-sha.txt # deben coincidir
kubectl -n <svc> delete pod <svc>-restore
# 5. Escalar a 1
kubectl -n <svc> scale deploy <svc> --replicas=1
kubectl -n <svc> rollout status deploy/<svc> --timeout=120s
El tarball contiene
config/…; al extraer en/se escribe en/config. El tar preserva el propietario del legacy (coincide conPUID/PGID). El checksum se calcula en el pod temporal debusybox, no en la imagen de la app (que puede no traersha256sum).
PR 2 — Cloudflare IaC (solo exposición externa)¶
En terraform/cloudflare/:
dns.tf: quitar el hostname delocal.proxied_hosts(deja de apuntar al túnelLebron) y añadir uncloudflare_recorddedicado<svc>→homelabtunnel.gorkarevilla.com(patrón del recursopasswords).tunnel.tf: añadir unaingress_rule<svc>.gorkarevilla.com→http://traefik.traefik.svc.cluster.local:80en la config del túnelhomelab.docs/network/cloudflare.md: quitar el hostname de la listahometunnel, añadirlo a "Otros CNAME" y a la tabla de ingress del túnelhomelab.
Commit feat(cloudflare): .... El terraform plan lo publica el CI
(cloudflare-plan.yml); el apply es automático al mergear a main
(cloudflare-apply.yml). Mergear solo después de migrar los datos.
Retirar legacy¶
En el repo gorkarevilla/portainer-compose:
- Quitar el servicio del
docker-compose.ymlde su stack. - PR contra
main(squash).
El contenedor legacy ya queda parado (docker update --restart=no <svc>); al
quitar el servicio del compose, Portainer deja de recrearlo en redeploy.
Checklist final¶
- [ ] Servicio
Synced/Healthyen Argo CD; podRunning; PVCBound. - [ ] UI responde
200interno (https://<svc>.local.gorkarevilla.com). - [ ] Datos migrados y checksum verificado.
- [ ] Externo
200(https://<svc>.gorkarevilla.com) tras el PR 2 (si aplica). - [ ] Contenedor legacy parado (
restart=no). - [ ] Servicio fuera del
docker-compose.ymllegacy (PR enportainer-compose). - [ ] Documentación actualizada (services, index, mkdocs, legacy, README).