Laborator 11. Virtualizare în Docker

Cunoștințe și abilități ce vor fi dobândite

  • Intoducere în Docker CLI
  • Docker Container vs Docker Image
  • Dockerfile și construcția de Imagini
  • Union File System: Overlay2 - image layers
  • Docker Volumes
  • Docker Networking: bridge & overlay (VXLAN)
  • Docker SWARM
  • Introducere în arhitecturi pe microservicii
  • Arhitecturi pe microservicii vs Arhitecturi monolite

Pregătire infrastructură de laborator

Creați 2 mașini virtuale.

Folosiți imaginea Ubuntu 16.04 Xenial cu flavor m1.medium pe Openstack.

Atenție ! Utilizatorul NU este student, ci ubuntu.

ubuntu@hostname:~# sudo apt-get update
ubuntu@hostname:~# sudo apt-get install docker
ubuntu@hostname:~# sudo apt-get install docker.io
ubuntu@hostname:~# cat /etc/hostname
$MY_HOSTNAME
ubuntu@hostname:~# cat /etc/hosts   # Adăugați linia următoare în fișierul /etc/hosts
127.0.0.1 $MY_HOSTNAME
[...]
ubuntu@hostname:~# sudo usermod -aG docker $USER
ubuntu@hostname:~# exit # LOGOUT
 
$FEP_USER@fep.grid.pub.ro:~$ ssh -i ~/.ssh/id_rsa ubuntu@$VM_IP_ADDRESS # LOGIN

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]

Introducere

Iată că ne regăsim la sfârșitul semestrului I din anul 3, după o lungă și dificilă călătorie prin tainele ingineriei Calculatoarelor, traseu ce a adus în planul de studiu și materia Rețele locale.

Î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ă.

Arhitectura Monolită vs. Arhitectura bazată pe microservicii

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:

  • nivelul de utilizare va fi unul mediu
  • va porni cu un set minimal de funcționalități, dar va fi extins în timp
  • echipa de dezvoltatori este foarte dinamică din perspectiva migrării către alte proiecte, iar cunoștințele legate de anumite limbaje de programare sunt neuniforme
  • se dorește ca serviciul să aibă o rată de availability care să tindă spre procentul de 99% (Maxim 7 ore 18 minute și 17.5 secunde downtime pe lună - https://uptime.is/ )
  • se estimează că în perioada sărbătorilor vor exista spike-uri în utilizare, întrucât audiența va căuta activ poze cu mesaje motivaționale.

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.

Exerciții

01. [20p] Hello Docker

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)

Dacă nu înțelegeți încă această ultimă sintagmă… SO is coming!

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}}'

Pentru a forța execuția continuă a containerului, putem utiliza argumentele -dit (referințe suplimentare în pagina de manual docker-run)

  • -d detach
  • -i interactive
  • -t pseudo-tty

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

Afișați pe rând:

  • pid-ul utilizatorului curent: id
  • adresa ip a containerului: ip a
  • toate procesele care rulează pe sistem: ps -ef sau ps aux
  • hostname-ul: hostname
  • variabilele de mediu: env
  • și ieșiți: exit

În continuare vom vorbi despre conceptul de Docker Images.

02. [20p] 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.

În cadrul secțiunii curente vom parcurge etapele construcției unei imagini Docker scoțând în evidență câteva particularități de implementare.

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

Un aspect foarte important din output-ul anterior îl reprezintă coloana Official, atribut ce garantează faptul că imaginea vizată este întreținută și verificată de către o comunitate de încredere, validată de către Docker.

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

Convenție de nume extinsă

Convenție de nume extinsă

În sens mai extins, în cazul în care organizația în care lucrăm deține propriul repository de artefacte Docker (propriul Docker Registry), similar cu menținerea propriului Code Repository (ex: GitHub Enterprise, GitLab on premise, Atlassian Bitbucket etc.), convenția de nume este extinsă:

<adresă/hostname registry>:<port (oficial 5000)>/<user (repository)>/<Nume Aplicație (Nume Imagine)>:<tag (versiune, release)> mypersonalregistry:5000/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 | json_pp

Câte layere pot fi numărate? De ce?

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:

  • primele nivele sunt moștenite din construcția imaginii de bază Ubuntu cu tag-ul latest (FROM ubuntu:latest)
  • ultimul nivel este adăugat de noi prin instrucțiunea 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>

De ce returnează eroare? Ce putem face pentru a șterge imaginea?

O soluție a problemei anterioare o reprezintă ștergerea containerului care utilizează imaginea vizată:

student@aldebaran:~$ docker container ps -a

Care este containerul care utilizează imaginea vizată?

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.

03. [20p] Docker Networking

Așa cum ați văzut în laboratoarele anterioare de LXC, și în 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.

Expunerea către exterior a serviciilor dintr-un container

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.

Unde ați mai văzut un astfel de setup? Cum se numește acest procedeu?

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

Care sunt regulile de iptables care au fost generate de parametrul publish?

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.

Accesați din browser serviciul de frontend: http://<IP mașină gazdă>:8080

Service Discovery

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

Dar cum au reușit serviciile să se conecteze între ele?

Î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 
[...]

Pentru a ieși CTRL + ] sau comanda exit

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.

04. [20p] Docker Volumes

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?

05. [20p] Orchestrare cu Docker SWARM

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).

O mașină virtuală va avea rol de Master, iar cealaltă mașină va deservi ca Worker.

Î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:

  • MASTER - nodul/VM-ul care a fost ales, în cadul echipei, ca Docker SWARM master
  • WORKER - nodurile/VM-urile care au fost alese, în cadrul echipei, ca Docker SWARM workers

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.

Dați refresh de mai multe ori paginii. Ce observați?

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.

Chiar și cu capabilitatea nativă de balansare pe care o aduce Docker SWARM, în producția reală, în cele mai multe cazuri, se recomandă utilizarea unor appliances dedicate, optimizate pentru funcția de Load Balancing.

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ă.

06. [BONUS - 10p] Feedback

Pentru că ne dorim să fim mai buni și să oferim laboratoare de calitate, am aprecia dacă vei acorda 2 minute pentru a completa feedback-ul pentru acest laborator.

Pentru a puncta acest exercițiu trebuie să îi arătați asistentului ecranul de confirmare după completarea formularului.

Cunoștințe acumulate

  • Intoducere în Docker CLI
  • Docker Container vs Docker Image
  • Dockerfile și construcția de Imagini
  • CMD vs ENDPOINT
  • Union File System: Overlay2 - image layers
  • Docker Volumes
  • Mount binding vs volumes
  • Docker Networking: bridge & overlay (VXLAN)
  • Docker SWARM
  • Introducere în arhitecturi pe microservicii
  • Arhitecturi pe microservicii vs Arhitecturi monolite
rl/labs/11.txt · Last modified: 2019/12/09 14:55 by marius.calapod
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0