Source code of my website
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

🚧 : add timbernetes draft

+509
content/posts/2026/2026-03-20-timbernetes/cover.png

This is a binary file and will not be displayed.

+509
content/posts/2026/2026-03-20-timbernetes/index.md
··· 1 + --- 2 + date: 2026-03-20 3 + title: "Timbernetes : Ajuster les ressources à chaud d'une appli Java" 4 + slug: timbernetes-java 5 + tags: 6 + - kubernetes 7 + - java 8 + - scaleway 9 + draft: true 10 + --- 11 + 12 + La version 1.35 de Kubernetes, nommée "Timbernetes", est sortie le 17 décembre dernier (ça passe vite !) et est déjà disponible sur toutes les bonnes plateformes de Cloud. 13 + 14 + Une des nouveautés importantes de cette version est le passage en _Stable_ des _In-place updates of Pod resources_. Le principe de cette feature est de permettre de modifier _à chaud_, sans redémarrage donc, les ressources CPU ou RAM allouées à un _Pod_ ou à un _Container_. 15 + 16 + Dans cet article, j'explore cette feature, en particulier pour des applications Java. 17 + 18 + <!--more--> 19 + 20 + ## Une appli simple pour faire un bench 21 + 22 + Pour pouvoir tester cette feature, je veux pouvoir charger un peu le CPU et la Heap d'une JVM. J'ai donc demandé à OpenCode / DevStral de me générer une petite appli qui utilise JMH pour bencher un bon vieux _fibonnaci_ : 23 + 24 + ```java 25 + public class CPUStress { 26 + 27 + @Benchmark 28 + public void fibonacciCalculation(Blackhole blackhole) { 29 + for (int i = 0; i < 50; i++) { 30 + var result = fibonacci(i); 31 + blackhole.consume(result); 32 + } 33 + } 34 + 35 + private long fibonacci(int n) { 36 + if (n <= 1) return n; 37 + long a = 0, b = 1; 38 + for (int i = 2; i <= n; i++) { 39 + long temp = a + b; 40 + a = b; 41 + b = temp; 42 + } 43 + return b; 44 + } 45 + } 46 + ``` 47 + 48 + Côté bench de RAM, on va simplement allouer des tableaux d'octets pour gonfler la RAM avec du vide, le but étant de remplir la Heap : 49 + 50 + ```java 51 + @State(Scope.Thread) 52 + public class MemoryStress { 53 + 54 + private static final int MB_TO_ALLOCATE = 50; 55 + 56 + private final List<byte[]> memory = new ArrayList<>(); 57 + 58 + @Benchmark 59 + public void allocateMemory(Blackhole blackhole) { 60 + try { 61 + memory.add(new byte[MB_TO_ALLOCATE * 1024 * 1024]); 62 + blackhole.consume(memory); 63 + } catch (OutOfMemoryError e) { 64 + memory.clear(); 65 + System.gc(); 66 + blackhole.consume(0L); 67 + } 68 + } 69 + } 70 + ``` 71 + 72 + J'expose aussi un petit runner que je déclenche avec une requête HTTP, pour démarrer le bench : 73 + 74 + ```java 75 + public class BenchmarkRunner { 76 + 77 + String runBenchmark() throws Exception { 78 + File tempFile = Files.createTempFile("jmh-result", ".txt").toFile(); 79 + try { 80 + Options options = new OptionsBuilder() 81 + .mode(Mode.Throughput) 82 + .timeUnit(TimeUnit.SECONDS) 83 + .forks(0) 84 + .result(tempFile.getAbsolutePath()) 85 + .resultFormat(ResultFormatType.TEXT) 86 + .build(); 87 + 88 + Runner runner = new Runner(options); 89 + runner.run(); 90 + 91 + return Files.readString(tempFile.toPath()); 92 + } finally { 93 + tempFile.delete(); 94 + } 95 + } 96 + } 97 + ``` 98 + 99 + Le bench est lancé sans forker la JVM, ce qui va me permettre de voir quel est l'impact d'un redimensionnement de la JVM pendant son exécution. 100 + J'ai exposé le démarrage du bench dans un endpoint HTTP `/stress/start` et la récupération des résultats dans `/stress/results`. 101 + 102 + ## Exposer quelques métriques avec Micrometer 103 + 104 + En complément, mon appli Java va aussi exposer quelques petites métriques au format Prometheus, pour que je puisse regarder comment la JVM réagit aux différents tirs. 105 + 106 + J'ai donc importé la dépendance _micrometer-registry-prometheus_ dans mon projet : 107 + 108 + ```xml 109 + <dependency> 110 + <groupId>io.micrometer</groupId> 111 + <artifactId>micrometer-registry-prometheus</artifactId> 112 + </dependency> 113 + ``` 114 + 115 + J'ai ensuite ajouté deux implémentations basiques de gauges : une première qui va exposer le nombre de CPU visibles par la JVM, et la charge constatée par l'OS : 116 + 117 + ```java 118 + public class CPUGauges { 119 + 120 + public void register(MeterRegistry registry) { 121 + Gauge.builder("cpu.count", 122 + Runtime.getRuntime()::availableProcessors) 123 + .register(registry); 124 + 125 + Gauge.builder("process.cpu.load", 126 + ManagementFactory.getOperatingSystemMXBean()::getSystemLoadAverage) 127 + .register(registry); 128 + } 129 + } 130 + ``` 131 + 132 + Pour la mémoire, j'expose la mémoire maximale visible par le runtime (qui correspond à ma taille de Heap Java), ainsi que la quantité de Heap utilisée. 133 + 134 + ```java 135 + public class MemoryGauges { 136 + 137 + public void register(MeterRegistry registry) { 138 + Gauge.builder("jvm.memory.max.mb", 139 + () -> Runtime.getRuntime().maxMemory() / (1024.0 * 1024.0)) 140 + .register(registry); 141 + 142 + Gauge.builder("jvm.memory.used.mb", 143 + () -> ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed() / (1024.0 * 1024.0)) 144 + .register(registry); 145 + } 146 + } 147 + ``` 148 + 149 + Ces gauges sont alors exposées sur un endpoint HTTP `/metrics` : 150 + 151 + ```java 152 + public class MetricsServer { 153 + 154 + public MetricsServer(int port) throws IOException { 155 + var registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); 156 + 157 + new CPUGauges().register(this.registry); 158 + new MemoryGauges().register(this.registry); 159 + 160 + var server = HttpServer.create(new InetSocketAddress(port), 0); 161 + server.createContext("/metrics", httpExchange -> { 162 + String response = registry.scrape(); 163 + httpExchange.getResponseHeaders().set("Content-Type", "text/plain; version=0.0.4"); 164 + httpExchange.sendResponseHeaders(200, response.getBytes().length); 165 + try (OutputStream os = httpExchange.getResponseBody()) { 166 + os.write(response.getBytes()); 167 + } 168 + }); 169 + server.start(); 170 + } 171 + } 172 + ``` 173 + 174 + Lorsque je démarre l'application sur ma machine, j'obtiens les métriques suivantes : 175 + 176 + ```http request 177 + GET localhost:8080/metrics 178 + 179 + cpu_count 22.0 180 + process_cpu_load 0.4892578125 181 + jvm_memory_max_mb 15920.0 182 + jvm_memory_used_mb 33.128868103027344 183 + ``` 184 + 185 + J'ai packagé mon application avec un `Dockerfile` simple : 186 + 187 + ```dockerfile 188 + FROM maven:3.9-eclipse-temurin-25 AS build 189 + WORKDIR /app 190 + COPY pom.xml . 191 + COPY src ./src 192 + RUN mvn clean package -DskipTests 193 + 194 + FROM eclipse-temurin:25-jre 195 + WORKDIR /app 196 + COPY --from=build /app/target/timbernetes-demo-1.0-SNAPSHOT.jar app.jar 197 + EXPOSE 8080 198 + ENTRYPOINT ["java", "-XX:MinRAMPercentage=80.0", "-XX:MaxRAMPercentage=80.0", "-jar", "app.jar"] 199 + ``` 200 + 201 + Lorsque la JVM démarre, elle viendra prendre 80% de la RAM disponible pour la Heap. 202 + 203 + En faisant un test rapide avec Docker, je peux vérifier que mes métriques sont correctes, en contraignant le nombre de CPU et la RAM visibles par le container : 204 + 205 + ```shell 206 + docker image build -t timbernetes-demo . 207 + 208 + docker container run --rm --cpus=2 --memory=512m -p 8080:8080 timbernetes-demo 209 + ``` 210 + 211 + ```http request 212 + GET localhost:8080/metrics 213 + 214 + cpu_count 2.0 215 + process_cpu_load 0.2646484375 216 + jvm_memory_max_mb 396.375 217 + jvm_memory_used_mb 4.075630187988281 218 + ``` 219 + 220 + ## Instancier un cluster sur Scaleway 221 + 222 + Pour pouvoir expérimenter et jouer avec ces features, j'ai choisi d'utiliser un cluster que j'instancie sur Scaleway. 223 + Ça me permet de valider un vrai comportement de production, là où utiliser un _minikube_ ou un _kind_ en local pourrait avoir des comportements différents. 224 + 225 + Armé de mon meilleur _CLI_, j'enchaine donc les commandes. 226 + 227 + Je commence par lister les versions disponibles. 228 + 229 + ```bash 230 + $ scw k8s version list 231 + NAME AVAILABLE CNIS AVAILABLE CONTAINER RUNTIMES 232 + 1.35.2 [cilium cilium_native calico kilo none] [containerd] 233 + 1.34.5 [cilium cilium_native calico kilo none] [containerd] 234 + 1.33.9 [cilium calico kilo none] [containerd] 235 + 1.32.13 [cilium calico kilo none] [containerd] 236 + ``` 237 + 238 + La version 1.35.2 est celle qui m'intéresse aujourd'hui, je vais donc pouvoir déployer un cluster avec cette version : 239 + 240 + ```bash 241 + # création du cluster 242 + $ scw k8s cluster create name=timbernetes-demo version=1.35.2 243 + 244 + ID 100d3564-66b2-4439-bcc2-b5e76cd6d1fb 245 + Type kapsule 246 + Name timbernetes-demo 247 + Status creating 248 + Version 1.35.2 249 + Region fr-par 250 + ClusterURL https://100d3564-66b2-4439-bcc2-b5e76cd6d1fb.api.k8s.fr-par.scw.cloud:6443 251 + DNSWildcard *.100d3564-66b2-4439-bcc2-b5e76cd6d1fb.nodes.k8s.fr-par.scw.cloud 252 + CreatedAt now 253 + UpdatedAt now 254 + UpgradeAvailable false 255 + PrivateNetworkID 5bfa5834-48fc-41bd-8d47-5f0c1059522c 256 + CommitmentEndsAt now 257 + ACLAvailable true 258 + IamNodesGroupID - 259 + PodCidr 100.64.0.0/15 260 + ServiceCidr 10.32.0.0/20 261 + ServiceDNSIP 10.32.0.10 262 + ``` 263 + 264 + Le cluster est créé immédiatement. Les paramètres par défaut sont suffisants pour mes tests. 265 + 266 + Le cluster apparaît dans la console : 267 + 268 + ![scaleway-console-cluster-starting](scaleway-console-cluster-starting.png) 269 + 270 + Une fois le cluster créé, il faut lui ajouter un _node-pool_, avec une petite machine _DEV1-M_ (3CPU et 4G de RAM) qui sera bien suffisante pour mes test : 271 + 272 + ```bash 273 + # création du node-pool 274 + $ scw k8s pool create cluster-id=100d3564-66b2-4439-bcc2-b5e76cd6d1fb name=timbernetes-demo-pool node-type=DEV1-M size=1 275 + 276 + ID 8a27e395-19d7-439c-88ae-0a2d81680321 277 + ClusterID 100d3564-66b2-4439-bcc2-b5e76cd6d1fb 278 + CreatedAt now 279 + UpdatedAt now 280 + Name timbernetes-demo-pool 281 + Status scaling 282 + Version 1.35.2 283 + NodeType dev1_m 284 + Autoscaling false 285 + Size 1 286 + MinSize 0 287 + MaxSize 1 288 + ContainerRuntime containerd 289 + Autohealing false 290 + Zone fr-par-1 291 + RootVolumeType l_ssd 292 + RootVolumeSize 40 GB 293 + PublicIPDisabled false 294 + SecurityGroupID edf33b11-933e-473c-9f36-f86cd3da1037 295 + Region fr-par 296 + ``` 297 + 298 + Après quelques minutes, le cluster est dispo : 299 + 300 + ![img.png](scaleway-console-cluster-up.png) 301 + 302 + ![img.png](scaleway-console-nodepool-up.png) 303 + 304 + Je peux générer mon fichier `kubeconfig`, et vérifier que tout fonctionne bien : 305 + 306 + ```bash 307 + $ scw k8s kubeconfig get 100d3564-66b2-4439-bcc2-b5e76cd6d1fb > kubeconfig.yaml 308 + 309 + kubectl get nodes 310 + 311 + NAME STATUS ROLES AGE VERSION 312 + scw-timbernetes-dem-timbernetes-demo-po-fd96dc Ready <none> 1m55s v1.35.2 313 + ``` 314 + 315 + Je vais aussi avoir besoin d'un container registry pour y stocker l'image de mon application, je le crée en une commande : 316 + 317 + ```bash 318 + $ scw registry namespace create name=timbernetes-demo 319 + ID 16244ac8-828b-4b5d-a15e-d7508330c3ec 320 + Name timbernetes-demo 321 + Description - 322 + Status ready 323 + StatusMessage - 324 + Endpoint rg.fr-par.scw.cloud/timbernetes-demo 325 + IsPublic false 326 + Size 0 B 327 + CreatedAt now 328 + UpdatedAt now 329 + ImageCount 0 330 + Region fr-par 331 + ``` 332 + 333 + ![img.png](scaleway-console-registry.png) 334 + 335 + J'authentifie mon CLI Docker au registry avec un `docker login` : 336 + 337 + ```bash 338 + $ docker login rg.fr-par.scw.cloud/timbernetes-demo -u nologin --password-stdin <<< "$SCW_SECRET_KEY" 339 + ``` 340 + 341 + Puis, je pousse mon image sur le registry : 342 + 343 + ```bash 344 + $ docker tag timbernetes-demo rg.fr-par.scw.cloud/timbernetes-demo/java:latest 345 + 346 + $ docker push rg.fr-par.scw.cloud/timbernetes-demo/java:latest 347 + ``` 348 + 349 + ![img.png](scaleway-console-image.png) 350 + 351 + Tout est prêt pour pouvoir déployer l'application et lancer les tests. 352 + 353 + ## Déployer l'appli 354 + 355 + Pour déployer l'application, rien de plus simple, je déploie un simple pod : 356 + 357 + ```yaml 358 + apiVersion: v1 359 + kind: Pod 360 + metadata: 361 + name: timbernetes-demo 362 + labels: 363 + app: timbernetes-demo 364 + spec: 365 + containers: 366 + - name: timbernetes-demo 367 + image: rg.fr-par.scw.cloud/timbernetes-demo/java:latest 368 + ports: 369 + - containerPort: 8080 370 + name: metrics 371 + - containerPort: 8081 372 + name: stress 373 + resources: 374 + limits: 375 + cpu: "1" 376 + memory: "512Mi" 377 + requests: 378 + cpu: "1" 379 + memory: "512Mi" 380 + ``` 381 + 382 + ```bash 383 + $ kubectl apply -f pod.yaml 384 + 385 + pod/timbernetes-demo created 386 + ``` 387 + 388 + Je démarre avec un unique CPU et 512Mo de RAM. 389 + Une fois le pod déployé, j'ouvre 2 ports pour pouvoir appeler les métriques, et déclencher les tests. 390 + 391 + ```bash 392 + $ kubectl port-forward timbernetes-demo 8080:8080 8081:8081 393 + 394 + Forwarding from 127.0.0.1:8080 -> 8080 395 + Forwarding from [::1]:8080 -> 8080 396 + Forwarding from 127.0.0.1:8081 -> 8081 397 + Forwarding from [::1]:8081 -> 8081 398 + ``` 399 + 400 + Je regarde les métriques à froid : 401 + 402 + ```http request 403 + GET localhost:8080/metrics 404 + 405 + cpu_count 1.0 406 + process_cpu_load 1.4755859375 407 + jvm_memory_max_mb 396.375 408 + jvm_memory_used_mb 4.081321716308594 409 + ``` 410 + 411 + Le CPU unique affecté au pod est bien visible, ainsi que les 512Mo de RAM, donc 80% sont alloués à la Heap (les 400Mo visibles donc). 412 + 413 + Il est temps de lancer les tests. 414 + 415 + ## Les tests 416 + 417 + Pour ces tests, je vais suivre le scénario suivant : 418 + 419 + * Lancer un premier stress-test avec un dimensionnement de 1 CPU et 512Mo RAM 420 + * Modifier la taille du pod pour le passer à 2CPU et 1Go de RAM 421 + * Relancer un stress-test 422 + * Remodifier la taille du pod pour revenir à 1 CPU et 512Mo de RAM 423 + * Relancer un dernier stress-test 424 + 425 + Je m'attends à voir le nombre de CPU modifiés, et les résultats des tests adaptés en fonction. Par contre, pour la RAM, je m'attends à ce qu'il ne se passe rien, puisque la RAM consommée par la JVM est fixée au redémarrage, allouer de la RAM supplémentaire sera donc inutile. 426 + 427 + ### Premier tir 428 + 429 + Je démarre le premier tir avec un curl : 430 + 431 + ```http request 432 + GET localhost:8081/stress/start 433 + 434 + 200 OK 435 + Started benchmark 436 + ``` 437 + 438 + Pendant le premier Benchmark, CPUStress, le CPU est bien chargé, la RAM ne bouge pas : 439 + 440 + ```shell 441 + cpu_count 1.0 442 + process_cpu_load 0.80859375 443 + jvm_memory_max_mb 396.375 444 + jvm_memory_used_mb 5.6450958251953125 445 + ``` 446 + 447 + Pendant le second Benchmark, on voit que la RAM se rempli, et est nettoyée une fois pleine : 448 + 449 + ```shell 450 + cpu_count 1.0 451 + process_cpu_load 0.875 452 + jvm_memory_max_mb 396.375 453 + jvm_memory_used_mb 353.49063873291016 454 + ``` 455 + 456 + Le benchmark donne les résultats suivants : 457 + 458 + ```text 459 + Benchmark Mode Cnt Score Error Units 460 + CPUStress.fibonacciCalculation thrpt 5 1853767.056 ± 303895.231 ops/s 461 + MemoryStress.allocateMemory thrpt 5 44.607 ± 2.511 ops/s 462 + ``` 463 + 464 + Cela nous fait un point de départ. 465 + 466 + ### Redimensionnement du pod et deuxième tir 467 + 468 + C'est là que ça devient rigolo. 469 + 470 + On commence par redimensionner le pod pour qu'il prenne 2 CPU et 1Go de RAM, avec un `kubectl patch` : 471 + 472 + ```bash 473 + $ kubectl patch pod timbernetes-demo --subresource resize --patch \ 474 + '{"spec":{"containers":[{"name":"timbernetes-demo","resources":{"limits":{"cpu":"2","memory":"1Gi"},"requests":{"cpu":"2","memory":"1Gi"}}}]}}' 475 + 476 + pod/timbernetes-demo patched 477 + ``` 478 + 479 + On peut lister les events du pod pour voir que le resizing est bien fait, et que le pod n'a pas été redémarré, la modification est bien faite à chaud : 480 + 481 + ```bash 482 + & kubectl events --for pod/timbernetes-demo 483 + LAST SEEN TYPE REASON OBJECT MESSAGE 484 + 17m Normal NotTriggerScaleUp Pod/timbernetes-demo pod didn't trigger scale-up: 485 + 17m Warning FailedScheduling Pod/timbernetes-demo no nodes available to schedule pods 486 + 17m (x4 over 17m) Warning FailedScheduling Pod/timbernetes-demo no nodes available to schedule pods 487 + 16m Normal Scheduled Pod/timbernetes-demo Successfully assigned default/timbernetes-demo to scw-timbernetes-dem-timbernetes-demo-po-1b7caa 488 + 16m Normal Pulling Pod/timbernetes-demo Pulling image "rg.fr-par.scw.cloud/timbernetes-demo/java:latest" 489 + 15m Normal Pulled Pod/timbernetes-demo Successfully pulled image "rg.fr-par.scw.cloud/timbernetes-demo/java:latest" in 14.487s (27.916s including waiting). Image size: 109821985 bytes. 490 + 15m Normal Created Pod/timbernetes-demo Container created 491 + 15m Normal Started Pod/timbernetes-demo Container started 492 + 113s Normal ResizeStarted Pod/timbernetes-demo Pod resize started: {"containers":[{"name":"timbernetes-demo","resources":{"limits":{"cpu":"2","memory":"1Gi"},"requests":{"cpu":"2","memory":"1Gi"}}}],"generation":2} 493 + 112s Normal ResizeCompleted Pod/timbernetes-demo Pod resize completed: {"containers":[{"name":"timbernetes-demo","resources":{"limits":{"cpu":"2","memory":"1Gi"},"requests":{"cpu":"2","memory":"1Gi"}}}],"generation":2} 494 + ``` 495 + 496 + ## Liens et références 497 + 498 + Kubernetes : 499 + * [Release](https://kubernetes.io/blog/2025/12/17/kubernetes-v1-35-release/) note de Kubernetes 1.35 : Timbernetes 500 + * La [KEP #1287](https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/1287-in-place-update-pod-resources) In-Place Update of Pod Resources 501 + * L'article de blog pour promouvoir la feature : [Kubernetes 1.35: In-Place Pod Resize Graduates to Stable](https://kubernetes.io/blog/2025/12/19/kubernetes-v1-35-in-place-pod-resize-ga/) 502 + * La page de documentation [Resize CPU and Memory Resources assigned to Containers](https://kubernetes.io/docs/tasks/configure-pod-container/resize-container-resources/) 503 + * La page de documentation [Resize CPU and Memory Resources assigned to Pods](https://kubernetes.io/docs/tasks/configure-pod-container/resize-pod-resources/) 504 + Scaleway : 505 + * La documentation Scaleway : [Kapsule & Kosmos release calendar](https://www.scaleway.com/en/docs/kubernetes/reference-content/version-support-policy/#scaleway-kubernetes-kapsule--kosmos-release-calendar) 506 + * La documentation du CLI Scaleway : [Creating and managing a Kubernetes Kapsule with CLI (v2)](https://www.scaleway.com/en/docs/kubernetes/api-cli/creating-managing-kubernetes-lifecycle-cliv2/) 507 + * [Scaleway Instances datasheet](https://www.scaleway.com/en/docs/instances/reference-content/instances-datasheet/) 508 + JMH : 509 + * Le tuto de Baeldung [Microbenchmarking with Java](https://www.baeldung.com/java-microbenchmark-harness)
content/posts/2026/2026-03-20-timbernetes/scaleway-console-cluster-starting.png

This is a binary file and will not be displayed.

content/posts/2026/2026-03-20-timbernetes/scaleway-console-cluster-up.png

This is a binary file and will not be displayed.

content/posts/2026/2026-03-20-timbernetes/scaleway-console-image.png

This is a binary file and will not be displayed.

content/posts/2026/2026-03-20-timbernetes/scaleway-console-nodepool-up.png

This is a binary file and will not be displayed.

content/posts/2026/2026-03-20-timbernetes/scaleway-console-registry.png

This is a binary file and will not be displayed.