Lab 01: Contenerizar TaskFlow
Día: lunes · Branch de referencia: main (lab01-start) → lab01-solution
Objetivo
Entender qué hace cada línea de un Dockerfile y por qué existe el patrón
multi-stage, practicándolo primero sobre un proyecto mínimo sin
dependencias (hello-api, Parte A) para tener una primera victoria sin
sorpresas. Recién después aplicar exactamente lo mismo sobre TaskFlow
(Parte B), publicar la imagen en el registry interno de OpenShift, y
desplegarla con Deployment + Service + Route.
Contexto
app/TaskFlow.Api/ todavía no tiene forma de correr en un contenedor: su
Dockerfile es de una sola etapa, y su connection string a PostgreSQL
está hardcodeada a Host=localhost en appsettings.json. Ese localhost
es de tu terminal hoy, y va a ser un problema en cuanto la app corra en un
Pod: no hay ningún PostgreSQL en localhost dentro de un contenedor. Para
este lab, la solución no es arreglar la configuración (eso es el
lab02): es meter un contenedor de PostgreSQL como sidecar en el mismo
Pod, para que "localhost" siga siendo válido dentro del namespace de red
del Pod. Es una solución fea a propósito: el objetivo es que se sienta el
dolor de la config hardcodeada antes de resolverlo formalmente mañana.
Antes de llegar a esa parte, la Parte A de este lab usa un proyecto
aparte, hello-api (sin base de datos, sin nada que pueda fallar), para
que el primer contenedor que construyas, publiques y despliegues funcione
a la primera. El objetivo de esa parte no es TaskFlow: es que la mecánica
de "Dockerfile → build → push → Deployment → Route → 200" quede
clara y sin fricción antes de meterla con un caso real que sí tiene un
problema intencional.
Conceptos
- Qué hace
dotnet publish(si nunca tocaste .NET) -
Compila el código (
dotnet restorebaja las dependencias del.csproj, equivalente a unnpm installo unmvn install) y arma una carpeta con el ensamblado (.dll) más todo lo que necesita para correr. - Qué es un stage en un Dockerfile
-
Cada instrucción
FROMarranca un sistema de archivos nuevo e independiente, sin relación con elFROManterior salvo que se copie algo explícitamente conCOPY --from=<stage>. Un Dockerfile puede tener uno o variosFROM; solo el contenido del último stage termina en la imagen final. Esto es lo que hace posible compilar con una imagen pesada (el SDK) y correr con una liviana (el runtime), sin que el compilador viaje a producción. - Principios Cloud Native
-
Diseñar asumiendo que un pod puede morir en cualquier momento (la plataforma lo reemplaza, no lo repara), mantener el estado fuera del proceso, y declarar en manifiestos lo que la plataforma debe garantizar en vez de operarlo a mano.
- Arquitectura basada en contenedores
-
La unidad de despliegue es una imagen inmutable versionada, no un servidor que se actualiza en el lugar.
- Responsabilidad de la plataforma vs. del desarrollador
-
OpenShift garantiza que el contenedor declarado siga corriendo (scheduling, red, reinicios); tú garantizas que la app se comporte bien dentro de ese contrato (arranca sin depender de estado local, expone sus puertos, sabe reportar si está sana).
Routees una conveniencia de OpenShift, no de Kubernetes-
El objeto portable equivalente para exponer un
Servicehacia afuera del clúster esIngress. Se usaRouteacá porque es lo nativo del clúster del workshop.
Pasos
|
Antes de empezar: confirma que ya hiciste |
Parte A: mecánica de contenerizar, sin fricción (hello-api)
labs/lab01-contenerizar/hello-api/ es un proyecto .NET mínimo (un único
endpoint, sin base de datos) que ya viene completo: no hay nada que
resolver acá, es para leer, construir y correr.
-
Leer y construir el Dockerfile de una sola etapa (
hello-api/Dockerfile.una-etapa):FROM mcr.microsoft.com/dotnet/sdk:10.0 WORKDIR /src COPY *.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app/publish --no-restore WORKDIR /app/publish EXPOSE 8080 ENTRYPOINT ["dotnet", "HelloApi.dll"]Línea por línea:
-
FROM mcr.microsoft.com/dotnet/sdk:10.0: imagen base. Trae el SDK completo de .NET 10 (compilador, herramientas de build, runtime): todo lo necesario para compilar, mucho más de lo necesario para correr. -
WORKDIR /src: crea (si no existe) y se posiciona en/srcdentro de la imagen; toda instrucción siguiente corre relativa a esa carpeta. -
COPY *.csproj .: copia solo el archivo de proyecto, todavía no el código. Es a propósito: separar esta copia de la del código fuente permite que Podman/Docker cachee la capa dedotnet restore(que baja paquetes NuGet) y no la repita si solo cambió el código, no las dependencias. -
RUN dotnet restore: descarga los paquetes NuGet declarados en el.csproj. -
COPY . .: ahora sí copia el resto del código fuente. -
RUN dotnet publish -c Release -o /app/publish --no-restore: compila en modo Release y deja el resultado (el ensamblado + todo lo necesario para ejecutar) en/app/publish.--no-restoreevita repetir el restore que ya se hizo arriba. -
WORKDIR /app/publish: cambia el directorio de trabajo al de la publicación, para que elENTRYPOINTno necesite rutas absolutas. -
EXPOSE 8080: documenta el puerto donde el contenedor escucha. No abre ningún puerto por sí solo (eso lo hace-penpodman run, o elServiceen Kubernetes): es metadata. -
ENTRYPOINT ["dotnet", "HelloApi.dll"]: el comando que arranca el contenedor. Kestrel (el servidor HTTP embebido de .NET) escucha en el puerto 8080 por defecto en contenedores .NET 8+, sin configurarASPNETCORE_URLS.Construirla, correrla, y confirmar que responde:
cd labs/lab01-contenerizar/hello-api podman build -t hello-api:una-etapa -f Dockerfile.una-etapa . podman images hello-api:una-etapa # anotar el tamaño podman run --rm --detach --name hello-api-test -p 8080:8080 hello-api:una-etapa curl http://localhost:8080/ podman stop hello-api-testEl hostname que devuelve la respuesta (
Environment.MachineNameen .NET) es el del propio contenedor, no el de tu workspace: local, va a verse como un ID de contenedor (85340440a316); más adelante, cuando la despliegues en OpenShift, va a coincidir exactamente con el nombre del Pod que ves enoc get pods. Guarda este dato: sirve para confirmar en el paso 3 que estás pegándole al Pod real, no a una copia en caché.
-
-
Leer y construir la versión multi-stage (
hello-api/Dockerfile):FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY *.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app/publish --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build /app/publish . EXPOSE 8080 ENTRYPOINT ["dotnet", "HelloApi.dll"]Qué cambió respecto al anterior:
-
FROM … AS build: es el mismo primer stage de antes, solo que ahora tiene nombre (build) para poder referenciarlo más adelante. -
FROM mcr.microsoft.com/dotnet/aspnet:10.0: un segundoFROMarranca un stage completamente nuevo, desde la imagen de runtime de ASP.NET (sin SDK, sin compilador, sin herramientas de build: solo lo necesario para ejecutar un.dllya compilado). -
COPY --from=build /app/publish .: la única cosa que cruza del stagebuildal final es el resultado ya compilado. El compilador, el código fuente y la caché de NuGet se quedan atrás, no forman parte de la imagen final. -
El resto (
EXPOSE/ENTRYPOINT) es idéntico.Construir bajo otro tag y comparar tamaños:
podman build -t hello-api:multi-stage -f Dockerfile . podman images | grep hello-apiLa diferencia de tamaño entre ambas filas es la razón de ser del multi-stage build: nada de lo que el compilador necesitó queda en la imagen que corre en producción.
-
-
Publicar y desplegar
hello-api. Los manifiestos enhello-api/manifests/ya están completos, no hay nada que editar:REGISTRY=default-route-openshift-image-registry.apps.workshop.bg.daytwodemo.com NS=$(oc project -q) # tu namespace actual (<tu-usuario>) USUARIO=$(oc whoami) TOKEN=$(oc whoami -t) podman login -u $USUARIO -p $TOKEN $REGISTRY podman tag hello-api:multi-stage $REGISTRY/$NS/hello-api:multi-stage podman push $REGISTRY/$NS/hello-api:multi-stage sed "s#tu-namespace#${NS}#g" manifests/deployment.yaml | oc apply -f - oc apply -f manifests/service.yaml -f manifests/route.yaml curl "https://$(oc get route hello-api -o jsonpath='{.spec.host}')/" oc get pods -l app=hello-apiSi ves
Hola, corro dentro de un contenedor .NET con hostname …, esa es tu primera victoria del workshop: contenerizaste, publicaste y desplegaste algo de punta a punta, sin ningún error en el medio. Confirma además que el hostname de la respuesta coincide exactamente con el nombre del Pod que te devolvióoc get pods: es la misma máquina lógica, no una coincidencia. Vuelve a esta parte si algo de TaskFlow (Parte B) no funciona, para aislar si el problema es de mecánica (que ya probaste que domina) o específico de TaskFlow.
Parte B: TaskFlow real (con el problema intencional)
-
Construir el Dockerfile de una sola etapa de TaskFlow (
app/Dockerfile, ya completo, mismo patrón que el paso 1):podman build -t taskflow-api:una-etapa -f app/Dockerfile app podman images taskflow-api:una-etapaAnotar el tamaño. Probarla:
podman run --rm -p 8080:8080 taskflow-api:una-etapaEsta vez sí va a fallar al conectar a Postgres (
EnsureCreated()tira una excepción de conexión), a diferencia de cuando corristedotnet run --project TaskFlow.Apidirecto en la terminal. La diferencia no es casualidad:dotnet runarranca un proceso dentro del mismo contenedor de la terminal, que comparte el namespace de red del Pod con el sidecar de Postgres del devfile, por esolocalhost:5432resuelve.podman runen cambio crea un contenedor nuevo, con su propio namespace de red aislado: "localhost" ahí adentro es el loopback de ese contenedor recién creado, donde no hay ningún Postgres escuchando. Es la misma razón por la que el Deployment real necesita su propio sidecar de Postgres en el paso 6: cada Pod (o cada contenedor anidado) tiene que resolver "localhost" contra algo que esté corriendo ahí mismo. -
Convertirlo a multi-stage tú mismo. Ahora que ya lo hiciste una vez con
hello-api, aplica el mismo patrón aapp/Dockerfile: agrégaleAS buildal primerFROM, y debajo un segundoFROM mcr.microsoft.com/dotnet/aspnet:10.0conCOPY --from=build /app/publish .. No hace falta ningún archivo de referencia nuevo: es exactamente la misma transformación del paso 2.Reconstruir bajo otro tag y comparar:
podman build -t taskflow-api:multi-stage -f app/Dockerfile app podman images | grep taskflow-apiNo hace falta todavía usuario non-root, rootfs de solo lectura, ni resource limits, eso es lab03.
-
Publicarla en el registry interno de OpenShift. Mismo mecanismo que usaste para
hello-apien el paso 3, con tu propio token de OpenShift haciendo de password:podman build -t $REGISTRY/$NS/taskflow-api:lab01 -f app/Dockerfile app podman push $REGISTRY/$NS/taskflow-api:lab01El
pushcrea automáticamente unImageStream taskflow-apicon el taglab01en tu namespace: verifícalo conoc get is taskflow-api. -
Completar
manifests/deployment.yaml: referenciar la imagen recién publicada, y agregar un sidecar de PostgreSQL usandoregistry.redhat.io/rhel9/postgresql-16:latest. -
Completar
manifests/service.yamlymanifests/route.yaml:ServiceClusterIP apuntando al puerto 8080,Routeedge apuntando alService. -
Desplegar y validar:
oc apply -f labs/lab01-contenerizar/manifests/ oc get pods -w curl "https://$(oc get route taskflow-api -o jsonpath='{.spec.host}')/api/tasks"
Criterios de "hecho"
-
hello-apiresponde200en su propia Route (Parte A, sin fricción). -
Puedes explicar qué copia
COPY --from=buildy por qué la imagen final no tiene el SDK. -
oc get is taskflow-apimuestra la imagen publicada. -
El pod de TaskFlow queda en
Running(noCrashLoopBackOff): sin esto, algo falló conectando el sidecar de Postgres. -
GET /api/tasksa través de la Route de TaskFlow devuelve200con[]. -
Puedes explicar por qué hizo falta el sidecar de Postgres en este lab puntual (pista:
Host=localhosten la connection string).