Skip to main content

Introducere

Cele mai multe aplicații moderne sunt dezvoltate cu ajutorul sau sunt gazduite în medii virtualizate pentru că oferă un mediu securizat și consitent. Înainte cea mai populară soluție de virtualizare au fost mașinile virtuale, însă acestea necesită multe resurse și sunt greu de șablonat. În momentul de față aplicațiile ce necesită virtualizare au migrat înspre containere care oferă mult mai multă flexibilitate ca masinile virtuale și cu costuri mai reduse. În cadrul acestui laborator vom folosi containere Docker pentru a ne împacheta o aplicație ca să fie lansată într-un mediu de producție.

Docker este o platformă de containere software, folosită pentru a împacheta și rula aplicații atât local, cât și pe sisteme Cloud, eliminând probleme de genul „pe calculatorul meu funcționează”. Docker poate fi deci privit ca un mediu care permite rularea containerelor pe orice platformă, bazat pe containerd. Ca beneficii, oferă compilare, testare, deployment, actualizare și recuperare în caz de eroare mai rapide față de modul standard de deployment al aplicațiilor.

Docker oferă un mediu uniform de dezvoltare și producție, unde nu se mai pune problema compatibilității aplicațiilor cu sistemul de operare și nu mai există conflicte între versiunile de biblioteci/pachete de pe sistemul gazdă. Containerele sunt efemere, așa că stricarea sau închiderea unuia nu duce la căderea întregului sistemul. Ele ajută la asigurarea consistenței stricte între comportamentul în mediul de dezvoltare cu cel în mediul de producție.

Imagini și containere

Containerele Docker au la bază imagini, care sunt pachete executabile lightweight de sine stătătoare ce conțin tot ce este necesar pentru rularea unor aplicații software, incluzând cod, runtime, biblioteci, variabile de mediu și fișiere de configurare. Imaginile au o dimensiune variabilă, nu conțin versiuni complete ale sistemelor de operare, și sunt stocate în cache-ul local sau într-un registru. O imagine Docker are un sistem de fișiere de tip union, unde fiecare schimbare asupra sistemului de fișiere sau metadate este considerată ca fiind un strat (layer), mai multe astfel de straturi formând o imagine. Fiecare strat este identificat unic (printr-un hash) și stocat doar o singură dată.

Un container reprezintă o instanță a unei imagini, adică ceea ce imaginea devine în memorie atunci când este executată. El rulează complet izolat de mediul gazdă, accesând fișiere și porturi ale acestuia doar dacă este configurat să facă acest lucru. Containerele rulează aplicații nativ pe nucleul mașinii gazdă, având performanțe mai bune decât mașinile virtuale, care au acces la resursele gazdei prin intermediul unui hipervizor. Fiecare container rulează într-un proces discret, necesitând tot atât de multă memorie cât orice alt executabil. Din punct de vedere al sistemului de fișiere, un container reprezintă un strat adițional de read/write peste straturile imaginii.

img

În imaginea de mai sus (preluată din documentația oficială Docker), mașinile virtuale rulează sisteme de operare „oaspete”, lucru care consumă multe resurse, iar imaginea rezultată ocupă mult spațiu, conținând setări de sistem de operare, dependențe, patch-uri de securitate, etc. În schimb, containerele pot să împartă același nucleu, și singurele date care trebuie să fie într-o imagine de container sunt executabilul și pachetele de care depinde, care nu trebuie deloc instalate pe sistemul gazdă. Dacă o mașină virtuală abstractizează resursele hardware, un container Docker este un proces care abstractizează baza pe care rulează aplicațiile în cadrul unui sistem de operare și izolează resursele software ale sistemului de operare (memorie, access la rețea și fișiere, etc.).

Arhitectura Docker

Docker are o arhitectură de tip client-server, așa cum se poate observa în imaginea de mai jos (preluată din documentația oficială Docker). Clientul Docker comunică, prin intermediul unui API REST (peste sockeți UNIX sau peste o interfață de rețea), cu daemon-ul de Docker (serverul), care se ocupă de crearea, rularea și distribuția de containere Docker. Clientul și daemon-ul pot rula pe același sistem sau pe sisteme diferite. Un registru Docker are rolul de a stoca imagini.

img

Instalarea

Docker este disponibil în două variante: Community Edition (CE) și Enterprise Edition (EE). Docker CE este util pentru dezvoltatori și echipe mici care vor să construiască aplicații bazate pe containere. Pe de altă parte, Docker EE a fost creat pentru dezvoltare enterprise și echipe IT care scriu și rulează aplicații critice de business pe scară largă. Versiunea Docker CE este gratuită, pe când EE este disponibilă cu subscripție. În cadrul laboratorului de IDP, vom folosi Docker Community Edition. Docker este disponibil atât pe platforme desktop (Windows, macOS), cât și Cloud (Amazon Web Services, Microsoft Azure) sau server (CentOS, Fedora, Ubuntu, Windows Server 2016, etc.).

