Kubernetes!

Kubernetesen futtatni JVM-ben futó alkalmazást nem mindig teljesen egyszerű. Pár tapasztalat, hátha lesz, akinek hasznos.

Volt egy alkalmazásunk, a neve legyen mondjuk example-application. Viszonylag egyszerű app:

  • induláskor benyalja pár MySQL query eredményét egy in-memory h2 adatbázisba
  • nyújt egy REST API-t, amin keresztül a h2-ből lehet kérdezgetni dolgokat
  • naponta 1x újra benyalja az adatokat a MySQL-ből, hátha frissültek

Java, SpringBoot alkalmazás, 11-es language level-t igényel, 13-as JVM-en futott éppen, egy Kubernetes podban. A Kubernetes EKS, rajta az AWS Load-Balancer Controller nyújtotta az ingress-eket, instance módban működve. A pod memory limitje 8 GB volt, a JVM pedig nem kapott külön paramétereket, simán

java -jar example-application-2.1.0.jar

futott. A JVM-ben futott egy actuator, és a pod readinessProbe-ja az actuatorra volt kötve, livenessProbe-ja nem volt külön.

 

Na, ez megdöglött, de nagyon több szinten és nagyon csúnyán, így visszanézve szégyellem is, ezért leírom, hogy mi a baj a fentebbi setuppal. Aki akar, itt álljon neki gondolkodni, hogy mi a törékeny fentebbiekben.

 

Egyrészt a JVM dobott egy szép OOM-et, pontosabban "java.lang.OutOfMemoryError: Java heap space" üzenetet. Mindezt úgy, hogy a pod memóriafoglalása kb. 2.5 GB volt, a pod limitje meg 8 GB - és a Kubernetes node-on más nem futott. De miért???

Az van, hogy a Java 10 után jól beharangozták, hogy most már aztán nagyon okos a JVM, és nem a futtató host, hanem a futtató konténer (még pontosabban a konténer cgroup-ja) határozza meg a default memória limiteket és minden jó lesz így automatikusan.

Aha, persze. Tényleg így van, a cgroup mérete az alap - viszont a Java heap méretét alapból a cgroup memóriaméretének 25%-ára állítja be ez a hülye. Azaz esetünkben volt 2 GB heap, 0.5 GB egyéb (metaspace meg ilyenek), és ennyi. Az interneten senki nem érti, hogy miért lett ez 25%, ami teljesen rossz default, de az lett. Itt a JVM kódjában a megfelelő részlet, ami kalkulál:

https://hg.openjdk.java.net/jdk-updates/jdk11u/file/a7f53869e42b/src/hotspot/share/runtime/arguments.cpp#l1750

Namármost, ezt a hibát szépen meg lehetett oldani úgy, hogy a JVM-nek adtunk egy

-XX:MaxRAMPercentage=75

kapcsolót. Szépen el is kezdett 2.5 GB helyett kb. 4 GB memóriát enni, ennél több meg nem is kell neki, lehet majd lentebb venni a limitet, ugye...

 

Van egy olyan cucc, hogy Vertical Pod Autoscaler. Ez (többek között) azt tudja, hogy figyel egy podot, hogy mennyi memóriát eszik, és ha több van allokálva neki, mint amennyit ténylegesen eszik, akkor újrarúgja a podot, alacsonyabb limitekkel. És mivel a hülye JVM a rendelkezésre álló memória %-ában foglal heap-et, így az alacsonyabb limit mellett újrarúgott konténer megint kevesebbet fog enni, úgyhogy megint újra kell rúgni, amitől megint kevesebbet eszik, ... amíg nem lesz OOM megint. Sigh.

Szóval ilyen esetekben vagy VPA disable erre a podra, vagy legyen fix -Xmx megadva, de az meg olyan csúnya.

 

És a történetnek még nincs teljesen vége, ugyanis picit pontosabban az történt, hogy amikor OOM lett, akkor leállt a JVM-ben az actuator threadje, a readiness átbillent false-ba, de maga a REST kiszolgálás szépen működött tovább (!) - legalábbis a podon belülről. De kívülről semmi se működött. Az ALB-n látszott, hogy a mögöttes target groupban mindenki unhealthy, de az is látszott a logjában, hogy ennek ellenére round-robin módon minden unhealthy targetnek küldött kéréseket, mert az AWS-es ALB így működik. Ha működik a kiszolgálás, és működik előtte a load-balancer, akkor miért nem megy az egész? Nos, a readinessProbe miatt. Mivel a podok nem voltak ready-k, így a Kubernetesig eljutott a kérés, de nem proxy-zta be magának a konténernek, hanem egy szép timeout lett a vége. Tök idegesítő (szerintem), hogy az AWS és a Kubernetesesek két különböző módon kezelik a "semmi se healthy/ready" kérdéskört.

