4. Construire une image
Cette leçon empaquette deux petites API web dans des images — l’une en C# (API minimale ASP.NET Core), l’autre en Java (Spring Boot WebFlux, bâti sur Reactor) — et les lance avec wslc. Les deux font la même chose, ce qui permet de comparer les deux écosystèmes étape par étape.
Le code complet se trouve dans le dépôt : code/wsl-containers. Tout ce qui suit a été exécuté avec wslc 2.9.11 ; les sorties sont réelles.
Les deux applications
Section intitulée « Les deux applications »Chaque API expose deux points de terminaison sur le port 8080 :
GET /renvoie un petit document JSON : nom de l’application, runtime, système d’exploitation et nom de la machine ;GET /ticksdiffuse trois Server-Sent Events, un par seconde.
| C# | Java | |
|---|---|---|
| Framework | API minimale ASP.NET Core 10 | Spring Boot 4.1 WebFlux |
| Valeur unique | objet anonyme renvoyé par la lambda | Mono<Info> |
| Flux | IAsyncEnumerable<int> + TypedResults.ServerSentEvents |
Flux<Long> + text/event-stream |
| Serveur web | Kestrel | Netty |
| Port par défaut dans un conteneur | 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 (projet généré avec start.spring.io, dépendance 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); }
}Le Containerfile
Section intitulée « Le Containerfile »Un Containerfile (même syntaxe qu’un Dockerfile) décrit comment construire l’image. Les deux applications utilisent une construction multi-étapes : une première étape avec le SDK complet compile l’application, une seconde étape avec le seul runtime reçoit le résultat. Les outils de construction n’arrivent jamais dans l’image finale.
C# — csharp-api/Containerfile :
# --- Étape 1 : construire avec le SDK complet (compilateur, NuGet) ---FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /src
# Restaurer d'abord : cette couche reste en cache tant que le .csproj ne change pasCOPY CsharpApi.csproj .RUN dotnet restore
COPY . .RUN dotnet publish -c Release -o /app --no-restore
# --- Étape 2 : exécuter avec le seul runtime ASP.NET Core ---FROM mcr.microsoft.com/dotnet/aspnet:10.0WORKDIR /appCOPY --from=build /app .
# Utilisateur non root fourni par les images .NETUSER $APP_UIDEXPOSE 8080ENTRYPOINT ["dotnet", "CsharpApi.dll"]Java — java-reactor-api/Containerfile :
# --- Étape 1 : construire avec Maven et le JDK complet ---FROM maven:3.9-eclipse-temurin-25 AS buildWORKDIR /src
# Télécharger d'abord les dépendances : cette couche reste en cache tant que pom.xml ne change pasCOPY pom.xml .RUN mvn -q dependency:go-offline
COPY src ./srcRUN mvn -q package
# --- Étape 2 : exécuter avec le seul JRE ---FROM eclipse-temurin:25-jreWORKDIR /appCOPY --from=build /src/target/app.jar app.jar
# Utilisateur non root fourni par l'image de base UbuntuUSER ubuntuEXPOSE 8080# Netty charge une bibliothèque native : l'autoriser explicitement (sinon Java 24+ affiche un avertissement)ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "app.jar"](<finalName>app</finalName> dans pom.xml donne au jar un nom fixe.)
| Instruction | Rôle |
|---|---|
FROM image AS name |
démarre une étape à partir d’une image de base et la nomme |
WORKDIR |
dossier de travail dans l’image |
COPY |
copie des fichiers du contexte de construction (le dossier passé à build) |
COPY --from=build |
copie des fichiers depuis une autre étape |
RUN |
commande exécutée pendant la construction |
USER |
utilisateur sous lequel tourne le processus du conteneur |
EXPOSE |
documente le port écouté (ne le publie pas) |
ENTRYPOINT |
commande lancée au démarrage du conteneur |
Les mêmes étapes, côte à côte :
| Étape | C# | Java |
|---|---|---|
| Image de construction | dotnet/sdk:10.0 |
maven:3.9-eclipse-temurin-25 |
| Dépendances | dotnet restore (NuGet) |
mvn dependency:go-offline |
| Compilation et empaquetage | dotnet publish → dossier de DLL |
mvn package → un jar exécutable |
| Image d’exécution | dotnet/aspnet:10.0 |
eclipse-temurin:25-jre |
| Utilisateur non root | USER $APP_UID (app, uid 1654) |
USER ubuntu (uid 1000) |
Chaque projet a aussi un .dockerignore (bin/ et obj/ pour C#, target/ pour Java) pour que les sorties de compilation locales ne soient pas envoyées dans la construction. wslc le respecte : un fichier placé dans obj/ n’est pas arrivé dans l’image, et COPY . . est resté en cache.
Construire
Section intitulée « Construire »Depuis le dossier de chaque projet (wslc build trouve le Containerfile tout seul ; utilise -f pour un autre nom) :
cd code\wsl-containers\csharp-apiwslc build -t csharp-api .
cd ..\java-reactor-apiwslc build -t java-reactor-api .Extrait de la construction 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 387MBLes deux applications écoutent sur 8080 dans leur conteneur ; publie-les sur deux ports Windows différents :
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 publie sur 127.0.0.1 par défaut, utilise donc cette adresse avec curl (curl.exe est fourni avec 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"}Le nom de la machine est l’identifiant du conteneur. Le flux (-N désactive la mise en tampon de curl, pour que les événements s’affichent un par seconde) :
curl.exe -N http://127.0.0.1:5000/tickscurl.exe -N http://127.0.0.1:8081/ticksdata: 0
data: 1
data: 2Java écrit data:0 sans l’espace ; les deux formes sont du SSE valide.
La preuve que c’est Linux, et pas root
Section intitulée « La preuve que c’est Linux, et pas 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)Le noyau est celui de WSL : les conteneurs partagent le noyau de la VM de la session wslc. Sans la ligne USER, le conteneur Java tournerait en uid=0(root).
Lire les logs
Section intitulée « Lire les 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 javaDiagnostiquer
Section intitulée « Diagnostiquer »wslc container list --all # inclut les conteneurs arrêtés, avec leur code de sortiewslc container logs <conteneur> # ce que l'application a affichéwslc container inspect <conteneur> # configuration effective : commande, env, ports, code de sortiewslc image inspect <image>Libérer de l’espace disque
Section intitulée « Libérer de l’espace disque »Chaque reconstruction déplace le tag vers la nouvelle image et laisse l’ancienne derrière elle, sans tag :
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 # supprime les conteneurs arrêtéswslc image prune # supprime les images orphelines (les <none>)wslc image prune --all # supprime toutes les images non utilisées par un conteneur (sans confirmation)À retenir
Section intitulée « À retenir »- Une construction multi-étapes compile avec le SDK et ne livre que le runtime : 230 Mo pour l’image C# au lieu de 918 Mo pour l’étape de construction.
- Copie le fichier de projet (
.csproj,pom.xml) et restaure les dépendances avant de copier les sources, pour garder cette couche en cache. RUNs’exécute à la construction,ENTRYPOINTau démarrage.- Exécute sous un utilisateur non root :
USER $APP_UIDpour les images .NET,USER ubuntupour les images Temurin. - Les deux applications écoutent sur 8080 dans le conteneur ;
-p host:containerchoisit le port Windows. container list --all,logsetinspectsont les premiers réflexes quand un conteneur ne se comporte pas comme prévu.
Exercices
Section intitulée « Exercices »- Quelle serait la taille de l’image C# si tu livrais l’étape de construction au lieu de l’étape d’exécution ? Mesure-la sans modifier le
Containerfile.
Solution
--target arrête la construction à une étape nommée :
wslc build --target build -t csharp-api:build .wslc image listcsharp-api latest c32cae38bd07 3 minutes ago 230MBcsharp-api build 3d3e47d940b4 3 minutes ago 918MBLe SDK, les caches NuGet et les fichiers intermédiaires rendent l’étape de construction quatre fois plus lourde. Supprime-la ensuite : wslc image remove csharp-api:build.
- Fais écouter les deux applications sur le port 9000 dans leur conteneur, sans reconstruire les images.
Solution
Les deux frameworks lisent le port dans une variable d’environnement, passée avec -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)Le côté conteneur de -p doit suivre : 5000:9000, et non 5000:8080. EXPOSE 8080 dans le Containerfile n’est que de la documentation et ne l’empêche pas.
- Un collègue lance l’application Java avec
wslc run -d --rm -e SERVER_PORT=abc --name java java-reactor-api. Quelques secondes plus tard,wslc container listn’affiche rien etwslc container logs javarépond :
Container 'java' not found.Error code: WSLC_E_CONTAINER_NOT_FOUNDQue s’est-il passé, et comment trouver la cause ?
Solution
L’application a planté au démarrage, et --rm a supprimé le conteneur avec ses logs. Relance-la sans --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 confirme "SERVER_PORT=abc" dans Env et "ExitCode": 1. Nettoie avec wslc container remove java.
GET /indique"os":"Ubuntu 24.04.5 LTS"pour C#, mais"os":"Linux 6.18.40.1-microsoft-standard-WSL2"pour Java. Les deux conteneurs tournent-ils sur des systèmes différents ?
Solution
Non. Les deux runtimes ne décrivent pas la même chose :
RuntimeInformation.OSDescription(.NET) lit la distribution de l’image (/etc/os-release) : l’imageaspnet:10.0est basée sur Ubuntu 24.04 ;os.name+os.version(Java) donnent le nom et la version du noyau, partagé par tous les conteneurs de la session.
wslc exec java cat /etc/os-release montre que l’image Temurin 25 est basée sur Ubuntu 26.04, et wslc exec csharp uname -r affiche le même noyau WSL que Java.
- WSL container — Microsoft Learn
- Conteneuriser une application .NET et Images conteneur .NET — Microsoft Learn
- Server-Sent Events dans les API minimales ASP.NET Core — Microsoft Learn
- Container images et Dockerfiles — référence Spring Boot
- Web on Reactive Stack (WebFlux) — référence Spring Framework
- Multi-stage builds et Dockerfile reference — documentation Docker (
wslcutilise la même syntaxe)