Windows și MacOS

Pentru că Docker nu avea inițial suport nativ pentru Windows și MacOS, s-a introdus Docker Toolbox, care poate lansa un mediu Docker virtualizat (mai precis, se folosește o mașină virtuală VirtualBox pentru a fi baza mediului de Docker). De câțiva ani, Docker Toolbox a fost marcat ca „legacy” și a fost înlocuit de Docker Desktop for Mac și Docker Desktop for Windows, care oferă funcționalități similare cu performanțe mai bune. Mai mult, Windows Server 2016, Windows 10 și Windows 11 au acum suport pentru Docker nativ pentru arhitecturi x86_64.

Linux

Comenzile de mai jos sunt pentru Ubuntu. Pentru alte variante de Linux (Debian, CentOS, Fedora), găsiți informații suplimentare pe pagina de documentație oficială Docker.

Pentru instalarea Docker CE, este nevoie de una din următoarele versiuni de Ubuntu: Ubuntu Mantic 23.10, Ubuntu Jammy 22.04 (LTS), Ubuntu Focal 20.04 (LTS). Docker CE are suport pentru arhitecturile x86_64, amd64, armhf, arm64, s390x, și ppc64le (ppc64el).

Varianta recomandată de instalare a Docker CE presupune folosirea repository-ului oficial, deoarece update-urile sunt apoi instalate automat. La prima instalare a Docker CE pe o mașină, este necesară inițializarea repository-ului:

$ sudo apt-get update
$ sudo apt-get install ca-certificates curl gnupg lsb-release
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
$ echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

După inițializare, se poate instala Docker CE:

$ sudo apt-get update
$ sudo apt-get install docker-ce docker-ce-cli containerd.io
tip

O variantă mai simplă de a instala Docker CE pe Linux este utilizarea acestui script.

Testarea instalării

Pentru a verifica dacă instalarea s-a realizat cu succes, putem rula un container simplu de tip Hello World:

$ docker container run hello-world

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
c1ec31eb5944: Pull complete
Digest: sha256:d000bc569937abbe195e20322a0bde6b2922d805332fd6d8a68b19f524b7d21d
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/

Output-ul execuției ne arată pașii pe care Docker îi face în spate pentru a rula acest container. Mai precis, dacă imaginea pe care dorim să o rulăm într-un container nu este disponibilă local, ea este descărcată din repository, după care se creează un nou container pe baza acelei imagini, în care se rulează aplicația dorită.

Rularea unui container

Am văzut mai sus cum putem rula un Hello World într-un container simplu, însă putem rula imagini mult mai complexe. Putem să ne creăm propria imagine (așa cum vom vedea mai târziu) sau putem descărca o imagine dintr-un registru, cum ar fi Docker Hub). Acesta conține imagini publice, care variază de la sisteme de operare (Ubuntu, Alpine, Amazon Linux, etc.) la limbaje de programare (Java, Ruby, Perl, Python, etc.), servere Web (NGINX, Apache), etc.

Pentru acest laborator, vom rula Alpine Linux, care este o distribuție lightweight de Linux (dimensiunea sa este de 7 MB). Primul pas constă în descărcarea imaginii dintr-un registru Docker (în cazul nostru, Docker Hub):

$ docker image pull alpine

Pentru a vedea toate imaginile prezente pe sistemul nostru, putem rula următoarea comandă:

$ docker image ls

REPOSITORY TAG IMAGE ID CREATED SIZE
alpine latest 05455a08881e 3 weeks ago 7.38MB

Se poate observa mai sus că imaginea pe care am descărcat-o are numele alpine și tag-ul latest. Tag-ul unei imagini reprezintă o etichetă care desemnează în general versiunea imaginii, iar latest este un alias pentru versiunea cea mai recentă, pus automat atunci când nu specificăm explicit niciun tag.

Odată descărcată imaginea, o putem rula într-un container. Un mod de a face acest lucru este prin specificarea unei comenzi care să fie rulată în interiorul containerului (în cazul nostru, pe sistemul de operare Alpine Linux):

$ docker container run alpine ls -l

total 56
drwxr-xr-x 2 root root 4096 Jan 26 17:53 bin
drwxr-xr-x 5 root root 340 Feb 23 10:48 dev
drwxr-xr-x 1 root root 4096 Feb 23 10:48 etc
drwxr-xr-x 2 root root 4096 Jan 26 17:53 home
[...]

