Guía práctica · Contenedores · Kubernetes · Migración legacy

Migración de una aplicación legacy a contenedores y Kubernetes

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.

1. Punto de partida: no tocar nada sin inventario

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.

2. Inventario técnico mínimo

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ó.

3. Separar lo que es aplicación de lo que es infraestructura

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.

4. Primera contenerización

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.

5. Configuración externa

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.

6. Logs: stdout antes que ficheros locales

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.

7. Adaptación a Kubernetes

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

8. ConfigMaps y Secrets

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.

9. Publicación de servicios

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.

10. Compatibilidad con clientes legacy

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.

11. Validación de red

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.

12. Healthchecks

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.

13. Persistencia

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.

14. Imagen, registro y versionado

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

15. Despliegue controlado

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.

16. Validación funcional con cliente

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?

17. Checklist final

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

18. Conclusión

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.

Volver a guías