Pentru instalarea Docker pe Ubuntu urmăriți procedura de aici [1]. Suplimentar față de pașii descriși anterior, rulați comanda:
sudo usermod -aG docker $USER
desprinsă din documentația [2]
Întrucât, preponderența claselor din anii anteriori a fost cu precădere orientată pe multe din aspectele complexe ale dezvoltării de software, în laboratorul de azi ne propunem să oferim o altă perspectivă în conceperea unei aplicații sau a unei soluții. Aspecte precum volumul de date de procesat, toleranța la defecte, portabilitatea sau scalabilitatea sunt constrângeri esențiale care trebuie tratate în procesul de implementare a unei soluții sau aplicații.
Standardele și cerințele dictate de industrie se focalizează din ce în ce mai mult pe adoptarea modelului de cloud, menit, de cele mai multe ori, să ofere un șablon/pattern care să adreseze parțial sau integral constrângerile (sau oportunitățile) menționate anterior.
Laboratorul curent își propune o introducere pragmatică în modele clasice de system design & architecture încercând să atingă gradual mici elemente de complexitate, care pot aduce un plus de valoare unei aplicații în contextul de cloud.
Modelele prezentate pot fi puncte de plecare importante pentru parcurgerea unor interviuri de Site Reliability Engineer, Cloud Engineer sau DevOps Engineer.
Mai mult, laboratorul deservește, în egală măsură, o introducere în Docker, Docker Networking & Docker Orchestration, facilitând implementarea graduală a unui serviciu web, de la concept până într-o formă care ar putea răspunde unor cerințe reale de producție. Laboratorul se adresează în egală măsură atât oamenilor cu înclinații către dezvoltare cât și oamenilor care se orientează spre poziții operaționale.
Ben Treynor VP of Engineers @ Google și inventatorul termenului Site Reliability Engineer încercând să definească conceptul: “Fundamentally, it’s what happens when you ask a software engineer to design an operations function”
Înainte de a pătrunde în tainele containerizarii cu Docker, vom încerca să definim un model arhitectural care să se încadreze în tipul de aplicație pentru care Docker devine o unealtă importantă.
Este greșit să plecăm de la premisa că o abordare este mai bună decât cealaltă, fiecare fiind o opțiune optimă în contextul potrivit. O abordare monolită implică dezvoltarea întregului stack de aplicație pe un singur nod, un singur sistem, în timp ce, în cadrul modelului bazat pe microservicii, fiecare unitate funcțională va avea propriul mediu izolat în care rulează. Gradul de rezoluție pentru definirea unității funcționale poate varia, de la viziunea macro, cu 2 componente: Frontend - Backend, până la nivelul de granularitate prin care putem avea fiecare feature al aplicației dezvoltat ca un microserviciu (ca un obiect rulând individual).
O comparație minimală între cele două modele arhitecturale, reliefând aspecte pozitive și problematice în ambele abordări, poate fi văzută în tabelul următor:
Arhitecturi Monolite | Arhitecturi bazate pe Microservicii |
---|---|
Modulele unei aplicații sunt Tightly Coupled (ex: Comunicare Inter Proces - IPC) | Serviciile unei aplicații sunt Loosely Coupled (ex: communicare peste rețea utilizând mesaje HTTP via REST API microservices) |
Fiecare funcționalitate nouă trebuie scrisă în același limbaj de programare. | Fiecare serviciu component al aplicației poate fi scris într-un limbaj diferit. |
Modificarea codului presupune recompilarea și reinstalarea (redeployment-ul) întregii aplicații. | Modificarea unui serviciu/feature necesită reinstalarea acelui serviciu, celelalte rămânând intacte și operaționale. |
Simplu de dezvoltat. | Poate aduce un nivel de complexitate mult prea mare (ex: pentru fiecare serviciu trebuie construit un API care să faciliteze comunicația între microservicii) |
Scalarea aplicației se aplică întregului stack. | Procedurile de scalare pot fi aplicate pe servicii individuale (eg: multiplicarea serviciului de Frontend al unui magazin online în timpul reducerilor de Black Friday) |
Abordarea pe care vom merge în continuare este una bazată pe servicii, cu amendamentul că unitățile funcționale vor fi construite la un nivel de rezoluție macro: Frontend și Backend, la care vom atașa și o bază de date. Să presupunem că avem o cerere din partea unui angajator de a dezvolta un site de stocare și partajare de imagini. Trebuie să ne asigurăm că acesta îndeplinește un set minimal de caracteristici, înainte de a fi lansat în producție:
Aplicația construită pentru laborator va aborda o arhitectură simplificată, bazată pe servicii, Vom încerca să utilizăm Docker pentru a ne asigura că îndeplinim cât mai multe din cerințele date. Înainte de a parcurge cerințele proiectului, ne vom familiariza cu tehnologia de containerizare.
host
) folosiți comenzile următoare din contul utilizatorului root
de pe stația host
(puteți da copy/paste la comenzi în terminal):root@host:~# update_lab --force root@host:~# start_lab lab6
host
) sunt (username:parola
):
root:student
student:student
root
pentru conectare pe toate stațiile. Aveți nevoie de drepturi privilegiate pentru configurare. Folosiți contul student
doar unde vi se cere explicit.
Play With Docker (PWD) este un mediu online dedicat învățării și testării Docker. Oferă mașini virtuale gratuit în limita a 4h/sesiune. Reprezintă un bun loc de început interacțiunea în lumea Docker și permite utilizatorilor să ruleze comenzile Docker fără a fi necesare instalări sau configurări suplimentare. Oferă experiența de a avea o mașină virtuală Alpine Linux gratuită în browser, unde puteți construi și rula containere Docker și chiar crea clustere.
Pentru a începe interacțiunea cu PWD utilizați linkul: https://labs.play-with-docker.com/. Vă puteți autentifica folosind contul vostru de Dockerhub: https://hub.docker.com/
Odată contul creat, urmați pașii: “Login” (with docker account) → “Start”.
Pentru început, adăugați o singură instanță așa cum se poate observa în imaginea atașată mai jos:
Odată lansată o instanță, va trebui să fiți direct conectati la ea. Interacțiunea făcându-se direct în consola atașată instanței creată anterior.
Pentru a accesa public o aplicație care ruleaza în Play with Docker (PWD) este nevoie să folosiți portul expus. Astfel veți putea observa în exercițiile următoare pagina creată, similar ca în imaginea de mai jos:
Acest lucru este posibil deoarece PWD crează un tunel public ce permite expunerea portului afișat.
Docker este un runtime de containerizare modern, care aduce în componența sa uneltele necesare împachetării unei aplicații, și a dependințelor acesteia, într-un obiect numit container. Asemănător LXC, Docker utilizează mecanismele native din Kernel-ul sistemului de operare care permit rularea izolată a unei aplicații, fără ca aceasta să interacționeze sau să influențeze procesele sistemului gazdă sau procesele aflate în execuție în alte containere. Dintr-o perspectivă mai pragmatică, putem considera un container ca fiind o multiplicare a user-space-ului (terminologia utilizată în sistemelor de operare monolitice)
Ca în orice început din IT, vom porni cu Hello, world!
student@aldebaran:~$ docker run hello-world Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world 1b930d010525: Pull complete Digest: sha256:4df8ca8a7e309c256d60d7971ea14c27672fc0d10c5f303856d7bc48f8cc17ff Status: Downloaded newer image for hello-world:latest Hello from Docker! This message shows that your installation appears to be working correctly. To generate this message, Docker took the following steps: 1. The Docker client contacted the Docker daemon. 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. (amd64) 3. The Docker daemon created a new container from that image which runs the executable that produces the output you are currently reading. 4. The Docker daemon streamed that output to the Docker client, which sent it to your terminal. To try something more ambitious, you can run an Ubuntu container with: $ docker run -it ubuntu bash Share images, automate workflows, and more with a free Docker ID: https://hub.docker.com/ For more examples and ideas, visit: https://docs.docker.com/get-started/
Să verificăm ce se întâmplă. Pentru a vedea containerele care sunt în running state:
student@aldebaran:~$ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Lista este goală. Pentru a vedea toate containerele, atât cele în running state cât și cele oprite:
student@aldebaran:~$ docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 4bedb1ae0304 hello-world "/hello" 4 minutes ago Exited (0) 4 minutes ago vibrant_wu
Voila!
Să încercăm să rulăm un container de Alpine Linux, una din distribuțiile lightweight de Linux existente.
student@aldebaran:~$ docker run alpine Unable to find image 'alpine:latest' locally latest: Pulling from library/alpine 89d9c30c1d48: Pull complete Digest: sha256:c19173c5ada610a5989151111163d28a67368362762534d8a8121ce95cf2bd5a Status: Downloaded newer image for alpine:latest student@aldebaran:~$ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES student@aldebaran:~$ docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 7e1f217f73d4 alpine "/bin/sh" 38 seconds ago Exited (0) 35 seconds ago cool_cray 4bedb1ae0304 hello-world "/hello" 6 minutes ago Exited (0) 6 minutes ago vibrant_wu
Container-ul este oprit. Motivul pentru care se întâmplă acest lucru se datorează faptului că ciclul de viață al unui container este 1 la 1 cu ciclul de viață al aplicației care rulează în acel container. Astfel, în cazul în care aplicația își finalizează execuția, container-ul aferent își va încheia și el rularea. Pentru a vedea ce proces rulează în mod implicit în container putem folosi docker inspect: docker inspect <nume sau id container>
Pentru simplitate, putem obține informația căutată utilizând filtre (--format):
docker inspect <nume sau id container> --format='{{.Config.Cmd}}
'
student@aldebaran:~$ docker run -dit --name infinite-alpine alpine b3bbcf9fcd8c265cf96e50c885c10ff68b597e4ba987f7e78630c094432f14bc
Putem schimba, totodată, comanda care rulează în container:
docker run -dit --name sleep-alpine alpine sleep 10000
Și putem atașa sesiunea curentă de shell unui container aflat în execuție:
student@aldebaran:~$ docker exec -it infinite-alpine /bin/sh / # ls bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
id
ip a
ps -ef
sau ps aux
hostname
env
exit
În continuare vom vorbi despre conceptul de Docker Images.
Făcând o analogie cu lumea sistemelor de operare, putem considera un Docker image ca fiind similar unui binar - o componenta statică ce poate fi lansată în execuție. Un Docker container este asemănător procesului, o componenta dinamică care este o instanță aflată în execuție a unei imagini Docker.
Pentru început, să listăm imaginile stocate local. În lista desfășurată veți vedea imaginile pe care le-am utilizat în secțiunea 1:
student@aldebaran:~$ docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE alpine latest 965ea09ff2eb 6 weeks ago 5.55MB hello-world latest fce289e99eb9 11 months ago 1.84kB
Ca strategie, Docker își propune să ofere o bibliotecă extrem de largă de servicii gata configurate, împachetate sub formă de containere. În cazul în care ne dorim să căutăm o aplicație, un middleware, un runtime etc. care ne-ar fi utile pentru a rezolva o cerință sau a dezvolta o soluție complexă, putem căuta în upstream dacă există deja o astfel de imagine dezvoltată. Pentru a face acest lucru avem diferite metode, atât din CLI cât și din browser. Implicit, engine-ul docker va căuta în DockerHub registry, echivalentul GitHub pentru imagini Docker. În terminologia Docker, un registry este un repository de imagini Docker: docker search <nume imagine cautata>
docker search alpine
docker search httpd
docker search tomcat
docker search --filter is-official=true golang
Online putem accesa: https://hub.docker.com Pentru informații despre imagine de Alpine: https://hub.docker.com/_/alpine Meniul tags este utilizat pentru diferențierea imaginilor pe versiuni/pe releases: https://hub.docker.com/_/alpine?tab=tags
Să creăm o imagine pornind de la imaginea oficială Alpine cu versiunea de release latest. În folder-ul home al utilizatorului curent creați un fișier numit Dockerfile
și scrieți următoarele 2 linii:
student@aldebaran:~$ cat Dockerfile FROM alpine:latest CMD ["echo", "Am facut si bune am facut si RL"]
Se poate observa ușor forma declarativă, auto-descriptivă, a instrucțiunilor Docker.
Urmând același flux ca în cazul construcției de software, vom compila codul Docker anterior scris, rezultatul fiind un obiect de tip Docker Image. Timpul de execuție poate varia și poate dura chiar și câteva minute.
student@aldebaran:~$ docker build -t rlrules/rlhello:v1.0 ./
Înainte de a merge mai departe, să disecăm succint convenția de nume anterior folosită. Numele imaginii Docker are în general următorul format:
<DockerHub User (Repository) >/<Nume Aplicație (Nume Imagine)>:<tag (versiune, release)>
rlrules/rlhello:v1.0
Respectarea unui astfel de format ne permite ulterior să putem face operațiuni de push sau pull asemănător acțiunilor pe care le putem face în GitHub/git server pentru un repository în care putem versiona și stoca codul.
În continuare vom rula imaginea anterior creată:
student@aldebaran:~$ docker run rlrules/rlhello:v1.0 Am facut si bune am facut si RL
Utilizarea instucțiunii CMD, din Dockerfile, ne oferă, suplimentar, capabilitatea suprascrierii la runtime a comenzii specificate în declarație. Să testăm:
student@aldebaran:~$ docker run rlrules/rlhello:v1.0 "hello, world" docker: Error response from daemon: OCI runtime create failed: container_linux.go:346: starting container process caused "exec: \"hello, world\": executable file not found in $PATH": unknown.
Eroarea apărută se datorează faptului că “hello, world” nu este o comandă viabilă în Linux și deci nu este interpretată de către shell-ul utilizat (ex: bash, sh etc.)
student@aldebaran:~$ docker run rlrules/rlhello:v1.0 cat /etc/issue Welcome to Alpine Linux 3.10 Kernel \r on an \m (\l) student@aldebaran:~$ docker run rlrules/rlhello:v1.0 echo "hello, world" hello, world
Cele două comenzi vor rula cu succes, prima afișând o informație de pe container în timp ce a doua afișează mesajul transmis comenzii echo
: hello, world
Să modificăm imaginea anterioară înlocuind instrucțiunea CMD cu instrucțiunea ENTRYPOINT.
student@aldebaran:~$ cat Dockerfile FROM alpine:latest ENTRYPOINT ["echo", "Am facut si bune am facut si RL"]
În etapa de build vom construi un nou release v2.0:
student@aldebaran:~$ docker build -t rlrules/rlhello:v2.0 ./
Rularea release-ului v2.0 va avea același outcome ca în cazul versiunii anterioare :
student@aldebaran:~$ docker run rlrules/rlhello:v2.0
Rezultatul utilizării celor 2 instrucțiuni (CMD și ENTRYPOINT) este același! Care este totuși diferența între CMD și ENTRYPOINT?
Pentru a vedea acest lucru, să încercăm să rulăm imaginea nouă utilizând, de data aceasta, argumente:
student@aldebaran:~$ docker run rlrules/rlhello:v2.0 "hello, world" Am facut si bune am facut si RL hello, world student@aldebaran:~$ docker run rlrules/rlhello:v2.0 cat /etc/issue Am facut si bune am facut si RL cat /etc/issue student@aldebaran:~$ docker run rlrules/rlhello:v2.0 echo "hello, world" Am facut si bune am facut si RL echo hello, world
Vedem că în cazul ENTRYPOINT, indiferent de argumentul adăugat la finalul comenzii docker run, acesta va fi anexat la comanda setată în instrucțiunea din Dockerfile. Întrucât comanda configurată este echo <string>
, șirurile de caractere adăugate ulterior vor fi atașate stringului “Am făcut și bune, am făcut și RL”. Într-o descriere mai pragmatică, ENTRYPOINT imprimă rolul unui executabil clasic asupra imaginii docker.
Un mecanism frecvent utilizat este conjuncția dintre cele două instrucțiuni. Putem folosi ENTRYPOINT pentru a desemna comanda fixă, explicit atașată containerului, în timp ce instrucțiunea CMD oferă argumentul default, interschimbabil, al comenzii definite de ENTRYPOINT.
Vom modifica imaginea astfel încât să reflecte scenariul definit anterior:
student@aldebaran:~$ cat Dockerfile FROM alpine:latest ENTRYPOINT ["echo"] CMD ["Am facut si bune, am facut si RL"]
Comanda echo este fixă, imutabilă, în timp ce string-ul Am făcut și bune, am făcut și RL
devine valoare implicită în cazul în care niciun argument suplimentar nu este specificat în comanda docker run.
Compilăm și rulăm noua imagine:
student@aldebaran:~$ docker build -t rlrules/rlhello:v3.0 ./ student@aldebaran:~$ docker run rlrules/rlhello:v3.0 Am facut si bune, am facut si RL student@aldebaran:~$ docker run rlrules/rlhello:v3.0 "hello, world" hello, world student@aldebaran:~$ docker run rlrules/rlhello:v3.0 cat /etc/issue cat /etc/issue student@aldebaran:~$ docker run rlrules/rlhello:v3.0 echo "hello, world" echo hello, world
Lista instrucțiunilor Docker nu se rezumă doar la FROM sau CMD/ENTRYPOINT. Pentru configurații mai complexe există o colecție largă de comenzi ce pot fi declarate la nivelul unui Dockerfile. Referințe despre aceste opțiuni se pot găsi pe site-ul Docker (https://docs.docker.com/engine/reference/builder/)
În continuare vom crește complexitatea și vom construi un Dockerfile extins:
student@aldebaran:~$ cat Dockerfile FROM ubuntu:latest MAINTAINER rl-awesome-students-team RUN apt-get update && apt-get -y install cowsay ENV RL_EXEC_PATH "/usr/games/" ENV PATH=${PATH}:${RL_EXEC_PATH} ENTRYPOINT ["cowsay"] CMD ["Am facut si bune, am facut si RL"]
Vom compila și rula imaginea nou definită:
student@aldebaran:~$ docker build -t rlrules/rlhello:v4.0 ./
student@aldebaran:~$ docker run rlrules/rlhello:v4.0
student@aldebaran:~$ docker run rlrules/rlhello:v4.0 "Hello, world"
În cadrul configurației curente, plecând de la imaginea de Ubuntu:latest, la build time am instalat pachetul cowsay, am setat variabile de mediu, am specificat maintainer-ul imaginii și am configurat un parametrul implicit, în cazul în care imaginea este rulată fără parametrii adiționali. Complexitatea poate crește, în funcție de acțiunile pe care trebuie să le facem pentru a prepara containerul cu toate dependințele de care are nevoie.
Un aspect important și interesant legat de imagini este reprezentat de construcția pe layere/straturi. Fiecare modificare adusă imaginii anterioare poate rezulta în construcția unui strat suplimentar. Dintr-o perspectivă simplificată, putem spune că fiecare instrucțiune din Dockerfile construiește o imagine nouă, intermediară, care se diferențiază de cea anterioară prin modificările aduse de instrucțiunea curentă. Plecând de la această premisă, un layer este exact acest delta dintre 2 imagini succesive. Dintr-o perspectivă mai complexă, nu orice instrucțiune rezultă într-un strat nou. Comenzi precum CMD, MAINTAINER, ENTRYPOINT nu aduc o modificare substanțială imaginii, ci alterează aspecte legate de metadatele atașate unei imagini.
Să validăm această informație pentru imaginea rlrules/rlhello:v4.0:
student@aldebaran:~$ docker image inspect rlrules/rlhello:v4.0
În output-ul comenzii anterioare, căutați secțiunea RootFS. Pentru simplitate puteți rula:
student@aldebaran:~$ docker image inspect --format "{{json .RootFS}}" rlrules/rlhello:v4.0
Să vedem care au fost acele instrucțiuni care au dus la construcția celor 5 straturi:
student@aldebaran:~$ docker image history rlrules/rlhello:v4.0
Toate instrucțiunile care au un SIZE > 0
sunt responsabile de crearea unui layer suplimentar în imagine:
FROM ubuntu:latest
)RUN apt-get update && apt-get install
ce creează noi fișiere în sistemul de fișiere
În cazul în care ne dorim să ștergem o imagine de pe sistem, mai întâi trebuie să obținem identificatorul unic al imaginii (IMAGE ID
):
student@aldebaran:~$ docker image ls
Să încercăm să ștergem imaginea rlrules/rlhello:v1.0:
student@aldebaran:~$ docker image rm <id imagine rlrules/rlhello:v1.0>
O soluție a problemei anterioare o reprezintă ștergerea containerului care utilizează imaginea vizată:
student@aldebaran:~$ docker container ps -a
student@aldebaran:~$ docker container rm <id container oprit care utilizeaza imaginea v1.0>
Acum putem efectua ștergerea imaginii:
student@aldebaran:~$ docker image rm <id imagine rlrules/rlhello:v1.0>
Voila!
Până în acest moment, ne-am familiarizat cu Docker Engine și cu obiectele cele mai importante din lumea Docker. În continuare, în cadrul laboratorului, vom începe să punem server-ul nostru de imagini în contextul de rulare cerut. Întrucât aplicația este slab cuplată, un prim pas care trebuie realizat este să stabilim comunicarea între serviciile componente. În acest context ne vom muta atenția asupra conceptelor de Networking din Docker.
In cazul rulării unui container Docker există necesitatea stabilirii comunicației către exterior prin rețele virtuale sau prin configurații particulare de rețea. Avantajul major îl constituie faptul că multe din aspectele anevoioase de configurare sunt abstractizate, fiind reduse la un număr mic de obiecte predefinite ce pot fi utilizate pentru scenarii complexe. O parte din terminologia aferentă Docker networking se poate găsi în tabelul următor, unde vom defini Driverele Docker - acele obiecte de rețea predefinite cu care vine Docker Engine-ul și care facilitează comunicarea la nivelul containerelor:
Driver network | Descriere |
---|---|
bridge | tip de driver utilizat în mod implicit, util pentru aplicații care stau în containere individuale și care au nevoie de conectivitate între ele sau către exterior |
host | disponibil doar în SWARM mode, tip de network care partajează rețeaua cu mașina gazdă |
overlay | tip de driver care permite comunicarea între containere aflate pe noduri Docker diferite din rețele diferite - comunicare la nivel 2 utilizând VXLAN |
macvlan | tip de network care alocă containerelor adrese MAC și permit atașarea container-ului în aceiași rețea cu sistemul gazdă |
none | tip de driver care dezactivează conectivitatea la rețea |
Referințe:
În continuare vom începe să construim aplicația de sharing de imagini, realizând în paralel și comunicația între serviciile componente, în concordanță cu modelul slab cuplat ales.
Să încercăm să lansăm serviciul de frontend. Întrucât acesta va fi expus către exterior, pentru a fi accesibil din browser, vom publica portul 80 al containerului:
student@aldebaran:~$ docker run -d --name=rl-frontend \ -e BACKEND_SERVER=rl-backend \ -p 8080:80 \ rlrules/docker-lab-frontend
Parametrul -p (publish) cu valoarea 8080:80 (<port sistem gazdă>:<port container>) expune/alocă pe mașina gazdă portul TCP 8080, urmând ca traficul orientat către acest port să fie trimis, mai departe, către socket-ul containerului expus pe portul TCP 80.
Prin abstractizarea implementată prin flag-ul publish, Docker crează, în backstage, o regulă iptables de DNAT (port-forwarding) care redirecționează traficul venit pe portul TCP/8080 al mașinii gazdă către portul TCP/80 de pe container. În plus, o altă regulă definită prin utilizarea publish, permite traficul destinat interfeței virtuale docker0 (network de tip bridge, creat implicit la instalarea Docker, util pentru a conecta toate containerele care nu au o configurație de rețea particulară).
student@aldebaran:~$ sudo iptables -L -nv -t filter student@aldebaran:~$ sudo iptables -L -nv -t nat
student@aldebaran:~$ sudo iptables -L -nv -t filter | grep -i 80 -A 3 -B 3 student@aldebaran:~$ sudo iptables -L -nv -t nat | grep -i 8080 -A 3 -B 3
Un bridge network, așa cum se auto-definește, este un echipament (appliance) virtual care permite comunicarea la nivel 2 a entităților de rețea conectate la acesta. În sensul terminologiei Docker, un bridge network adaugă suplimentar capabilității anterior definite, o configurație de Default Gateway la nivelul interfeței virtuale a containerului și implementarea de Masquerade - Network Address Translation/Port Address Translation (NAT/PAT). În acest fel, containerul va putea accesa, utilizând conectivitatea sistemului gazdă, resurse externe din afara rețelei sau Internet.
10.9.X.Y
nu este accesibilă din Internet, și, din păcate, nici de pe Wifi-ul facultății :(
Așadar, va trebui să facem proxy prin FEP:
# porniți terminal nou de pe stația locală: ssh -D8080 <username>@fep.grid.pub.ro
Apoi configurați proxy SOCKS din browserul vostru preferat la adresa localhost:8080
(atenție: nu folosiți proxy HTTP, protocolul este obligatoriu SOCKS). Căutați pe Google pentru modul de configurare al sistemului vostru + browser (e.g., Windows 10). Apoi veți putea accesa containerul prin adresa IP a acestuia (e.g., http://10.9.X.Y:8080
)
NU ÎNCHIDEȚI TERMINALUL! Cât timp conexiunile SSH rămân deschise, veți putea accesa din browserul vostru pagina http://localhost:8080 care va duce către portul din container (prin proxy pe fep).
Am observat că serviciul de frontend funcționează (http://<IP mașină gazdă>:8080
), însă pare că nu reușește să încarce conținut. Acest comportament se petrece deoarece aplicația încearcă să consume date de la un serviciu de backend (componentă ce înmagazinează toate funcțiile de procesare a informației) și așteaptă să îi devină disponibil. Este foarte important ca în design-ul unei aplicații, componenta de frontend să ofere doar interfața grafică (de interacțiune) și nimic mai mult, urmând ca procesele de manipulare a datelor să se petreacă în backend.
Ca în orice configurație loosely coupled (slab-cuplată), trebuie să ne asigurăm de faptul că elementele componente ale unei soluții pot comunica între ele. Pentru a obține acest rezultat, va trebui să creăm un Docker bridge în care să conectăm containerele aplicației. Pentru a crește un pic complexitatea din perspectiva izolării traficului, vom crea două rețele: o rețea între frontend și backend și o rețea între backend și baza de date:
student@aldebaran:~$ docker network create --driver bridge appnet student@aldebaran:~$ docker network create --driver bridge dbnet
În continuare vom afișa cele două entități nou create:
student@aldebaran:~$ docker network ls --filter name=net
Întrucât rețelele sunt deja construite, vom lansa cele 2 servicii suplimentare din componența aplicației - backend și baza de date:
student@aldebaran:~$ docker run -d --name=rl-database \ -e MYSQL_DATABASE=rl-database \ -e MYSQL_USER=rl-user \ -e MYSQL_PASSWORD=rl-specialpassword \ -e MYSQL_ROOT_PASSWORD=root \ -e TZ=Europe/Bucharest \ --network=dbnet \ mysql [...]
student@aldebaran:~$ docker run -d --name=rl-backend \ -e DB_SERVER=rl-database \ --network=dbnet \ rlrules/docker-lab-backend [...]
student@aldebaran:~$ docker network connect appnet rl-backend [...] student@aldebaran:~$ docker network connect appnet rl-frontend [...]
Va trebui să așteptăm câteva secunde pentru inițializarea bazei de date.
Să testăm funcționalitatea platformei accesând aplicația din Browser (http://<IP mașină gazdă>:8080
).
Dacă Pacman a dispărut de pe ecran, iar în locul lui a apărut o colecție impresionantă de meme-uri, înseamnă că platforma funcționează! În cazul în care întâmpinați probleme:
student@aldebaran:~$ docker restart rl-frontend
În lumea Docker conectivitatea cu alte servicii se face pe bază de nume. Să presupunem că vrem să ne conectăm la baza de date. În conectivitatea clasică, de cele mai multe ori, trebuie specificată adresa IP a bazei de date, portul dacă nu este cel standard soluției de baze de date, user-ul bazei de date și parola. În Docker, simplul re-deployment al unui container nu asigură persistența adresei IP alocat interfeței virtuale și deci, nu putem utiliza adresarea IP pentru a asigura conectivitatea dintre două servicii. Pentru a abstractiza elementul de configurare, Docker propune un model de Service Discovery bazat pe DNS. În acest sens, un identificator care rămâne nemodificat între două rulări succesive ale aceluiași container, reprezintă numele pe care îl atribuim containerului (în exemplul nostru: --name rl-frontend, --name rl-backend, --name rl-database). În cele mai multe cazuri, aplicațiile împachetate în container au hardcodate astfel de nume pentru a stabili conectivitatea, și totodată un set implicit de credențiale. În setup-uri mai inteligente, coordonatele de conectare între servicii pot fi configurate prin variabile de mediu (parametrul -e) admise la runtime, prin fișiere de configurare partajate cu sistemul gazdă etc.
În continuare vom testa funcționalitatea mecanismului de service discovery. Ne vom conecta la instanța de backend:
student@aldebaran:~$ docker exec -it rl-backend bash
Și vom rula utilitarele clasice de testare a conectivității - ping, telnet și host (pentru validarea rezoluției de nume)
root@rl-backend:~$ ping rl-frontend [...] root@rl-backend:~$ apt update && apt install -y host && host rl-frontend [...] root@rl-backend:~$ telnet rl-frontend 80 [...]
CTRL + ]
sau comanda quit
root@rl-backend:~$ ping rl-database [...] root@rl-backend:~$ host rl-database [...] exit
Dacă ștergem containerul de frontend și îl reconstruim:
student@aldebaran:~$ docker rm -f rl-frontend
student@aldebaran:~$ docker run -d --name=rl-frontend \ -e BACKEND_SERVER=rl-backend \ -p 8080:80 --network appnet \ rlrules/docker-lab-frontend
student@aldebaran:~$ docker exec -it rl-backend host rl-frontend
student@aldebaran:~$ docker exec -it rl-frontend ip a
Vom vedea că serviciul de DNS se va actualiza cu noua adresă IP a containerului, în cazul în care aceasta a fost schimbată între cele 2 rulări consecutive. În caz particular, este puțin probabil ca Docker engine să aloce o altă adresă IP.
În continuarea laboratorului, perspectiva va fi mutată pe date și persistența acestora.
Defineam un container Docker ca fiind o instanță în execuție a unei imagini Docker. În acest sens, o mare parte din datele existente în interiorul unui container provin din nivelele (layers) de construcție a imaginii instanțiate. O aplicație containerizată, odată lansată, poate genera date pe care le stochează pe sistemul de fișiere. În caz particular, aplicația noastră va stoca imaginile urcate în platformă pe sistemul de fișiere din backend, într-o cale specifică: /home/rl-user/rl-app/rl-images/upload
.
Straturile/layerele din construcția imaginii sunt read-only, ele deservind totalitatea containerelor aflate în execuție, care sunt instanțe ale imaginii. În cazul în care este necesară manipularea datelor existente sau crearea de date noi, funcția de storage a Docker Engine-ului va separa modificările aduse imaginii de bază print-un strat (layer) adițional, poziționat peste straturile read-only existente. Nivelul suplimentar, cu drepturi Read-Write (R/W), este activ atâta timp cât containerul își desfășoară ciclul de viață. La finalizarea execuției unui container, informația generată la runtime și stocată pe sistemul de fișiere se pierde. Mecanismul de stocare a datelor, așa cum a fost descris anterior poartă numele de Copy-on-write (COW), și este realizat prin utilizarea unui sistem de fișiere special din categoria Union File Systems.
În continuare ne propunem să exploatăm mecanismele native Docker pentru asigurarea persistenței datelor.
În demo-ul anterior, ați avut ocazia să urcați poze în platforma de sharing de imagini. Dacă nu ați făcut-o, acum este un moment propice (formatul suportat este PNG). În continuare vom șterge containerul de backend și îl vom reconstrui:
student@aldebaran:~$ docker rm -f rl-backend
student@aldebaran:~$ docker run -d --name=rl-backend \ -e DB_SERVER=rl-database \ --network=dbnet \ rlrules/docker-lab-backend
student@aldebaran:~$ docker network connect appnet rl-backend
Dați refresh la pagina web, încărcată în browser. Pozele nu mai există întrucât acestea au fost scrise de backend pe sistemul de fișiere. Pentru a păstra consistența informației stocate în baza de date vom reseta (șterge) atât baza de date cât și containerul de backend.
student@aldebaran:~$ docker rm -f rl-backend
student@aldebaran:~$ docker rm -f rl-database
Știm că aplicația stochează pozele uploadate în locația /home/rl-user/rl-app/rl-images/upload
de pe sistemul de fișiere. Pentru a asigura persistența acestui folder, în cazul în care accidental ștergem containerul de backend, sau în cazul în care suntem nevoiți să îl relansăm, din cauza unei probleme de funcționare, vom crea ceea ce se numește un Docker volume.
student@aldebaran:~$ docker volume create poze
student@aldebaran:~$ docker volume list
In continuare vom relansa serviciul baze de date:
student@aldebaran:~$ docker run -d --name=rl-database \ -e MYSQL_DATABASE=rl-database \ -e MYSQL_USER=rl-user \ -e MYSQL_PASSWORD=rl-specialpassword \ -e MYSQL_ROOT_PASSWORD=root \ -e TZ=Europe/Bucharest \ --network=dbnet \ mysql
Urmat de serviciul de backend, atașând în plus volumul nou creat.
student@aldebaran:~$ docker run -d --name=rl-backend \ -e DB_SERVER=rl-database \ --network=dbnet \ --mount type=volume,src=poze,dst=/home/rl-user/rl-app/rl-images/upload \ rlrules/docker-lab-backend [...]
student@aldebaran:~$ docker network connect appnet rl-backend
Alternativ puteti utiliza si constructia -v poze:/home/rl-user/rl-app/rl-images/upload
Să vedem dacă putem obține persistența datelor în urma unor rulări succesive. Urcați câteva poze în platformă și refaceți containerul de backend:
student@aldebaran:~$ docker rm -f rl-backend
student@aldebaran:~$ docker run -d --name=rl-backend \ -e DB_SERVER=rl-database \ --network=dbnet \ -v poze:/home/rl-user/rl-app/rl-images/upload \ rlrules/docker-lab-backend
student@aldebaran:~$ docker network connect appnet rl-backend
Disecând mecanismul de abstractizare prin volume, un obiect de tip volum este în, în spate, un folder stocat pe sistemul de fișiere al mașinii gazdă, într-o zonă specifică a Docker Engine-ului.
Să încercăm să găsim această locație utilizând comanda docker volume inspect
:
student@aldebaran:~$ docker volume inspect poze
Pentru simplitate:
student@aldebaran:~$ docker volume inspect poze --format {{.Mountpoint}}
O altă metodă de a asigura persistența datelor o poate furniza mecanismul de Mount Binding. În loc de a crea și atașa un volum unui container Docker, o altă posibilitate o presupune partajarea directă a unui custom folder de pe sistemul de fișiere al mașinii gazdă, între cele 2 entități. Întrucât anterior am descoperit unde stochează Docker datele dintr-un obiect de timp volum, în continuare vom copia fișierele din volum într-un custom folder creat în home-ul utilizatorului student și vom monta acel folder utilizând mecanismul de mount binding.
student@aldebaran:~$ mkdir $HOME/poze student@aldebaran:~$ sudo cp -p `docker volume inspect poze --format {{.Mountpoint}}/*` $HOME/poze/ student@aldebaran:~$ docker rm -f rl-backend [...] student@aldebaran:~$ docker run -d --name=rl-backend \ -e DB_SERVER=rl-database \ --network=dbnet \ --mount type=bind,src=$HOME/poze,dst=/home/rl-user/rl-app/rl-images/upload \ rlrules/docker-lab-backend [...] student@aldebaran:~$ docker network connect appnet rl-backend [...]
Urcați o nouă poză în platformă. Apare aceasta în directorul poze
?
Am ajuns în punctul în care trebuie să lansăm site-ul în producție și stim că trebuie să ne preocupăm de aspecte precum scalabilitate (capacitatea de a suporta o încărcătură mai mare de lucru - un caz particular pentru perioada sărbătorilor), high-availability (Service-Level Agreement stabilit la 99%), redundanță sau toleranță la defecte (auto-healing). În forma standard, Docker Engine nu poate asigura niciunul din aspectele prezentate anterior, el furnizând doar un runtime de containerizare. Pentru a suporta constrângerile de producție, într-o formă autonomă și automată, este necesar un sistem de clusterizare și orchestrare. Soluția pe care o vom utiliza poartă numele de Docker SWARM.
Docker SWARM este o soluție existentă în piață pentru orchestrarea de containere de tip Docker, în competiție cu platforme precum Kubernetes, Apache Mesos, OpenShift ș.a. Avantajul principal cu care vine platforma este ușurința în instalare și configurare, fiind un produs integrat nativ în Engine-ul de Docker. Din perspectivă arhitecturală, un cluster de Docker SWARM este alcătuit din două tipuri de noduri: Master Node - implementează nivelul de control, preluând cererile de deployment și monitorizând continuu starea cluster-ului Docker; Worker Node - asigură execuția containerelor solicitate de nivelul de control.
În cadrul laboratorului, vom utiliza o implementare simplificată, cu 2 noduri (1 master care are rol și de worker și 1 worker dedicat).
Înainte de a începe secțunea următoare, vom curăța nodurile de obiectele Docker create în exercițiile anterioare:
student@aldebaran:~$ docker rm -f rl-frontend rl-backend rl-database
student@aldebaran:~$ docker volume prune
student@aldebaran:~$ docker system prune
Totodată, pentru a simplifica terminologia utilizată în continuare, numim:
Pe nodul (VM) MASTER:
student@aldebaran:~$ docker swarm init --advertise-addr <IP interfață VM>
Din outputul anterior puteți obține comanda de join pe care o puteți utiliza pe nodurile care vor deservi ca workers. In cazul în care ulterior doriți să atașați noi noduri clusterului, puteți obține comanda anterioară astfel:
student@aldebaran:~$ docker swarm join-token worker
Pe celelalte noduri (pe nodurile WORKER):
student@aldebaran:~$ docker swarm join --token <SWARM cluster token> <IP interfață VM MASTER>:2377
Pe MASTER:
student@aldebaran:~$ docker node ls
În continuare vom vedea cum utilizarea unei soluții de orchestrare poate simplifica mentenanța, în producție, a unei aplicații critice, oferind capabilități și mecanisme de auto-healing, scheduling sau replicare.
Pe MASTER vom face o cerere de construire a unui tip special de setup de rețea, facil rulării aplicației noastre în mod distribuit, pe diverse noduri ale clusterului:
student@aldebaran:~$ docker network create -d overlay rlapp-overlay
Ulterior acestui pas, vom crea fiecare componentă a aplicație. În configurația curentă, toate cele 3 servicii vor fi conectate la același obiect de rețea:
student@aldebaran:~$ docker service create --name=rl-database \ -e MYSQL_DATABASE=rl-database \ -e MYSQL_USER=rl-user \ -e MYSQL_PASSWORD=rl-specialpassword \ -e MYSQL_ROOT_PASSWORD=root \ -e TZ=Europe/Bucharest \ --network=rlapp-overlay \ --replicas 1 \ mysql [...] student@aldebaran:~$ docker service create --name=rl-backend \ -e DB_SERVER=rl-database \ --network=rlapp-overlay \ --replicas 1 \ rlrules/docker-lab-backend [...] student@aldebaran:~$ docker service create --name=rl-frontend \ -e BACKEND_SERVER=rl-backend \ -p 8080:80 --network rlapp-overlay \ --replicas 1 \ rlrules/docker-lab-frontend [...]
Pentru a verifica distribuția serviciilor containerizate pe nodurile disponibilite:
student@aldebaran:~$ docker service ls
student@aldebaran:~$ docker service ps <numele serviciului>
Pentru a vedea doar acele containere care sunt în running state:
student@aldebaran:~$ docker service ps <numele serviciului> -f desired-state=running
Pentru simplitate, vom putea utiliza filtre:
student@aldebaran:~$ docker service ps $(docker service ls -q) -f desired-state=running
Din perspectiva unui orchestrator, Docker SWARM trebuie să se asigure că starea serviciilor pe care le-am lansat este consistentă. În cazul în care un serviciu(container) pică, SWARM trebuie să readucă starea clusterului la cea configurată. Pentru fiecare serviciu lansat anterior, am setat o constrângere particulară, care desemnează o stare a clusterului pe care o vrem constantă: --replicas 1
. Să validăm faptul că Docker SWARM are capabilitatea de self-healing, aspect ce ne permite să stabilim prerogativele asigurării unui disponibilități crescute a aplicației web de partajare de imagini.
student@aldebaran:~$ docker service ps rl-frontend -f desired-state=running
În coloana NODE vedem unde a fost planificată execuția containerului asociat serviciului de frontend.
Pe nodul anterior obținut vom șterge container-ul de frontend:
student@aldebaran:~$ docker ps -f name=rl-frontend*
student@aldebaran:~$ docker rm -f <nume container sau id container>
Pe MASTER:
student@aldebaran:~$ watch docker service ps rl-frontend -f desired-state=running
Se poate vedea cum Docker SWARM a sesizat modificările aduse stării clusterului și a relansat unui nou container de frontend.
Într-un scenariu realist, simpla oprire a execuției unui container nu este singurul scenariu valid, ce trebuie luat în considerare, pentru a stabili dacă un serviciu este disponibil sau nu. Ce se întâmplă dacă aplicația rulează în continuare în container, dar intră într-un context de eroare ca de exemplu: o buclă infinită? Docker poate asigura mecanisme de probing, însă, într-un design matur, efortul de auto-healing trebuie susținut atât de runtime-ul de containerizare cât și de aplicația care rulează în container.
Știm, din incipit, faptul că în perioada sărbătorilor va exista un spike în utilizarea platformei de sharing de poze, astfel încât se conturează scenariul probabil ca serviciul de frontend, expus către exterior, să fie bombardat de cereri HTTP și să cedeze în fața numărului de conexiuni lansate către acesta. O soluție simplă pentru o astfel de problemă o reprezintă multiplicarea serviciului preconizat a fi afectat. Traficul HTTP va trebui, totodată, balansat echitabil între instanțele (replicile) serviciului multiplicat.
Cu Docker SWARM implementarea unui astfel de model de rețea redundant se poate realiza simplu, întrucât orchestratorul oferă posibilitatea creșterii numărului de replici ale unui container și totodată oferă nativ funcția de balansare între instanțele aplicației replicate. Să validăm aceste capabilități:
student@aldebaran:~$ docker service scale rl-frontend=3
student@aldebaran:~$ docker service ps rl-frontend -f desired-state=running
Din browser, incercați să vă conectați la aplicație utilizând IP-ul MASTER-ului atât dintr-o fereastră normală cât și dintr-o fereastră incognito/private.
Id-ul din colțul dreapta sus este id-ul unic al containerului în care rulează instanța de frontend. Întrucât am creat 3 replici ale aplicației, Docker SWARM va balansa traficul(load) către toate instanțele generate.
Mai mult, încercați să vă conectați la aplicație utilizând IP-ul Worker1 sau/și IP-ul Worker2. Funcționează? Un port expus în cadrul unui cluster Docker SWARM este disponibil pe întreaga componență a clusterului. Astfel, pentru accesa o aplicație expusă este suficient să cunosc una din adresele nodurilor clusterului și portul aplicației, fără a conta explicit unde rulează containerul.
Revenind la comparația dintre arhitecturi pe microservicii și arhitecturi monolite, observăm oportunitatea de scalare granulară, per serviciu/feature, cu care vine un model loosely coupled. În imaginea de mai jos se poate vedea metoda de scalare orizontală ce poate fi aplicată în ambele abordări: aplicații monolit și aplicații distribuite pe microservicii.
Deși aparențele pot sugera faptul că o aplicație modelată pe microservicii poate rezolva multe din problemele de producție, efortul dezvoltării unei aplicații pe microservicii poate fi semnificativ mai mare, amintind aici faptul că trebuie definit și dezvoltat un mecanism de comunicare slab cuplat (exemplu REST API), trebuie asigurate mecanisme pentru consistența datelor, în contexul scalării orizontale prin multiplicare (acces atomic asupra bazei de date etc.), trebuie realizată o distribuției echitabilă a efortului de procesare pe diversele replici, trebuie asigurate mecanisme de comunicare asincronă (așa cum se poate vedea în Scenariul complex de mai sus) etc. Cu toate acestea piața oferă framework-uri care să faciliteze dezvoltarea de aplicații construite peste o infrastructura de tipcloud și peste un model bazat pe microservicii. Spre exemplu: SpringBoot - framework de Java, Kabanero, lansat în vara anului 2019 de către IBM, oferă unelte pentru dezvoltarea directă peste Kubernetes etc.
Pentru a reveni la structura inițială, după trecerea perioadei sărbătorilor, putem face scale-down:
student@aldebaran:~$ docker service scale rl-frontend=1
student@aldebaran:~$ docker service ps rl-frontend
În acest laborator am iterat prin câteva concepte din lumea Docker și totodată am adresat probleme și concepte de system design. Echipa de RL speră să vi le însușiți și să vă fie benefice în carieră.