Astfel, în exemplul de mai sus, Docker găsește imaginea specificată, construiește un container din ea, îl pornește, apoi rulează comanda în interiorul său. Dacă dorim acces interactiv în interiorul containerului, putem folosi următoarea comandă:

$ docker container run -it alpine

Dacă dorim să vedem ce containere rulează la un moment de timp, putem folosi comanda ls. Dacă vrem să vedem lista cu toate containerele pe care le-am rulat, folosim și flag-ul -a:

$ docker container ls -a

CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES
96e583b80c13 alpine "/bin/sh" 3 seconds ago Exited (0) 1 second ago fervent_ishizaka
d3f65a167db3 alpine "ls -l" 42 seconds ago Exited (0) 41 seconds ago strange_ramanujan

Pentru rularea unei imagini într-un container în background, putem folosi flag-ul -d. La pornire, va fi afișat ID-ul containerului pornit, informație pe care o putem folosi pentru a ne atașa la container, pentru a îl opri, pentru a îl șterge, etc.:

$ docker container run -d -it alpine

7919fb6e13ab9497fa12fa455362cb949448be207ad08e08e24a675a32c12919
$ docker container ls

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7919fb6e13ab alpine "/bin/sh" 10 seconds ago Up 9 seconds elastic_knuth
$ docker attach 7919fb6e13ab

/ # exit
$ docker stop 7919fb6e13ab

7919fb6e13ab
$ docker container ls

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
$ docker rm 7919fb6e13ab

7919fb6e13ab
$ docker container ls -a

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

Crearea unei imagini

O imagine este definită de un fișier numit Dockerfile, care specifică ce se întâmplă în interiorului containerului pe care vrem să îl creăm, unde accesul la resurse (cum ar fi interfețele de rețea sau hard disk-urile) este virtualizat și izolat de restul sistemului. Prin intermediul acestui fișier, putem specifica mapări de porturi, fișiere care vor fi copiate în container când este pornit, etc. Fiecare rând din Dockerfile descrie un strat din imagine. Odată ce am definit un Dockerfile corect, aplicația noastră se va comporta totdeauna identic, indiferent în ce mediu este rulată.

Pentru a simplifica containerizarea pentru aplicația noastră de .NET va trebui doar să folosim IDE-ul pentru a crea o imagine care să construiască și să ruleze aplicația din container. Pentru a crea un Dockerfile pentru aplicația noastră dați click-dreapta pe proiect > "Add" > "Dockerfile".

img

După ce executați comanda se va crea automat un fișier Dockerfile care contine intrucțiunile pentru contruitul imaginii:

Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["MobyLab.Ticketing/MobyLab.Ticketing.csproj", "MobyLab.Ticketing/"]
RUN dotnet restore "MobyLab.Ticketing/MobyLab.Ticketing.csproj"
COPY . .
WORKDIR "/src/MobyLab.Ticketing"
RUN dotnet build "MobyLab.Ticketing.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "MobyLab.Ticketing.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MobyLab.Ticketing.dll"]

În fișierul de mai sus, avem următoarele comenzi:

  • FROM – specifică o imagine pe care noua noastră imagine este bazată (în cazul nostru, pornim de la o imagine de bază cu SDK-ul de .NET instalat, care se află pe Docker Hub, și în interiorul căreia vom rula aplicația noastră Web) sau imagini intermediare
  • COPY – copiază fișierele dintr-un director local în containerul pe care îl creăm
  • ARG - adaugă argumente ce pot fi interpolate în alte comenzi
  • WORKDIR - specifică care este directorul curent pentru următoarele comenzi, dacă ne mutam într-un alt director la o comandă la următoare se va reveni la același director, doar cu acestă comandă îl putem seta pentru alte comenzi
  • RUN – rulează o comandă (în exemplul de mai sus, întâi instalează pachetele de Nuget cu dotnet restore și se compilează executabilul cu dotnet build)
  • ENTRYPOINT – specifică o comandă care va fi rulată atunci când containerul este pornit (în cazul de față, se rulează aplicația de .NET).

Pentru a construi imaginea trebuie să rulâm comanda docker build în folderul soluției:

 docker build -f .\MobyLab.Ticketing\Dockerfile -t mobylab/ticketing:0.0.1 .

În comandă se specifică fișierul Dockerfile, un tag care conține la final versiunea și contextul . care este folderul curent, putem să specificam și alt folder din care să construiască imaginea cu cuntinutul acestuia.

