Lo sviluppo lato backend negli ultimi anni è cambiato radicalmente per chi si è lasciato affascinare e coinvolgere da Docker e tutto quello che gli ruota intorno. Per molti, me in primis, ha semplificato notevolmente non solo la modalità di accesso e interazione con i sistemi attorno all’applicazione che stiamo sviluppando, come il database o il broker di code, ma soprattutto ha permesso di rendere più semplici e finalmente alla portata la scrittura e il mantenimento di test di integrazione (come avevamo già avuto modo di vedere).

Questa ondata è arrivata ad investire anche l’unità di deploy: se prima si deployavano EAR, WAR o JAR eseguibili, adesso si deployano immagini Docker! Se molto è cambiato però intorno al nostro codice, quest’ultimo è rimasto abbastanza fermo, quasi indietro. Il salto infatti da Java 8 alle versioni successive (che adesso stanno correndo!) non è affatto indolore, soprattutto se si ha a che fare con progetti anche solo di un paio d’anni o prodotti di terze parti, aggiornati nelle feature ma non nella versione della JVM supportata. La voglia di “containerizzare” anche loro non manca: è difficile ormai rinunciare alla comodità che tutto l’ambiente “buildato” dalla continuous integation va così com’è in UAT e magari in produzione!!
Lo scripting degli ambienti e la loro “replicabilità” ha reso tutto il nostro lavoro, a mio parere, molto più industriale e meno artigianale.

Se è estremamente facile quindi creare una immagine Docker che contiene la nostra applicazione basata su Java 8, non è altrettanto facile scoprire che qualcosa non quadra quando si impongono dei limiti di risorse al container e la JVM non li rispetta!

Docker e le risorse limitate: perché farlo?

Finché usiamo Docker per il supporto allo sviluppo, non abbiamo certo bisogno di limitare le risorse ai container. Quando però intendiamo usarlo in produzione, dobbiamo cominciare a pensare seriamente a quanti core e quanta memoria ha bisogno la nostra applicazione per funzionare bene, e non di più. Come fa quindi Docker a fare questo?

Facciamo un breve passo indietro. Sin dalla sua nascita, si è sempre rimarcata la differenza tra un container e una virtual machine, con grafici di questo tipo:

Dove nella realtà spesso il Docker Engine (stack a destra) gira dentro un Guest OS (a sinistra): le due tecnologie quindi convivono molto bene. Infatti Docker ti dà l’illusione di avere un file system, una rete, CPU e RAM solo per te, ma in realtà non fa altro che isolare processi sfruttando due caratteristiche nel kernel linux che sono cgroups e namespaces (e tante magie sull’iptables). Detta proprio in soldoni, il primo regola l’assegnazione delle risorse come CPU e RAM ad un processo, il secondo isola il processo, il file system e la rete.

E qui arriva il bello: se fino adesso usare Docker con Java 8 non aveva destato nessun sospetto, appena si cominciano ad applicare i limiti imposti da cgroups cominciano le sorprese, che purtroppo sono molto subdole. Come andremo a vedere a breve, è infatti noto che le prime JVM 8 (dall’update 121 in giù) ignorano totalmente i vincoli imposti da cgroups per quanto riguarda Memoria e CPU, creando inattesi problemi.

Il primo supporto ufficiale è stato introdotto dall’update 131 in poi, ma bisogna comunque stare attenti e capire che parametri passare alla JVM. Per stare tranquilli bisognerebbe passare a Java 10+, ma se siamo qua a parlarne è proprio perché non lo possiamo fare!

Perché però imporre dei limiti al container Docker? E’ uno scenario da cui non si può prescindere se andremo a deployare la nostra immagine all’interno di un cluster (gestito da Swarm o Kubernetes per esempio) dove risiederanno altre decine (se non di più) di container che si contendono le risorse del nodo in cui girano: limitarne quindi la visibilità è fondamentale per la sopravvivenza di tutti.

Java 8, Docker, CPU e Memoria

Cominciamo quindi da un semplice progetto Java 8 basato su Spring Boot che crea un’immagine Docker grazie al plugin docker-maven-plugin di Fabric8. Il progetto in oggetto si chiama jvm-info ed è disponibile sul nostro GitHub.

Prerequisiti

Al momento della scrittura del post, sto usando:

  • Docker for Mac versione 18.06.1-ce, in Swarm Mode.
  • La VM di Docker ha 4 CPU e 2 Gb di RAM, limiti fondamentali per comprendere quello che verrà dopo.

Una volta clonato il progetto da GitHub, è possibile eseguire la build delle immagini con un semplice:

mvn clean install

