Migrar una aplicación existente a contenedores y Kubernetes no consiste en meterla dentro de una imagen Docker y dar el trabajo por terminado. Eso puede servir para una demo, pero no para un entorno real donde hay clientes conectando, puertos esperados, servicios internos, dependencias entre procesos, ficheros de configuración, persistencia, logs, validaciones y operación diaria.
En una migración real lo importante no es solo que la aplicación arranque. Lo importante es que siga funcionando como antes desde el punto de vista del cliente, pero con una arquitectura más limpia, mantenible y preparada para evolucionar. Ese matiz cambia completamente el enfoque del proyecto.
Esta guía resume un enfoque completo para migrar una aplicación legacy hacia contenedores y Kubernetes sin romper compatibilidad. Está planteada en fases: inventario técnico, análisis de dependencias, contenerización, adaptación a Kubernetes, publicación de servicios, validación funcional y revisión operativa.
El primer error habitual en una migración es empezar directamente por el Dockerfile. Es tentador, porque parece que así se avanza rápido, pero normalmente acaba generando problemas. Antes de contenerizar hay que entender qué tienes delante.
Una aplicación legacy puede estar compuesta por varios procesos que se arrancan manualmente, servicios que dependen de rutas locales, ficheros de configuración con IPs o puertos hardcodeados, logs escritos en ubicaciones concretas, scripts auxiliares, tareas programadas, binarios externos, conexiones a base de datos, colas, APIs internas o clientes externos que esperan un comportamiento muy concreto.
Por eso el primer paso debe ser inventariar. No de forma decorativa, sino útil para tomar decisiones técnicas.
ps aux systemctl list-units --type=service --state=running ss -lntp ss -lunp lsof -i -P -n find /opt -maxdepth 3 -type f find /etc -maxdepth 3 -type f | grep -i nombre_aplicacion crontab -l ls -lah /var/log
La idea es responder preguntas básicas: qué procesos existen, cómo arrancan, qué puertos exponen, qué rutas usan, dónde escriben logs, qué configuración necesitan, qué servicios externos consumen y qué espera el cliente cuando se conecta.
Un inventario útil para migración debe recoger al menos estos bloques:
Aplicación: - Nombre del servicio - Versión actual - Lenguaje o runtime necesario - Forma actual de arranque - Usuario del sistema utilizado - Variables de entorno necesarias Red: - Puertos TCP/UDP expuestos - Servicios internos - Servicios externos consumidos - Protocolos usados - Clientes que conectan - Restricciones de compatibilidad Persistencia: - Rutas de datos - Rutas de configuración - Rutas de logs - Ficheros temporales - Necesidad de backup Operación: - Cómo se arranca - Cómo se para - Cómo se valida - Qué logs se revisan - Qué síntomas indican fallo
Esto parece básico, pero es justo lo que evita romper cosas después. En sistemas antiguos muchas dependencias no están documentadas. Están escondidas en scripts, rutas locales, configuraciones copiadas hace años o costumbres operativas que nadie escribió.
Antes de crear imágenes conviene separar responsabilidades. Hay elementos que pertenecen a la aplicación y otros que pertenecen al entorno donde se ejecuta.
La imagen del contenedor debería contener el runtime, los binarios, librerías necesarias y una forma clara de arrancar el proceso. No debería depender de que el nodo tenga una ruta concreta, un fichero local creado a mano o una herramienta instalada fuera de la imagen.
La configuración debe ir fuera de la imagen siempre que sea posible. En Kubernetes eso normalmente significa ConfigMaps, Secrets, variables de entorno o volúmenes montados. Si cada cambio de configuración obliga a reconstruir una imagen, la operación se vuelve más rígida de lo necesario.
La primera imagen no tiene que ser perfecta. Tiene que permitir validar que la aplicación puede ejecutarse aislada. Después ya se optimiza.
FROM debian:stable-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/app
COPY app/ /opt/app/
RUN chmod +x /opt/app/start.sh
EXPOSE 8080
ENTRYPOINT ["/opt/app/start.sh"]
En este punto lo importante es validar arranque, logs y conectividad. No hay que intentar resolver Kubernetes todavía. Primero hay que comprobar que el contenedor funciona de forma local.
docker build -t legacy-app:0.1 . docker run --rm -it legacy-app:0.1 docker run --rm -p 8080:8080 legacy-app:0.1 curl -v http://localhost:8080/health
Si aquí ya hay problemas, Kubernetes no los va a arreglar. Solo los hará más difíciles de diagnosticar.
Un patrón habitual en aplicaciones legacy es encontrar configuración mezclada con el código o con rutas absolutas. Para migrar bien hay que sacar esa configuración fuera.
APP_PORT=8080 APP_LOG_LEVEL=INFO APP_CONFIG_PATH=/etc/app/config.yaml DATABASE_HOST=db DATABASE_PORT=5432 BROKER_HOST=rabbitmq BROKER_PORT=5672
Dentro del contenedor el arranque debería leer variables o ficheros montados. Eso permite usar la misma imagen en local, preproducción y producción cambiando únicamente configuración.
En entornos clásicos muchas aplicaciones escriben en ficheros bajo /var/log. En contenedores, lo más operativo es enviar logs a stdout/stderr y dejar que la plataforma los recoja.
docker logs nombre_contenedor kubectl logs deploy/legacy-app kubectl logs pod/nombre-pod
Si por compatibilidad la aplicación necesita seguir escribiendo en fichero, hay que decidir si ese log es temporal, persistente o si debe enviarse a un sistema externo. Lo que no debería ocurrir es que un log crezca sin control dentro del contenedor o en un volumen sin rotación.
Una vez que la imagen funciona, el siguiente paso es definir los manifiestos Kubernetes. Primero de forma simple. Luego se endurece.
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-app
spec:
replicas: 1
selector:
matchLabels:
app: legacy-app
template:
metadata:
labels:
app: legacy-app
spec:
containers:
- name: legacy-app
image: registry.local/legacy-app:0.1
ports:
- containerPort: 8080
env:
- name: APP_PORT
value: "8080"
El objetivo inicial es confirmar que el pod arranca, que el proceso se mantiene vivo y que los logs son visibles.
kubectl apply -f deployment.yaml kubectl get pods -o wide kubectl describe pod -l app=legacy-app kubectl logs -l app=legacy-app
Una migración seria no debería dejar credenciales dentro de la imagen ni parámetros críticos hardcodeados en el manifiesto.
apiVersion: v1 kind: ConfigMap metadata: name: legacy-app-config data: APP_PORT: "8080" APP_LOG_LEVEL: "INFO" DATABASE_HOST: "db"
apiVersion: v1 kind: Secret metadata: name: legacy-app-secret type: Opaque stringData: DATABASE_USER: appuser DATABASE_PASSWORD: cambiar_esto
Luego se consumen desde el Deployment:
envFrom:
- configMapRef:
name: legacy-app-config
- secretRef:
name: legacy-app-secret
Esto permite separar imagen, configuración y secretos. Es una de las diferencias más importantes entre “meter algo en Kubernetes” y prepararlo mínimamente para operación.
La parte más delicada suele ser la publicación. En una aplicación nueva puedes diseñar exposición desde cero. En una legacy puede haber clientes antiguos esperando IP, puerto o protocolo concreto.
La opción estándar en Kubernetes es usar un Service:
apiVersion: v1
kind: Service
metadata:
name: legacy-app
spec:
selector:
app: legacy-app
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP
Para acceso interno del clúster, esto suele ser suficiente. Para acceso externo hay varias opciones: Ingress, LoadBalancer, NodePort, hostPort o publicación mediante un proxy externo. La decisión depende del entorno.
En algunos casos el cliente antiguo no puede modificarse. Espera conectar contra una IP y un puerto fijo. Ahí aparecen decisiones incómodas.
Usar hostPort puede ser una solución de compatibilidad, aunque no debería convertirse en patrón general sin pensarlo bien.
ports: - containerPort: 9000 hostPort: 9000 protocol: TCP
Esto permite que el nodo exponga el puerto directamente, manteniendo compatibilidad con clientes que ya conectaban a ese puerto. Pero tiene implicaciones: limita scheduling, puede generar conflictos si hay varias réplicas y acopla el pod al nodo.
Por eso hay que documentar claramente por qué se usa. No es “lo ideal”, es una decisión de transición para no romper compatibilidad.
Después de publicar servicios hay que validar desde tres puntos: dentro del pod, desde otro pod y desde el cliente externo.
kubectl exec -it deploy/legacy-app -- sh ss -lntp curl -v http://127.0.0.1:8080/health
kubectl run test-shell --rm -it --image=busybox -- sh wget -S -O- http://legacy-app:8080/health
curl -v http://IP_PUBLICA:PUERTO nc -vz IP_PUBLICA PUERTO
No basta con que el pod esté en Running. Kubernetes puede decir que todo está bien y aun así el cliente no poder conectar. La validación real es de extremo a extremo.
Una aplicación migrada necesita probes. Sin ellas, Kubernetes solo sabe si el proceso existe, no si realmente está funcionando.
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 20
La readiness probe indica si el pod puede recibir tráfico. La liveness probe indica si debe reiniciarse. Confundir ambas puede generar problemas. Una liveness agresiva puede reiniciar una aplicación que simplemente estaba lenta. Una readiness mal diseñada puede sacar de servicio un pod válido.
Si la aplicación escribe datos que deben sobrevivir al reinicio del pod, hace falta persistencia. Aquí hay que ser muy cuidadoso porque Kubernetes no convierte mágicamente una aplicación stateful en distribuida.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: legacy-app-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
volumeMounts:
- name: data
mountPath: /var/lib/app
volumes:
- name: data
persistentVolumeClaim:
claimName: legacy-app-data
Antes de montar un volumen hay que entender qué escribe la aplicación: datos reales, temporales, logs, caché, sesiones, uploads o configuración modificable. No todo merece persistencia. Persistir basura también es deuda técnica.
Una migración operable necesita versionado claro de imágenes. Usar latest puede ser cómodo al principio, pero complica trazabilidad.
registry.local/legacy-app:0.1.0 registry.local/legacy-app:0.1.1 registry.local/legacy-app:1.0.0
Cada despliegue debería poder responder a tres preguntas: qué versión se está ejecutando, cuándo se desplegó y cómo volver a la anterior.
kubectl rollout history deployment/legacy-app kubectl rollout undo deployment/legacy-app kubectl describe deployment legacy-app
Una vez validado el primer despliegue, se puede definir una estrategia más segura:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
Esto permite actualizar sin dejar el servicio completamente caído, siempre que la aplicación soporte varias instancias o la transición esté bien diseñada.
En aplicaciones legacy, antes de escalar réplicas hay que validar si son realmente stateless. Si mantienen sesión local, escriben en disco local o usan locks no distribuidos, escalar puede romper más de lo que arregla.
La validación no acaba con kubectl get pods. Hay que validar desde el cliente real o desde una simulación lo más cercana posible.
1. Cliente conecta al endpoint esperado. 2. El servicio responde en el puerto esperado. 3. Los flujos principales funcionan. 4. Los logs muestran actividad correcta. 5. No aparecen errores nuevos. 6. El rendimiento es aceptable. 7. El rollback está probado.
Una migración de este tipo no se valida solo técnicamente. Se valida operativamente: ¿el cliente puede seguir trabajando?, ¿el equipo sabe diagnosticar?, ¿hay logs suficientes?, ¿se puede revertir?, ¿están documentadas las decisiones raras?
Inventario realizado Dependencias identificadas Puertos documentados Configuración externalizada Secretos separados Imagen versionada Logs visibles Healthchecks definidos Servicio publicado Cliente legacy validado Persistencia revisada Rollback probado Sitemap/documentación actualizada Procedimiento de operación escrito
Migrar a Kubernetes no es un objetivo en sí mismo. El objetivo es mejorar operación, despliegue, trazabilidad, mantenimiento y capacidad de evolución.
Si después de migrar nadie entiende cómo diagnosticar un fallo, si el cliente antiguo deja de conectar, si los logs desaparecen, si no hay rollback o si cada cambio depende de tocar cosas a mano dentro del clúster, entonces no se ha modernizado el sistema. Solo se ha cambiado el sitio donde se rompe.
La migración correcta es progresiva, validada y documentada. Primero entender. Luego aislar. Después contenerizar. Más tarde adaptar. Y solo al final optimizar.
Kubernetes puede aportar muchísimo, pero solo cuando se usa para resolver problemas reales, no para esconderlos debajo de otra capa.