Ca totuși să putem folosi aceasta imagine trebuie înainte să adaptăm aplicația astfel încât să poată fi configurată. De exemplu, să îi configurăm locația bazei de date.

Pentru acest lucru vom schimba în builder-ul de aplicație să ne ia o configurație din appsettings.json. Așa că adaugăm în appsettings.json o intrare specială numită ConnectionStrings care va avea o cheie TicketingDatabase cu valoarea locația unde trebuie să fie fișierul bazei de date.

appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"TicketingDatabase": "DataSource=../Ticketing.db"
},
"AllowedHosts": "*"
}

Și apoi configurăm builder-ul de aplicație să ia această cheie din configurația noastră.

Program.cs
        builder.Services
.AddScoped<IUserService, UserService>()
.AddDbContext<TicketingDatabaseContext>(o =>
o.UseSqlite(builder.Configuration.GetConnectionString("TicketingDatabase")))
.AddControllers();

Ideea aici e ca vom putea suprascrie cu o variabilă de mediu aceasta valoare pentru altă locație a bazei de date. În acest caz dacă definim o variabilă de mediu ConnectionStrings__TicketingDatabase vom putea suprascrie valoarea din appsettings.json cu valoare variabilei.

Docker Compose

Imaginile pot fi rulate ca containere în mai multe moduri dar cel mai simplu este folosind Docker Compose.

În mod clasic, pentru rularea unor containere, este nevoie să rulați comanda aferentă de rulare (docker run) și să dați toți parametrii necesari. Acest proces poate deveni anevoios dacă este repetat pentru pornirea mai multor containere. Un mod de a „salva” configurația de rulare este să ne creăm scripturi. Problema în rularea mai multor scripturi este pierderea uniformității în configurare (ce container la ce rețea se conectează, cu cine comunică, etc.).

Docker Compose este un utilitar creat de către Docker folosit pentru centralizarea configurării de rulare a containerelor în manieră declarativă. Utilizând fișiere de configurare YAML (Yet Another Markup Language), Docker Compose centralizează procesul de configurare într-o manieră naturală, declarativă.

Mai mult decât atât, formatul pentru fișierele Compose este utilizat și în cadrul Docker Swarm, orchestratorul creat de Docker pentru gestiunea serviciilor Docker, despre care vom discuta mai târziu.

tip

Veți observa că, în acest laborator, folosim termenii de servicii și containere în mod interschimbabil. Acest lucru se întâmplă pentru că Docker Swarm lucrează cu servicii, în timp ce Docker Compose cu containere. Ne referim la ambii termeni în același context deoarece configurația este, în proporție de 90%, identică, indiferent de utilizarea Swarm sau Compose.

Instalare

Pentru sisteme Windows și MacOS, Docker Compose face parte din instalarea de Docker Desktop for Windows/Mac. Pentru Linux, instalarea se realizează conform ghidului oficial.

Elemente cheie

Formatul unui fișier YAML

Fișierele YAML sunt folosite de obicei pentru a scrie configurări în mod declarativ. Formatul este unul foarte ușor de înțeles și folosit, astfel:

  • se folosesc elemente de tip „cheie:valoare
  • aliniatele indentate reprezintă proprietăți copii ale paragrafelor anterioare
  • listele se delimitează prin „-”.

Exemplu de fișier Docker Compose

docker-compose.yml
version: "3.8"
services: # definim serviciile
ticketing-service: # dam un nume la serviciu
image: mobylab/ticketing:0.0.1 # folosește imaginea creată anterior
environment: # adăugăm variabile de mediu ca și dicționar cheie-valoare
ASPNETCORE_ENVIRONMENT: "development"
ConnectionStrings__TicketingDatabase: "DataSource=/app/database/Ticketing.db"
ports: # facem mapare de porturi, adică dacă accesăm portul 9090 se va trimite cererea către portul 8080 din container
- "9090:8080"
volumes: # definim și volume persiste ca să nu pierdem date, folderul database va apărea în container la /app/database
- ./database:/app/database

Ca să rulăm aplicația acum trebuie să rulăm comanda:

docker compose -p project-ticketing -f ./docker-compose.yml up -d

În comandă se specifică care este numele proiectului, fișierul de docker compose de trebuie folosit și parametrul -d îl face să ruleze în fundal.

Ca să opriți acest proiect trebuie doar să rulați:

docker compose -p project-ticketing down

Acum aplicația noastră va avea rula cu interfața de swagger pe http://localhost:9090/swagger/index.html și trebuie doar să punem fișierul de bază de date în folderul database ca aplicația să aibă acces la acesta și să fie persistată pe mașina gazdă.