Perché “immagini al plurale”? Questo progetto crea in realtà due immagini:

  • cnj/openjdk-jvm-info basata sull’immagine standard openjdk:8u181-jdk-slim-stretch, jdk 1.8u181.
  • cnj/fabric8-jvm-info basata sull’immagine fabric8/java-centos-openjdk8-jdk (jdk 1.8u181) che già è a conoscenza di questi problemi e implementa le soluzioni. La possiamo usare come metro di paragone per capire che parametri passare alla JVM

La memoria

Il problema

Cominciamo quindi da un semplice stack sull’immagine cnj/openjdk-jvm-info.

src/main/openjdk/stack-memory-default.yml

version: '3.7'

services:
    jvm-info:
      image: cnj/openjdk-jvm-info
      ports:
        - "8080:8080"
      environment:
        JAVA_OPTS:  >
          -XX:+PrintFlagsFinal
          -XX:+PrintGCDetails
      deploy:
        resources:
          limits:
            memory: 1280

Lasciando il container così,

docker stack deploy -c src/docker/openjdk/stack-memory-default.yml openjdk

la JVM stampa la sua configurazione (grazie a -XX:+PrintFlagsFinal e -XX:+PrintGCDetails) e prende i suoi valori di default, per cui la memoria dovrebbe essere 1/4 della totale (governato dal parametro -XX:MaxRAMFraction=4 di default sulle JVM Server a 64 bit con più di 1Gb di RAM), ovvero 320Mb.

