Acum câteva luni, în martie, Accesa a venit la mine cu o oportunitate interesantă. Cu cea de-a 20-a aniversare a firmei. Dar înainte de marea petrecere, pe 6 iunie urma să aibă loc un alt tip de eveniment, Tech Conference. Pentru acest eveniment, am avut ocazia să ținem o prezentare tehnică despre DevOps. Așa începe povestea noastră. 25 de minute, un PoC... trebuia să fie ceva special!
Din fericire pentru mine, în acea perioadă am aflat despre cea mai recentă carte despre Kubernetes a lui Nigel Poulton, prin amabilitatea unuia dintre colegii mei. Capitolul 9 al acestei cărți mi-a atras atenția: WebAssembly on Kubernetes.
La acea vreme nici nu auzisem de WebAssembly... așa că m-am întrebat: "Ce e chestia asta?".
Browserul este un instrument fascinant. El ne permite să ne facem treaba, să găsim cărți online, să ne uităm la emisiunile noastre preferate și multe altele. Și ce limbi vorbește browserul? HTML, CSS și JavaScript. Noi ne vom concentra pe ultima dintre ele.
JavaScript este uimitor la ceea ce face cu toate acestea, dar nu este grozav când vine vorba de sarcini de lucru computaționale de înaltă performanță. Acesta este unul dintre motivele pentru care ne este foarte dificil să ne jucăm jocurile noastre video preferate în browser.
Acum să ne imaginăm o lume în care am putea rula aplicații de jocuri video scrise în C/C++ sau Rust în browser. Destul de dificil, nu? Aceste limbaje se compilează în cod mașină și, prin urmare, nu sunt portabile.
Niște oameni foarte deștepți s-au gândit la aceeași lume în urmă cu șapte ani și nu au fost mulțumiți de situație, așa că s-au gândit la o soluție. Ce ar fi dacă am compila codul nostru scris în C/C++ sau Rust într-un low-level bytecode, nu direct în cod mașină?
Mai mult decât atât, ce ar fi dacă acel bytecode ar putea fi interpretat de browser? Asta ar însemna că putem să ne luăm implementarea din C/C++ sau Rust, să compilăm în acel bytecode și apoi să o rulăm în browser la o performanță aproape nativă.
Formatul de bytecode pe care l-am menționat este acum cunoscut sub numele de WebAssembly (abreviat ca WASM). De la începuturile sale, au avut loc multe progrese și în prezent există compilatoare care pot viza WASM din mai multe limbaje.
Acum vom lua o decizie radicală și vom abandona WASM (pentru moment).
Dacă suntem ingineri software, sunt șanse mari să fi folosit Docker pentru a ne containeriza aplicația cel puțin o dată. Cum am făcut asta? Probabil așa: am scris un Dockerfile, am construit imaginea cu docker build -t my-cool-image:latest și apoi am rulat-o cu docker run my-cool-image:latest. Iată prima noastră aplicație containerizată!
Totuși, pe măsură ce Tech Conference-ul se apropia cu pași repezi, am început să sap mai adânc pentru a mă pregăti și curând mi-am dat seama că, de fapt, nu știu nimic despre containere...
Unul dintre cele mai mari mituri este că Docker este ceea ce ne oferă containere. De fapt, Docker nu ne-a oferit niciodată containere. Adevărul este că prietenul nostru, Linux, este cel care o face.
Sursă img. Fermyon Developer
În următoarele câteva minute, vreau să uitați tot ceea ce știți despre Docker și containere.
Primul lucru de care trebuie să ne amintim este că totul este un proces! Jocul video, browserul și aplicațiile care rulează în containere sunt toate procese. Dar atunci, ce face containerele să difere de procesele normale care rulează pe host? Izolarea.
Procesele care rulează în interiorul containerelor sunt izolate! Deci, cum realizează Kernelul Linux această izolare atunci când creează un container? Există doar două concepte despre care trebuie să știți: control groups și namespaces.
Când rulăm containerul, noi specificăm un ENTRYPOINT sau un CMD, care este ceea ce va executa noul nostru proces.
FROM openjdk:23-slim
COPY my-application-1.0-SNAPSHOT.jar
/home/my-application-1.0-SNAPSHOT.jar
CMD ["java","-jar",
"/home/my-application-1.0-SNAPSHOT.jar"]
Dar cum este creat acest nou proces? Răspunsul este: la fel ca orice alt proces, folosind fork().
Acum, după fork(), avem un proces părinte și un proces copil (care este procesul containerizat). Ce se întâmplă dacă procesul copil apelează getppid() (obține ID-ul procesului părinte)? Apelul de sistem va returna identificatorul de proces al părintelui. Asta e rău! Nu dorim ca procesul nostru containerizat să știe despre nimic din afara containerului. Prin urmare, trebuie să facem ca procesul copil să creadă că nu are părinte, așa că setăm ID-ul de proces părinte la NULL folosind prctl(). De asemenea, în imagine puteți vedea că procesul copil are 2 ID-uri (8 și 1). Acest lucru îi este datorat contextului. În interiorul containerului este procesul 1, dar din punctul de vedere al gazdei, este procesul 8.
Acesta este tipul de problemă de care sunt responsabile namespace-urile. Ele ne spun ce poate vedea procesul!
Grupurile de control ne spun cât de mult poate folosi procesul. În exemplul de mai jos, parte a unui manifest YAML al unui Deployment Kubernetes, putem vedea limitele de resurse ale containerului meu.
containers:
- name: myapp
image: campionulclujului/resource-app:latest
command: ["/"]
resources:
limits:
cpu: "0.5"
memory: "256Mi"
Există destul de multe grupuri de control și dacă doriți să vă aprofundați în fiecare dintre ele, urmăriți acest videoclip grozav de pe YouTube.
Dacă Kernelul Linux ne oferă tot ce avem nevoie pentru a crea containere (namespaces și cgroups), atunci ce face Docker? Deși este adevărat că putem crea containere folosind doar apeluri de sistem Linux, este extrem de dificil. Docker oferă o modalitate foarte ușoară de a crea containere Linux. Nu numai asta, dar Docker vine și cu un CLI, un serviciu de autentificare (la Docker Hub) și multe alte utilități.
De aceea, a devenit atât de popular și și-a depășit toți concurenții la început! A fost foarte ușor de utilizat pentru ingineri software. Tot ce trebuie să facem este să rulăm o mică comandă și dintr-odată avem o aplicație containerizată care rulează pe host.
Cu toate acestea, pe măsură ce Cloud Native Computing Foundation (CNCF) a evoluat și Kubernetes a fost adoptat pe scară largă, au început să apară preocupări în ceea ce privește Docker. Deși a fost un instrument excelent pentru dezvoltatori pentru a rula local, avea o mulțime de funcționalități inutile în producție. Când facem deploymentul aplicației noastre containerizate în Kubernetes, ne pasă doar de container runtime, care era doar o mică parte din întregul pachet Docker.
Pe măsură ce cursa pentru containerizare s-a intensificat, oamenii au început să-și dorească runtime-uri diferite. Până atunci, Kubernetes era strâns cuplat cu Docker. Soluția a fost simplă: creăm un Container Runtime Interface (CRI) și lăsăm pe oricine să vină cu propria implementare.
Ca urmare a acestor evoluții, Docker a trebuit să reacționeze. Așadar, cei de la Docker și-au împărțit arhitectura monolitică pentru a se potrivi cu specificațiile Open Container Initiative (OCI). Container runtime-ul rezultat a devenit cunoscut sub numele de containerd.
Putem vedea câteva componente despre care nu am vorbit în imagine. Să începem cu runc. Scopul runc este de a configura mediul containerului (cgroups, namespaces), de a începe procesul de container și apoi de a ieși (exit).
Ce se întâmplă după ce runc iese? Cine va gestiona containerul acum? Responsabilitățile sale sunt trecute la containerd-shim. După cum putem vedea, ciclul de viață al acestei componente se potrivește cu ciclul de viață al containerului. Când containerul este ucis, shimul moare odată cu el.
Motivul pentru care avem un shim este că oferă un strat suplimentar de decuplare. Putem folosi diferite tipuri de shim. În imagine, două containere folosesc containerd-shim implicit, în timp ce al treilea folosește ceva diferit (poate că numele îi sună familiar).
Nu în ultimul rând, la cel mai înalt nivel, avem demonul containerd care are rolul de runtime al containerului de nivel înalt pe acel nod.
În sfârșit, am ajuns la finalul jocului. Am promis că ne vom întoarce la WASM și iată-ne.
Știm că containerele sunt foarte eficiente. Ele ne permit să izolăm aplicația noastră cu dependențele sale. Prin urmare, să luăm, de exemplu, o aplicație Java.
FROM openjdk:23-slim
COPY my-application-1.0-SNAPSHOT.jar /home/my-application-1.0-SNAPSHOT.jar
CMD ["java","-jar", "/home/my-application-1.0-SNAPSHOT.jar"]
Acest Dockerfile poate părea familiar, asta pentru că este același pe care l-am folosit anterior. Dar de data aceasta, vreau să ne concentrăm pe prima linie. Ce face acea instrucțiune FROM? Specifică o imagine de bază. De ce avem nevoie de o imagine de bază? Deoarece bytecode-ul nostru Java trebuie interpretat și are nevoie de un mediu de rulare. Asta ne oferă acel openjdk:23-slim.
Ok, e grozav. Cu toate acestea, ce se întâmplă atunci când trebuie să ne extindem aplicația containerizată? Vom avea 100 de instanțe ale containerului nostru, fiecare dintre ele având propriul său JDK. Acesta este overhead! Practic, trebuie să creăm același mediu de rulare de 100 de ori.
Acum ce-ar fi să ne imaginăm o lume în care nu avem nevoie de un mediu de rulare în interiorul containerului, o lume în care putem configura mediul de rulare în afara containerului, unde poate fi utilizat de toate containerele noastre?
"Dar, Mircea... asta e imposibil! Acest lucru ar necesita ca toate aplicațiile să fie scrise în același limbaj, același format."
Dar dacă am putea într-adevăr să facem asta? Într-un final, înțelegem de ce WebAssembly a fost atât de important în toată această poveste. Dacă ne putem compila toate aplicațiile, indiferent de limbajul în care sunt scrise, în bytecode WASM, atunci putem standardiza mediul de rulare. Exact asta au gândit un alt grup de oameni inteligenți când au venit cu ideea unui WebAssembly System Interface (WASI), o interfață pentru runtime-uri de WebAssembly.
În această imagine, putem vedea cum containerele nu mai au nevoie să își creeze propriul mediu de rulare (biblioteci, dependențe, bins). Tot ce rămâne în containerul (modulul) WASM este bytecode-ul, în forma sa cea mai pură.
Acest lucru nu numai că ne scapă de overhead, dar este și mult mai sigur, deoarece reduce suprafața de atac. Dacă nu există niciun mediu în interiorul containerului (nicio mini distribuție Linux), atunci nu există nimic de atacat. Dimensiunea containerului WASM este, de asemenea, mult mai mică, ceea ce îl face mai ușor și mai simplu de pornit, ideal pentru arhitectura serverless.
Microsoft Azure a anunțat suport pentru grupurile de noduri WASI în clusterele lor Azure Kubernetes Service (AKS). Sunt încă în previzualizare, dar viitorul pare promițător.
Suportul pentru limbi suplimentare este în creștere, așa cum se poate vedea în matricea de limbi pe care am analizat-o anterior.
Interfața de sistem WebAssembly a văzut deja o serie de implementări. Unele dintre ele includ wasmedge, wasmtime, wasmer și altele.
Serviciile serverless, cum ar fi Azure Functions, nu au încă un runtime pentru WASM, dar prevedem că nu va trece mult timp până când acest lucru va deveni realitate.
Cum ne rulăm containerele WASM pe un Windows host? Recomand să instalați Docker Desktop, deoarece oferă suport Beta pentru containere WASM.
Mai întâi, accesăm Settings -> General și verificăm opțiunea Use containerd for pulling and storing images.
Apoi, accesăm Settings -> Features in development și verificați funcția Enable Wasm. Aceasta va instala următoarele runtime-uri:
Acum, suntem gata să rulăm primul nostru container WASM:
docker run --runtime=io.containerd.wasmedge.v1 --platform=wasi/wasm secondstate/rust-example-hello
Recomand acest tutorial de la Microsoft, care trece prin toate condițiile preliminare pentru a avea un pool de noduri WASI în AKS și chiar ne arată cum să implementăm o aplicație simplă.
În primul rând, trebuie să instalăm compilatorul potrivit, care va prelua codul scris în C/C++, Rust, Java sau orice alt limbaj acceptat și îl va compila în bytecode WASM. Apoi, singurul alt fișier de care avem nevoie este un TOML (Tom's Obvious Minimal Language), care este un fișier de configurare care îi spune runtime-ului WASM cum să ruleze modulul WASM. Asta e tot!
După ce am obținut bytecode-ul WASM, creăm un Dockerfile și copiem cele două fișiere (.wasm și .toml).
FROM scratch
COPY /target/wasm32-wasi/release/myapp_wasm.wasm .
COPY spin.toml .
Putem observa că instrucțiunea FROM nu folosește o imagine de bază tipică Linux (scratch este o imagine de bază goală)!
Acum putem construi imaginea.
docker build --platform wasi/wasm --provenance=false -t /mywasmapp:0.1 .
Apoi o încărcăm pe Docker Hub.
docker push <your_docker_hub_account>/mywasmapp:0.1
Înainte de a da deploy în Kubernetes, trebuie să creăm o clasă de rulare.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: rc-spin
scheduling:
nodeSelector:
wasm: "yes"
handler: spin
Acum ne putem folosi imaginea pentru a crea un Deployment,
apiVersion: apps/v1
kind: Deployment
metadata:
name: mywasmapp
spec:
runtimeClassName: rc-spin
containers:
- name: mywasmapp
image: /mywasmapp:0.1
command: ["/"]
resources:
limits:
cpu: "0.1"
memory: 128Mi
de Mihai Darie
de Vlad Petrean