4. Construir una imagen
Esta lección empaqueta dos pequeñas API web en imágenes —una en C# (minimal API de ASP.NET Core), otra en Java (Spring Boot WebFlux, basada en Reactor)— y las ejecuta con wslc. Las dos hacen lo mismo, así que puedes comparar los dos ecosistemas paso a paso.
El código completo está en el repositorio: code/wsl-containers. Todo lo que sigue se ejecutó con wslc 2.9.11; las salidas son reales.
Las dos aplicaciones
Sección titulada «Las dos aplicaciones»Cada API expone dos endpoints en el puerto 8080:
GET /devuelve un pequeño documento JSON: nombre de la aplicación, runtime, sistema operativo y nombre de la máquina;GET /ticksemite en streaming tres Server-Sent Events, uno por segundo.
| C# | Java | |
|---|---|---|
| Framework | minimal API de ASP.NET Core 10 | Spring Boot 4.1 WebFlux |
| Valor único | objeto anónimo devuelto por la lambda | Mono<Info> |
| Flujo | IAsyncEnumerable<int> + TypedResults.ServerSentEvents |
Flux<Long> + text/event-stream |
| Servidor web | Kestrel | Netty |
| Puerto por defecto en un contenedor | 8080 | 8080 |
C# — csharp-api/Program.cs:
app.MapGet("/", () => new{ App = "csharp-api", Runtime = RuntimeInformation.FrameworkDescription, Os = RuntimeInformation.OSDescription, Machine = Environment.MachineName});
app.MapGet("/ticks", (CancellationToken ct) => TypedResults.ServerSentEvents(Ticks(ct)));
static async IAsyncEnumerable<int> Ticks([EnumeratorCancellation] CancellationToken ct){ for (var i = 0; i < 3; i++) { await Task.Delay(TimeSpan.FromSeconds(1), ct); yield return i; }}Java — java-reactor-api/src/main/java/dev/learn/reactorapi/JavaReactorApiApplication.java (proyecto generado con start.spring.io, dependencia Spring Reactive Web):
record Info(String app, String runtime, String os, String machine) {}
@RestControllerclass InfoController {
@GetMapping("/") Mono<Info> info() { return Mono.just(new Info( "java-reactor-api", "Java " + Runtime.version(), System.getProperty("os.name") + " " + System.getProperty("os.version"), System.getenv().getOrDefault("HOSTNAME", "?"))); }
@GetMapping(value = "/ticks", produces = MediaType.TEXT_EVENT_STREAM_VALUE) Flux<Long> ticks() { return Flux.interval(Duration.ofSeconds(1)).take(3); }
}El Containerfile
Sección titulada «El Containerfile»Un Containerfile (misma sintaxis que un Dockerfile) describe cómo construir la imagen. Las dos aplicaciones usan un build multietapa: una primera etapa con el SDK completo compila la aplicación, y una segunda etapa con solo el runtime recibe el resultado. Las herramientas de build nunca llegan a la imagen final.
C# — csharp-api/Containerfile:
# --- Etapa 1: build con el SDK completo (compilador, NuGet) ---FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /src
# Restaurar primero: esta capa queda en caché mientras el .csproj no cambieCOPY CsharpApi.csproj .RUN dotnet restore
COPY . .RUN dotnet publish -c Release -o /app --no-restore
# --- Etapa 2: ejecución con solo el runtime de ASP.NET Core ---FROM mcr.microsoft.com/dotnet/aspnet:10.0WORKDIR /appCOPY --from=build /app .
# Usuario no root proporcionado por las imágenes de .NETUSER $APP_UIDEXPOSE 8080ENTRYPOINT ["dotnet", "CsharpApi.dll"]Java — java-reactor-api/Containerfile:
# --- Etapa 1: build con Maven y el JDK completo ---FROM maven:3.9-eclipse-temurin-25 AS buildWORKDIR /src
# Descargar primero las dependencias: esta capa queda en caché mientras pom.xml no cambieCOPY pom.xml .RUN mvn -q dependency:go-offline
COPY src ./srcRUN mvn -q package
# --- Etapa 2: ejecución con solo el JRE ---FROM eclipse-temurin:25-jreWORKDIR /appCOPY --from=build /src/target/app.jar app.jar
# Usuario no root proporcionado por la imagen base de UbuntuUSER ubuntuEXPOSE 8080# Netty carga una biblioteca nativa: permitirlo explícitamente (si no, Java 24+ muestra una advertencia)ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "app.jar"](<finalName>app</finalName> en pom.xml da al jar un nombre fijo.)
| Instrucción | Función |
|---|---|
FROM image AS name |
inicia una etapa a partir de una imagen base y le da un nombre |
WORKDIR |
directorio de trabajo en la imagen |
COPY |
copia archivos desde el contexto de build (la carpeta pasada a build) |
COPY --from=build |
copia archivos desde otra etapa |
RUN |
comando ejecutado durante el build |
USER |
usuario con el que se ejecuta el proceso del contenedor |
EXPOSE |
documenta el puerto de escucha (no lo publica) |
ENTRYPOINT |
comando ejecutado cuando arranca el contenedor |
Los mismos pasos, lado a lado:
| Paso | C# | Java |
|---|---|---|
| Imagen de build | dotnet/sdk:10.0 |
maven:3.9-eclipse-temurin-25 |
| Dependencias | dotnet restore (NuGet) |
mvn dependency:go-offline |
| Compilar y empaquetar | dotnet publish → carpeta de DLL |
mvn package → un único jar ejecutable |
| Imagen de runtime | dotnet/aspnet:10.0 |
eclipse-temurin:25-jre |
| Usuario no root | USER $APP_UID (app, uid 1654) |
USER ubuntu (uid 1000) |
Cada proyecto tiene también un .dockerignore (bin/ y obj/ para C#, target/ para Java) para que las salidas de build locales no se envíen al build. wslc lo respeta: un archivo colocado en obj/ no llegó a la imagen, y COPY . . siguió en caché.
Construir
Sección titulada «Construir»Desde la carpeta de cada proyecto (wslc build encuentra el Containerfile por sí solo; usa -f para otro nombre):
cd code\wsl-containers\csharp-apiwslc build -t csharp-api .
cd ..\java-reactor-apiwslc build -t java-reactor-api .Extracto del build de C#:
[build 3/6] COPY CsharpApi.csproj .[build 4/6] RUN dotnet restore [build] Determining projects to restore... [build] Restored /src/CsharpApi.csproj (in 201 ms).[build 5/6] COPY . .[build 6/6] RUN dotnet publish -c Release -o /app --no-restore [build] CsharpApi -> /src/bin/Release/net10.0/CsharpApi.dll [build] CsharpApi -> /app/[stage-1 3/3] COPY --from=build /app .exporting to image | naming to docker.io/library/csharp-api> wslc image listREPOSITORY TAG IMAGE ID CREATED SIZEcsharp-api latest c1bdb0897739 3 minutes ago 230MBjava-reactor-api latest 3a731cbc7d15 4 minutes ago 387MBEjecutar
Sección titulada «Ejecutar»Las dos aplicaciones escuchan en el 8080 dentro de su contenedor; publícalas en dos puertos distintos de Windows:
wslc run -d --rm -p 5000:8080 --name csharp csharp-apiwslc run -d --rm -p 8081:8080 --name java java-reactor-apiwslc container listCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMESb8acbb234fb1 java-reactor-api "java --enable-nativ…" 8 seconds ago Up 7 seconds 127.0.0.1:8081->8080/tcp java8bdcda0c3927 csharp-api "dotnet CsharpApi.dll" 8 seconds ago Up 7 seconds 127.0.0.1:5000->8080/tcp csharpwslc publica en 127.0.0.1 por defecto, así que usa esa dirección con curl (curl.exe viene con Windows):
curl.exe http://127.0.0.1:5000/curl.exe http://127.0.0.1:8081/{"app":"csharp-api","runtime":".NET 10.0.12","os":"Ubuntu 24.04.5 LTS","machine":"8bdcda0c3927"}{"app":"java-reactor-api","runtime":"Java 25.0.4+7-LTS","os":"Linux 6.18.40.1-microsoft-standard-WSL2","machine":"b8acbb234fb1"}El nombre de la máquina es el ID del contenedor. El flujo (-N desactiva el búfer de curl, para que los eventos aparezcan uno por segundo):
curl.exe -N http://127.0.0.1:5000/tickscurl.exe -N http://127.0.0.1:8081/ticksdata: 0
data: 1
data: 2Java escribe data:0 sin el espacio; las dos formas son SSE válido.
Prueba de que es Linux, y no root
Sección titulada «Prueba de que es Linux, y no root»wslc exec csharp uname -awslc exec csharp idwslc exec java idLinux 8bdcda0c3927 6.18.40.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Fri Jul 31 22:12:15 UTC 2026 x86_64 x86_64 x86_64 GNU/Linuxuid=1654(app) gid=1654(app) groups=1654(app)uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev)El kernel es el de WSL: los contenedores comparten el kernel de la VM de la sesión wslc. Sin la línea USER, el contenedor Java se ejecutaría como uid=0(root).
Leer los logs
Sección titulada «Leer los logs»wslc container logs csharpwslc container logs javainfo: Microsoft.Hosting.Lifetime[14] Now listening on: http://[::]:8080info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down. :: Spring Boot :: (v4.1.1)
... Starting JavaReactorApiApplication v0.0.1-SNAPSHOT using Java 25.0.4 with PID 1 (/app/app.jar started by ubuntu in /app)... Netty started on port 8080 (http)... Started JavaReactorApiApplication in 1.391 seconds (process running for 1.795)wslc container stop csharp javaDiagnosticar
Sección titulada «Diagnosticar»wslc container list --all # incluye los contenedores detenidos, con su código de salidawslc container logs <container> # lo que imprimió la aplicaciónwslc container inspect <container> # configuración efectiva: comando, env, puertos, código de salidawslc image inspect <image>Liberar espacio en disco
Sección titulada «Liberar espacio en disco»Cada rebuild mueve la etiqueta a la nueva imagen y deja la antigua atrás, sin etiqueta:
REPOSITORY TAG IMAGE ID CREATED SIZEcsharp-api latest c1bdb0897739 3 minutes ago 230MB<none> <none> ddb9e015ed72 4 minutes ago 387MBjava-reactor-api latest 3a731cbc7d15 4 minutes ago 387MB<none> <none> c32cae38bd07 7 minutes ago 230MBwslc container prune # elimina los contenedores detenidoswslc image prune # elimina las imágenes huérfanas (las <none>)wslc image prune --all # elimina todas las imágenes que no usa ningún contenedor (sin confirmación)Puntos clave
Sección titulada «Puntos clave»- Un build multietapa compila con el SDK y solo entrega el runtime: 230 MB para la imagen de C# en lugar de 918 MB para la etapa de build.
- Copia el archivo de proyecto (
.csproj,pom.xml) y restaura las dependencias antes de copiar el código fuente, para mantener esa capa en caché. RUNse ejecuta en el build,ENTRYPOINTal arrancar.- Ejecuta con un usuario no root:
USER $APP_UIDpara las imágenes de .NET,USER ubuntupara las imágenes de Temurin. - Las dos aplicaciones escuchan en el 8080 dentro del contenedor;
-p host:containerelige el puerto de Windows. container list --all,logseinspectson lo primero a lo que recurrir cuando un contenedor no se comporta como se espera.
Ejercicios
Sección titulada «Ejercicios»- ¿Qué tamaño tendría la imagen de C# si entregaras la etapa de build en lugar de la etapa de runtime? Mídelo sin editar el
Containerfile.
Solución
--target detiene el build en una etapa con nombre:
wslc build --target build -t csharp-api:build .wslc image listcsharp-api latest c32cae38bd07 3 minutes ago 230MBcsharp-api build 3d3e47d940b4 3 minutes ago 918MBEl SDK, las cachés de NuGet y los archivos intermedios hacen que la etapa de build sea cuatro veces más grande. Elimínala después: wslc image remove csharp-api:build.
- Haz que las dos aplicaciones escuchen en el puerto 9000 dentro de su contenedor, sin reconstruir las imágenes.
Solución
Los dos frameworks leen el puerto de una variable de entorno, pasada con -e:
wslc run -d --rm -e ASPNETCORE_HTTP_PORTS=9000 -p 5000:9000 --name csharp csharp-apiwslc run -d --rm -e SERVER_PORT=9000 -p 8081:9000 --name java java-reactor-apiwslc container logs csharpwslc container logs java Now listening on: http://[::]:9000... Netty started on port 9000 (http)El lado del contenedor de -p debe seguir el cambio: 5000:9000, no 5000:8080. EXPOSE 8080 en el Containerfile es solo documentación y no lo impide.
- Un compañero de equipo inicia la aplicación Java con
wslc run -d --rm -e SERVER_PORT=abc --name java java-reactor-api. Unos segundos después,wslc container listno muestra nada ywslc container logs javaresponde:
Container 'java' not found.Error code: WSLC_E_CONTAINER_NOT_FOUND¿Qué ha pasado y cómo encuentras la causa?
Solución
La aplicación falló al arrancar, y --rm eliminó el contenedor junto con sus logs. Vuelve a ejecutarla sin --rm:
wslc run -d -e SERVER_PORT=abc --name java java-reactor-apiwslc container list --allwslc container logs javaCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES7b374a736ad1 java-reactor-api "java --enable-nativ…" 7 seconds ago Exited (1) 4 seconds ago java***************************APPLICATION FAILED TO START***************************
Description:
Failed to bind properties under 'server.port' to java.lang.Integer:
Property: server.port Value: "abc" Origin: System Environment Property "SERVER_PORT" Reason: failed to convert java.lang.String to java.lang.Integer (caused by java.lang.NumberFormatException: For input string: "abc")wslc container inspect java confirma "SERVER_PORT=abc" en Env y "ExitCode": 1. Limpia con wslc container remove java.
GET /indica"os":"Ubuntu 24.04.5 LTS"para C# pero"os":"Linux 6.18.40.1-microsoft-standard-WSL2"para Java. ¿Se ejecutan los dos contenedores en sistemas distintos?
Solución
No. Los dos runtimes no describen lo mismo:
RuntimeInformation.OSDescription(.NET) lee la distribución de la imagen (/etc/os-release): la imagenaspnet:10.0se basa en Ubuntu 24.04;os.name+os.version(Java) dan el nombre y la versión del kernel, compartido por todos los contenedores de la sesión.
wslc exec java cat /etc/os-release muestra que la imagen de Temurin 25 se basa en Ubuntu 26.04, y wslc exec csharp uname -r muestra el mismo kernel de WSL que Java.
Fuentes
Sección titulada «Fuentes»- WSL container — Microsoft Learn
- Contenerizar una aplicación .NET e imágenes de contenedor de .NET — Microsoft Learn
- Server-Sent Events en las minimal API de ASP.NET Core — Microsoft Learn
- Container images y Dockerfiles — referencia de Spring Boot
- Web on Reactive Stack (WebFlux) — referencia de Spring Framework
- Multi-stage builds y Dockerfile reference — documentación de Docker (
wslcusa la misma sintaxis)