Diamo un’occhiata ai log dell’applicazione e vediamo l’-Xmx calcolato (che corrisponde al parametro MaxHeapSize:

docker service -f logs openjdk_jvm-info | grep MaxHeapSize

Scopriamo che MaxHeapSize := 524288000 byte, ovvero 500Mb, che guarda caso è 1/4 di 4Gb, ovvero della macchina host, non dei limiti che ho imposto al container! Per comodità è disponibile l’endpoint http://localhost:8080/jvm che riporta anche le informazioni della memoria: sono meno precise, ma l’ordine di grandezza è sempre quello.

Ma non avevamo detto che dall’update 131 potevamo stare tranquilli? Si, a patto di passare particolari parametri alla JVM: -XX:+UnlockExperimentalVMOptions e -XX:+UseCGroupMemoryLimitForHeap

stack-memory-unlock-experimentals.yml

version: '3.7'

services:
    jvm-info:
      image: cnj/openjdk-jvm-info
      ports:
        - "8080:8080"
      environment:
        JAVA_OPTS: >
          -XX:+UnlockExperimentalVMOptions
          -XX:+UseCGroupMemoryLimitForHeap
          -XX:+PrintFlagsFinal
          -XX:+PrintGCDetails
      deploy:
        resources:
          limits:
            memory: 1280M

Aggiornando lo stack con la nuova configurazione

docker stack deploy -c src/docker/openjdk/stack-memory-default.yml openjdk
docker service -f logs openjdk_jvm-info | grep MaxHeapSize

Dopo qualche secondo vedremo che comparirà una nuova linea di log, relativa al nuovo container che è stato creato: questa volta riporta MaxHeapSize := 335544320 byte, ovvero esattamente 320Mb, cioè 1/4 di 1280Mb.

In rete si trovano molte prove più approfondite: date un occhio per esempio al blog di Red Hat in merito.

La soluzione

Abilitando quindi i parametri -XX:+UnlockExperimentalVMOptions e -XX:+UseCGroupMemoryLimitForHeap, la JVM comincia a comportarsi come si deve. Normalmente però si tende ad impostare manualmente il valore della memoria della JVM che vogliamo, sapendo come best practice che i valori di -Xms e -Xmx è bene che siano uguali per una questione di performance.

Come regola principale, consigliata da Red Hat, è importante sottolineare che il limite dato al Container deve essere quasi il doppio della heap. Questo banalmente perché il modello di memoria della JVM non è formato solo dall’heap: per cui se questo è uguale al limite del container, non c’è più spazio per il resto, Docker rileva che si sta eccedendo le risorse dedicate e uccide il container.

Assodato quindi il rapporto “Docker resource limit” e “JVM heap” possiamo procedere in due modi:

  • definiamo esplicitamente i parametri della JVM -Xms e -Xmx come la metà di quelli che diamo ai limiti di Docker, in modo da avere tolleranza:
    version: '3.7'
    
    services:
        jvm-info:
          image: cnj/openjdk-jvm-info
          ports:
            - "8080:8080"
          environment:
            JAVA_OPTS: >
              -XX:+UnlockExperimentalVMOptions
              -XX:+UseCGroupMemoryLimitForHeap
              -Xms640m
              -Xmx640m
          deploy:
            resources:
              limits:
                memory: 1280M
    

    Teoricamente in questo caso non servirebbe l'”unlock experimental”, ma è bene che la JVM capisca dove sta girando.

  • lasciamo che sia la JVM a calcolare i limiti, specificando però che la minima e la massima dimensione della heap siano la metà del limite imposto, grazie alle proprietà -XX:InitialRAMFraction=2 e -XX:MaxRAMFraction=2:
    version: '3.7'
    
    services:
        jvm-info:
          image: cnj/openjdk-jvm-info
          ports:
            - "8080:8080"
          environment:
            JAVA_OPTS: >
              -XX:+UnlockExperimentalVMOptions
              -XX:+UseCGroupMemoryLimitForHeap
              -XX:InitialRAMFraction=2
              -XX:MaxRAMFraction=2
          deploy:
            resources:
              limits:
                memory: 1280M
    

    vediamo che InitialHeapSize (che corrisponde a -Xms) e MaxHeapSize (che corrisponde a -Xmx) hanno lo stesso valore

    docker service logs -f jvm_jvm-info | grep -Ei "InitialHeapSize|MaxHeapSize"
    

    pari a 512Mb.

Risolta la memoria, vediamo cosa possiamo fare per la CPU.

I core

Questo caso è più difficile da diagnosticare, o meglio: è più difficile capire se la JVM sta considerando i limiti di CPU o meno, dal momento che quando si usa il “cpu quota e sharing” (come probabilmente fa Swarm) non possiamo fare affidamento sul classico Runtime.getRuntime().availableProcessors() perché c’è un bug risolto solo a partire dalla JVM 9. In realtà non è cosa da poco perché molte API si basano su questo valore per dimensionare i thread pool, come per il garbage collector o il fork-join.

Questa cosa effettivamente mi ha mandato in confusione: la risposta di http://localhost:8080/jvm relativa ai core sarà sempre 4 (quelli dell’host) avviando lo stack in Swarm mode, nonostante tutti i cambiamenti che possiamo fare, ma effettivamente notiamo delle differenze di performance variando il limiti della cpu. L’unico caso in cui sembra si abbia un valore corretto è quando si avvia un container singolo con il parametro --cpuset-cpus:

docker run --name openjdk -p 8080:8080 -d --cpuset-cpus=0-1 cnj/openjdk-jvm-info

Purtroppo, l’avvio di un container in questo modo non è applicabile ad un contesto di produzione: non è gestito da un orchestratore e non scala. Se si avvia un container in questo modo è molto probabile che non abbiamo bisogno di impostare i limiti.

In questa modalità però (dove si assegnano esplicitamente i core) gli availableProcessors risultano effettivamente 2 e i parametri dei pool del GC e del JIT sono corretti: è probabile quindi che la modalità Swarm faccia riferimento al concetto di cpu quota e sharing, per cui la JVM non è in grado di determinare i core esatti.

Il problema

Come nel caso della memoria, anche i limiti della CPU sembra che vengano ignorati, almeno in parte. Da cosa si deduce? Partiamo dalla situazione standard e limitiamo l’uso di un core:

version: ‘3.7’

services:
    jvm-info:
      image: cnj/openjdk-jvm-info
      ports:
        - "8080:8080"
      environment:
        JAVA_OPTS: >
          -XX:+PrintFlagsFinal
          -XX:+PrintGCDetails
      deploy:
        resources:
          limits:
            cpus: '1'

Il contaniner ci mette veramente tanto ad avviarsi: quasi 12 secondi! Qualcosa quindi sta succedendo, ma da http://localhost:8080/jvm risultano sempre 4 core. Vediamo allora come si sono dimensionati i thread pool del garbage collector:

docker service logs -f jvm_jvm-info | grep ParallelGCThreads

In accordo con la documentazione ufficiale, sarebbero dovuti essere 2, invece sono 4 (come i core dell’host).

Passando a 1.5 core, l’avvio impiega 9 secondi, mentre a 2 core 5 secondi: sembra che effettivamente la JVM risponda ai limiti in termini di performance, ma i core “contati” sono sempre 4 e questo fa sballare il dimensionamento di alcuni parametri. Non ci resta quindi che reimpostarli manualmente!

La soluzione

E’ qua che la seconda immagine generata dal progetto ci viene in aiuto! Cercando una soluzione in rete infatti si trova tanto materiale che è necessario testare e sintetizzare ad un certo punto. Partendo quindi dal post “OpenJDK and Containers” e dalle slide di “Why you’re going to fail running Java on Docker” (per citarne due) si riesce a tirar fuori una serie di parametri per la JVM da mettere in accordo ai core impostati come limiti, dal momento che in modalità Swarm non avviene da sé.

L’immagine base fabric8/java-centos-openjdk8-jdk sembra implementare queste considerazioni e crea automaticamente una serie di parametri per la JVM.

Avviando infatti lo stack definito da

src/docker/fabric8/stack-cpu.yml

version: '3.7'

services:
    jvm-info:
      image: cnj/fabric8-jvm-info
      ports:
        - "8081:8080"
      environment:
        JAVA_OPTIONS: >
          -XX:+PrintFlagsFinal
          -XX:+PrintGCDetails
        JAVA_MAX_CORE: 3
      deploy:
        resources:
          limits:
            cpus: '3'

dove si specifica che la variabile JAVA_MAX_CORE == resources.limits.cpus, vengono generati per noi una serie di flag per JVM a partire proprio da questo valore. Andando su http://localhost:8081/jvm, mi colpiscono i seguenti “input arguments“:

-XX:ParallelGCThreads=3
-XX:ConcGCThreads=3
-Djava.util.concurrent.ForkJoinPool.common.parallelism=3
-XX:CICompilerCount=2
-XX:+UseParallelGC
-XX:+ExitOnOutOfMemoryError

Dove -XX:ParallelGCThreads, -XX:ConcGCThreads e -Djava.util.concurrent.ForkJoinPool.common.parallelism coincidono con i core.
Usare -XX:+ExitOnOutOfMemoryError può essere interessante, così, nel caso di out-of-memory, il container di ferma e Docker ne avvia uno nuovo.

Conclusioni

Abbiamo visto come la JVM 8 (>= 131) si possa adattare in qualche modo ai limiti imposti da Docker al processo Java. Il “problema” maggiore è quello legato ai core, che non vengono calcolati correttamente quando si limita il processore in quota, in percentuale. Assegnare invece dei core specifici sembra funzionare (ricordate --cpuset-cpus?), ma non è supportato né dalla modalità Swarm di Docker, né da Kubernetes, quindi i casi di applicabilità reale si riducono drasticamente.

Limitare le risorse è un’operazione importante da tenere in considerazione nel momento in cui si va a deployare un’immagine in un ambiente ad alta concorrenza dove girano tanti container. Da prove sperimentali trovate in rete, sembra che fino a 3/4 JVM per nodo non abbiamo grandi impatti, ma per numeri maggiori la concorrenza diventa tanta e soprattutto a livello di CPU si comincia a pagare il context switch. Tenendo a mente quindi che sono prove sperimentali difficilmente verificabili, meglio usufruire dei limiti offerti da Docker, ricordandoci di impostare un set di parametri per la JVM congruo con i limiti:

src/docker/openjdk/stack.yml

version: '3.7'

services:
    jvm-info:
      image: cnj/openjdk-jvm-info
      ports:
        - "8080:8080"
      environment:
        JAVA_OPTS: >
          -XX:+UnlockExperimentalVMOptions
          -XX:+UseCGroupMemoryLimitForHeap
          -XX:InitialRAMFraction=2
          -XX:MaxRAMFraction=2
          -XX:ParallelGCThreads=3
          -XX:ConcGCThreads=3
          -XX:CICompilerCount=2
          -Djava.util.concurrent.ForkJoinPool.common.parallelism=3
          -XX:+ExitOnOutOfMemoryError
      deploy:
        resources:
          limits:
            memory: 1024M
            cpus: '3'

Nel caso di core >= 2, -Djava.util.concurrent.ForkJoinPool.common.parallelism, -XX:ConcGCThreads e -XX:ParallelGCThreads andranno impostati pari al numero di core. Se invece i core < 2, conviene sostituire il GC parallelo con quello seriale, togliendo -XX:ConcGCThreads e -XX:ParallelGCThreads in favore di -XX:+UseSerialGC.

Se poi avete la fortuna di poter saltare all’ultima versione di Java (ad oggi la 11), non avrete bisogno di nessuno di questi parametri perché la JVM adesso è “cgroups aware“.

83 Posts

Sono un software engineer focalizzato nella progettazione e sviluppo di applicazioni web in Java. Presso OmniaGroup ricopro il ruolo di Tech Leader sulle tecnologie legate alla piattaforma Java EE 5 (come WebSphere 7.0, EJB3, JPA 1 (EclipseLink), JSF 1.2 (Mojarra) e RichFaces 3) e Java EE 6 con JBoss AS 7, in particolare di CDI, JAX-RS, nonché di EJB 3.1, JPA2, JSF2 e RichFaces 4. Al momento mi occupo di ECM, in particolar modo sulla customizzazione di Alfresco 4 e sulla sua installazione con tecnologie da devops come Vagrant e Chef. In passato ho lavorato con la piattaforma alternativa alla enterprise per lo sviluppo web: Java SE 6, Tomcat 6, Hibernate 3 e Spring 2.5. Nei ritagli di tempo sviluppo siti web in PHP e ASP. Per maggiori informazioni consulta il mio curriculum pubblico. Follow me on Twitter - LinkedIn profile - Google+