A megoldás itt nyilván az application lifecycle helyes kezelése - azaz legyen readiness és liveness probe is külön-külön, legyen shutdown handler, ami a readinessProbe-ot átbillenti false-ba + a livenessProbe ekkor maradjon true.

 

A Kubernetes a megoldás minden problémára, de főleg arra, hogy még egy darabig legyen fizetésem.

Hozzászólások

Ha nem tévedek a readinessProbe-ot csak addig használja amíg sikeresen el nem indul a pod, onnantól kezdve már csak a livenessProbe játszik. De mivel általában ugyanaz a kettő csak az időzítésekben és retry logikában tér el, így lehet hogy ezt rosszul tudom.

A VPA-t én kerülöm. Nálunk csak javaslatot tehet, de magát a pod-ot nem rúghatja újra. Főleg hogy elég hosszú ideig csak mér és aztán akkor csap le amikor nem várod. A javaslatokat a fejlesztő csapatok havonta, két havonta átfutják és reszelnek a resource igényeken ha kell. Ha megoldható érdemesebb horizontálisan skálázódni HPA-val, de abban is vannak vicces dolgok. Amikor a metrics-server megdöglött a HPA kérése hibára futott, de ezzel nem törődött és úgy számolta hogy minden pod 0% CPU-n pörög, szóval lehet lejjebb menni...

Nem írtad hogy NodePort vagy AWS VPC CNI és direkt pod címzés - az EKS miatt ez utóbbira gondolok - de mindkét esetben az ALB-ben meg kell adnod egy külön healthcheck eljárást. Az ALB nem tudja hogy ki/mi van mögötte az instance-on, ő csak azt tudja hogy itt van az instance - abból meg van az IP cím - és ezen a path-on tudja ellenőrizni hogy él-e még a szerviz. Ha csak unhealthy target van akkor - ha jól emlékszem - mindegyikre eldobja a kérést, rosszabb már nem lehet alapon. Emiatt találtunk olyan TargetGroup-okat amikben soha nem volt egyetlen egy healthy instance sem, mert a fejlesztő elírta a health check path-t.

Rosszul tudod. ReadinessProbe mindig megy, hiszen az alapjan donti el, hogy Service-tol kaphat-e a Pod workloadot (beregisztralodik-e mint Endpoint). Nyilvan ha ugyanazt figyeli mindket Probe, akkor nincs jelentosege, mert hiba eseten ugyis ujra van inditva a Pod. De lehetnek olyan specialis esetek, hogy a ReadinessProbe bukik, mig LivenessProbe jo eredmenyt ad.

De lehetnek olyan specialis esetek, hogy a ReadinessProbe bukik, mig LivenessProbe jo eredmenyt ad.
 

Ezt hogy kell elképzelni? Tudnál mondani egy szemléletes példát? Arra tudok gondolni, hogy a readiness csak azt ellenőrzi minimalisztikusan, hogy a service elindult-e rendesen, míg a liveness azt is, hogy a DB, meg egyéb kapcsolatok felépültek-e. De így se igazán értem, hogy hogy bukhat a readiness, míg a liveness jó.

Nekem is az volt az érzésem, hogy a readiness csak service induláskor, míg a liveness a service futásakor ellenőrzi, hogy healthy-e a service; de k8s-ben nem(sem) vagyok expert, szóval lehet én értettem félre valamit.

szerk: megoldás lentebb.

Mondok még egy szemléleteset, amihez nem feltétlenül kell nagy load. Tegyük fel, hogy a Kubernetes le akarja állítani a pododat (mert mondjuk másik node-ra akarja átpakolni). Ilyenkor szól, hogy "hé, leállás lesz!", majd vár egy kicsit, majd szépen megöli a pod processzét.

A pod, ha szépen csinálja, akkor a "hé, leállás lesz!" mondásra úgy reagál, hogy elmegy "not ready" állapotba, a még nála lévő kéréseket szépen kiszolgálja, aztán vagy leáll magától, vagy megvárja a kill-t. Ilyenkor van úgy, hogy a readiness már nem él, a liveness viszont még él.

És egyébként ezért érdemes a load-balancer health-checkjét kimondottan a readiness probe-ra kötni, így lesz szinkronban a load-balancer és a Kubernetes.

