Aller au contenu

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.

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 /ticks diffuse 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;
}
}

Javajava-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) {
}
@RestController
class 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);
}
}

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 build
WORKDIR /src
# Restaurer d'abord : cette couche reste en cache tant que le .csproj ne change pas
COPY 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.0
WORKDIR /app
COPY --from=build /app .
# Utilisateur non root fourni par les images .NET
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "CsharpApi.dll"]

Javajava-reactor-api/Containerfile :

# --- Étape 1 : construire avec Maven et le JDK complet ---
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /src
# Télécharger d'abord les dépendances : cette couche reste en cache tant que pom.xml ne change pas
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package
# --- Étape 2 : exécuter avec le seul JRE ---
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar
# Utilisateur non root fourni par l'image de base Ubuntu
USER ubuntu
EXPOSE 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.

Depuis le dossier de chaque projet (wslc build trouve le Containerfile tout seul ; utilise -f pour un autre nom) :

Fenêtre de terminal
cd code\wsl-containers\csharp-api
wslc build -t csharp-api .
cd ..\java-reactor-api
wslc 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 list
REPOSITORY TAG IMAGE ID CREATED SIZE
csharp-api latest c1bdb0897739 3 minutes ago 230MB
java-reactor-api latest 3a731cbc7d15 4 minutes ago 387MB

Les deux applications écoutent sur 8080 dans leur conteneur ; publie-les sur deux ports Windows différents :

Fenêtre de terminal
wslc run -d --rm -p 5000:8080 --name csharp csharp-api
wslc run -d --rm -p 8081:8080 --name java java-reactor-api
wslc container list
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b8acbb234fb1 java-reactor-api "java --enable-nativ…" 8 seconds ago Up 7 seconds 127.0.0.1:8081->8080/tcp java
8bdcda0c3927 csharp-api "dotnet CsharpApi.dll" 8 seconds ago Up 7 seconds 127.0.0.1:5000->8080/tcp csharp

wslc publie sur 127.0.0.1 par défaut, utilise donc cette adresse avec curl (curl.exe est fourni avec Windows) :

Fenêtre de terminal
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) :

Fenêtre de terminal
curl.exe -N http://127.0.0.1:5000/ticks
curl.exe -N http://127.0.0.1:8081/ticks
data: 0
data: 1
data: 2

Java écrit data:0 sans l’espace ; les deux formes sont du SSE valide.

Fenêtre de terminal
wslc exec csharp uname -a
wslc exec csharp id
wslc exec java id
Linux 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/Linux
uid=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).

Fenêtre de terminal
wslc container logs csharp
wslc container logs java
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
info: 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)
Fenêtre de terminal
wslc container stop csharp java
Fenêtre de terminal
wslc container list --all # inclut les conteneurs arrêtés, avec leur code de sortie
wslc container logs <conteneur> # ce que l'application a affiché
wslc container inspect <conteneur> # configuration effective : commande, env, ports, code de sortie
wslc image inspect <image>

Chaque reconstruction déplace le tag vers la nouvelle image et laisse l’ancienne derrière elle, sans tag :

REPOSITORY TAG IMAGE ID CREATED SIZE
csharp-api latest c1bdb0897739 3 minutes ago 230MB
<none> <none> ddb9e015ed72 4 minutes ago 387MB
java-reactor-api latest 3a731cbc7d15 4 minutes ago 387MB
<none> <none> c32cae38bd07 7 minutes ago 230MB
Fenêtre de terminal
wslc container prune # supprime les conteneurs arrêtés
wslc image prune # supprime les images orphelines (les <none>)
wslc image prune --all # supprime toutes les images non utilisées par un conteneur (sans confirmation)
  • 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.
  • RUN s’exécute à la construction, ENTRYPOINT au démarrage.
  • Exécute sous un utilisateur non root : USER $APP_UID pour les images .NET, USER ubuntu pour les images Temurin.
  • Les deux applications écoutent sur 8080 dans le conteneur ; -p host:container choisit le port Windows.
  • container list --all, logs et inspect sont les premiers réflexes quand un conteneur ne se comporte pas comme prévu.
  1. 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 :

Fenêtre de terminal
wslc build --target build -t csharp-api:build .
wslc image list
csharp-api latest c32cae38bd07 3 minutes ago 230MB
csharp-api build 3d3e47d940b4 3 minutes ago 918MB

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

  1. 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 :

Fenêtre de terminal
wslc run -d --rm -e ASPNETCORE_HTTP_PORTS=9000 -p 5000:9000 --name csharp csharp-api
wslc run -d --rm -e SERVER_PORT=9000 -p 8081:9000 --name java java-reactor-api
wslc container logs csharp
wslc 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.

  1. 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 list n’affiche rien et wslc container logs java répond :
Container 'java' not found.
Error code: WSLC_E_CONTAINER_NOT_FOUND

Que 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 :

Fenêtre de terminal
wslc run -d -e SERVER_PORT=abc --name java java-reactor-api
wslc container list --all
wslc container logs java
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7b374a736ad1 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.

  1. 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’image aspnet:10.0 est 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.