De lehetnek olyan specialis esetek, hogy a ReadinessProbe bukik, mig LivenessProbe jo eredmenyt ad.

Persze, hogy lehet, főleg ha heavy load alatt van az appod:

Sometimes, applications are temporarily unable to serve traffic. For example, an application might need to load large data or configuration files during startup, or depend on external services after startup. In such cases, you don't want to kill the application, but you don't want to send it requests either. Kubernetes provides readiness probes to detect and mitigate these situations.

Míg liveness csupán azt nézi, hogy az alkalmazás amúgy válaszol e, mert ha nem akkor esélyesen újra kell rúgni:

The kubelet uses liveness probes to know when to restart a container. For example, liveness probes could catch a deadlock, where an application is running, but unable to make progress. Restarting a container in such a state can help to make the application more available despite bugs.

Ez miatt például érdemesebb is külön (!) endpointokat használni (amiket külön thread szolgál ki) liveness és readiness probe-okhoz, különben egy komolyabb load simán megakasztja a liveness-t és restartoltatja a POD-ot totál feleslegesen.

Az embert 2 éven át arra tanítják hogyan álljon meg a 2 lábán, és hogyan beszéljen... Aztán azt mondják neki: -"Ülj le és kuss legyen!"..

Három probe van a Kubernetesnél:

  • startup --> ez szól arról, hogy induláskor mikortól élő a szolgáltatásod, picit pontosabban amíg a startup probe fut, addig a readiness és a liveness check nem fut
  • readiness --> ez akkor igaz, ha az alkalmazásod éppen képes kéréseket fogadni, a Kubernetes ez alapján adja oda vagy nem adja oda a kéréseket egy futó podnak
  • liveness --> ez akkor igaz, ha az alkalmazásod él, és ha nem él, akkor a Kubernetes próbál tenni annak érdekében, hogy éljen, pl. újraindítja

A VPA legalább recommend módban tud hasznos lenni. Ha megkérdezed a fejlesztőt, hogy "mennyi memória kell a pododnak?" akkor sokszor csak néz bután, vagy azt mondja, hogy "a gépemben van 32 GB RAM, azon fut, annyi biztos jó lesz" - miközben az alkalmazásnak ténylegesen kellene mondjuk 3.2 GB. Az automatikusan beavatkozó VPA tényleg megfontolást igényel, ebben egyetértek.

NodePort volt itt jelen esetben (instance típusa volt az Ingress-nek), de ez mindegy, az ALB mindkét esetben pont úgy működik, ahogy leírtad.

-Xmx -et én a helyedben kerülném, mert ha POD resource-ot akarod piszkálni, akkor folyamatosan hozzá kell igazítani a JAVA_ARGS-ot is. Ilyen szempontból a -XX:MaxRAMPercentage generikusabb tud lenni.

VPA meg szintén kerülendő, ez az ökoszisztéma nem vertikális skálázásra lett kitalálva és kész (mondjon nekem bárki bármit). Horizontálisan szépen lehet skálázni, de ahhoz meg persze az kell, hogy az alkalmazás (cluster) is úgy legyen designolva, hogy támogassa az ad-hoc bejövő / eltűnő application cluster membereket..

Az embert 2 éven át arra tanítják hogyan álljon meg a 2 lábán, és hogyan beszéljen... Aztán azt mondják neki: -"Ülj le és kuss legyen!"..

Egyébként igen, az alkalmazásnak kell támogatnia ezt a működést... :/

Egyik szenvedésem most ez: https://github.com/jhuckaby/Cronicle#installation - eléggé verem a fejem a falba amiatt, hogy mennyire nem Kubernetes-kompatibilis ez, de semmi kedvem csak ezért a vacakért beállítani két EC2-t. (Cloudwatch Events vagy Kubernetes CronJob nem jó most, kell a szép GUI ahhoz, amit csinálok.)

Még azért van egy olyan hátulütője a cgroupnak, hogy a memória limitbe a file system cache-t is beleszámolja. Érdemes ezt figyelni akkor, ha az alkalmazásunk használna filesystem cache-t, akkor annyival nagyobb limitet állítsunk be neki.

Extrém esetben még olyan is előfordulhat, hogy malloc hibát dob az alkalmazás, mert az oprendszer nem tudta elég gyorsan felszabadítani a filesystem cachet.

Tanácsként megjegyzem, a threadek számát is limitáljátok, mert nem csak a heap tud elfogyni.