Anxietate de separare: un tutorial pentru izolarea sistemului cu spații de nume Linux

Publicat: 2022-03-11

Odată cu apariția instrumentelor precum Docker, Linux Containers și altele, a devenit foarte ușor să izolați procesele Linux în propriile lor medii de sistem mici. Acest lucru face posibilă rularea unei game întregi de aplicații pe o singură mașină Linux reală și vă asigură că două dintre ele nu pot interfera între ele, fără a fi nevoie să recurgeți la utilizarea mașinilor virtuale. Aceste instrumente au fost un avantaj imens pentru furnizorii PaaS. Dar ce se întâmplă mai exact sub capotă?

Aceste instrumente se bazează pe o serie de caracteristici și componente ale nucleului Linux. Unele dintre aceste caracteristici au fost introduse destul de recent, în timp ce altele necesită încă să patchați nucleul în sine. Dar una dintre componentele cheie, folosind spațiile de nume Linux, a fost o caracteristică a Linux de când versiunea 2.6.24 a fost lansată în 2008.

Oricine este familiarizat cu chroot are deja o idee de bază despre ce pot face spațiile de nume Linux și despre cum să folosească spațiul de nume în general. Așa cum chroot permite proceselor să vadă orice director arbitrar ca rădăcină a sistemului (independent de restul proceselor), spațiile de nume Linux permit și alte aspecte ale sistemului de operare să fie modificate independent. Aceasta include arborele de proces, interfețele de rețea, punctele de montare, resursele de comunicare între procese și multe altele.

De ce să folosiți spațiile de nume pentru izolarea proceselor?

Într-un computer cu un singur utilizator, un singur mediu de sistem poate fi bine. Dar pe un server, în care doriți să rulați mai multe servicii, este esențial pentru securitate și stabilitate ca serviciile să fie cât mai izolate unele de altele. Imaginați-vă un server care rulează mai multe servicii, dintre care unul este compromis de un intrus. Într-un astfel de caz, intrusul poate fi capabil să exploateze acel serviciu și să se îndrepte către celelalte servicii și poate chiar să compromită întregul server. Izolarea spațiului de nume poate oferi un mediu sigur pentru a elimina acest risc.

De exemplu, folosind spațiarea numelor, este posibil să executați în siguranță programe arbitrare sau necunoscute pe serverul dvs. Recent, a existat un număr tot mai mare de concursuri de programare și platforme „hackathon”, cum ar fi HackerRank, TopCoder, Codeforces și multe altele. Mulți dintre ei utilizează conducte automate pentru a rula și valida programele care sunt trimise de concurenți. Adesea este imposibil să cunoști dinainte adevărata natură a programelor concurenților, iar unele pot conține chiar elemente rău intenționate. Prin rularea acestor programe cu spații de nume complet izolate de restul sistemului, software-ul poate fi testat și validat fără a pune în pericol restul mașinii. În mod similar, serviciile de integrare continuă online, cum ar fi Drone.io, preiau automat depozitul de coduri și execută scripturile de testare pe propriile servere. Din nou, izolarea spațiului de nume este ceea ce face posibilă furnizarea acestor servicii în siguranță.

Instrumentele de spațiere a numelor precum Docker permit, de asemenea, un control mai bun asupra utilizării resurselor sistemului de către procese, făcând astfel de instrumente extrem de populare pentru utilizarea de către furnizorii PaaS. Servicii precum Heroku și Google App Engine folosesc astfel de instrumente pentru a izola și a rula mai multe aplicații de server web pe același hardware real. Aceste instrumente le permit să ruleze fiecare aplicație (care poate să fi fost implementată de oricare dintre un număr de utilizatori diferiți) fără să-și facă griji că unul dintre ei folosește prea multe resurse de sistem sau să interfereze și/sau să intre în conflict cu alte servicii implementate pe aceeași mașină. Cu o astfel de izolare a proceselor, este chiar posibil să existe stive complet diferite de software-uri (și versiuni) de dependență pentru fiecare mediu izolat!

Dacă ați folosit instrumente precum Docker, știți deja că aceste instrumente sunt capabile să izoleze procesele în „containere” mici. Rularea proceselor în containerele Docker este ca și cum le rulați în mașinile virtuale, doar că aceste containere sunt semnificativ mai ușoare decât mașinile virtuale. O mașină virtuală emulează de obicei un strat hardware deasupra sistemului de operare și apoi rulează un alt sistem de operare pe deasupra. Acest lucru vă permite să rulați procese în interiorul unei mașini virtuale, complet izolat de sistemul dvs. de operare real. Dar mașinile virtuale sunt grele! Containerele Docker, pe de altă parte, folosesc unele caracteristici cheie ale sistemului dumneavoastră de operare real, inclusiv spațiile de nume, și asigură un nivel similar de izolare, dar fără a emula hardware-ul și fără a rula încă un sistem de operare pe aceeași mașină. Acest lucru le face foarte ușoare.

Spațiu de nume de proces

Din punct de vedere istoric, nucleul Linux a menținut un singur arbore de proces. Arborele conține o referință la fiecare proces care rulează în prezent într-o ierarhie părinte-copil. Un proces, având în vedere că are suficiente privilegii și îndeplinește anumite condiții, poate inspecta un alt proces atașându-i un trasor sau poate chiar să-l omoare.

Odată cu introducerea spațiilor de nume Linux, a devenit posibil să existe mai mulți arbori de proces „imbricați”. Fiecare arbore de proces poate avea un set complet izolat de procese. Acest lucru poate asigura că procesele aparținând unui arbore de proces nu pot inspecta sau ucide - de fapt nici măcar nu pot ști despre existența - procese în alți arbori de proces frați sau părinte.

De fiecare dată când pornește un computer cu Linux, acesta începe cu un singur proces, cu identificatorul de proces (PID) 1. Acest proces este rădăcina arborelui de proces și inițiază restul sistemului prin efectuarea lucrărilor de întreținere corespunzătoare și pornirea demonii/serviciile corecte. Toate celelalte procese încep sub acest proces în arbore. Spațiul de nume PID permite cuiva să dezvolte un nou arbore, cu propriul său proces PID 1. Procesul care face acest lucru rămâne în spațiul de nume părinte, în arborele original, dar face din copil rădăcina propriului arbore de procese.

Cu izolarea spațiului de nume PID, procesele din spațiul de nume copil nu au de unde să cunoască existența procesului părinte. Cu toate acestea, procesele din spațiul de nume părinte au o vedere completă a proceselor din spațiul de nume copil, ca și cum ar fi orice alt proces din spațiul de nume părinte.

Acest tutorial despre spațiul de nume conturează separarea diferiților arbori de procese folosind sisteme de spațiu de nume în Linux.

Este posibil să se creeze un set imbricat de spații de nume copil: un proces pornește un proces copil într-un spațiu de nume PID nou, iar acel proces copil generează încă un proces într-un spațiu de nume PID nou și așa mai departe.

Odată cu introducerea spațiilor de nume PID, un singur proces poate avea acum mai multe PID-uri asociate, câte unul pentru fiecare spațiu de nume sub care se încadrează. În codul sursă Linux, putem vedea că o structură numită pid , care obișnuia să țină evidența unui singur PID, acum urmărește mai multe PID-uri prin utilizarea unei structuri numite upid :

 struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };

Pentru a crea un nou spațiu de nume PID, trebuie să apelați apelul de sistem clone() cu un flag special CLONE_NEWPID . (C oferă un wrapper pentru a expune acest apel de sistem, la fel și multe alte limbi populare.) În timp ce celelalte spații de nume discutate mai jos pot fi create și folosind apelul de sistem unshare() , un spațiu de nume PID poate fi creat numai în momentul în care un nou procesul este generat folosind clone() . Odată ce clone() este apelat cu acest indicator, noul proces începe imediat într-un nou spațiu de nume PID, sub un nou arbore de proces. Acest lucru poate fi demonstrat cu un program simplu C:

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }

Compilați și rulați acest program cu privilegii de root și veți observa o ieșire care seamănă cu aceasta:

 clone() = 5304 PID: 1

PID-ul, așa cum este imprimat din interiorul child_fn , va fi 1 .

Chiar dacă acest cod de tutorial al spațiului de nume de mai sus nu este cu mult mai lung decât „Bună ziua, lume” în unele limbi, multe s-au întâmplat în culise. Funcția clone() , așa cum v-ați aștepta, a creat un nou proces prin clonarea celui curent și a început execuția la începutul funcției child_fn() . Cu toate acestea, în timp ce făcea acest lucru, a detașat noul proces de arborele de proces original și a creat un arbore de proces separat pentru noul proces.

Încercați să înlocuiți funcția static int child_fn() cu următoarele, pentru a imprima PID-ul părinte din perspectiva procesului izolat:

 static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }

Rularea programului de această dată produce următorul rezultat:

 clone() = 11449 Parent PID: 0

Observați cum PID-ul părinte din perspectiva procesului izolat este 0, indicând niciun părinte. Încercați să rulați din nou același program, dar de data aceasta, eliminați CLONE_NEWPID din apelul funcției clone() :

 pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);

De data aceasta, veți observa că PID-ul părinte nu mai este 0:

 clone() = 11561 Parent PID: 11560

Cu toate acestea, acesta este doar primul pas din tutorialul nostru. Aceste procese au încă acces nerestricționat la alte resurse comune sau partajate. De exemplu, interfața de rețea: dacă procesul copil creat mai sus ar asculta pe portul 80, ar împiedica orice alt proces din sistem să poată asculta pe el.

Spațiu de nume de rețea Linux

Aici devine util un spațiu de nume de rețea. Un spațiu de nume de rețea permite fiecăruia dintre aceste procese să vadă un set complet diferit de interfețe de rețea. Chiar și interfața de loopback este diferită pentru fiecare spațiu de nume de rețea.

Izolarea unui proces în propriul său spațiu de nume de rețea implică introducerea unui alt flag în apelul funcției clone() : CLONE_NEWNET ;

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }

Ieșire:

 Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

Ce se petrece aici? Dispozitivul fizic Ethernet enp4s0 aparține spațiului de nume rețelei globale, așa cum este indicat de instrumentul „ip” rulat din acest spațiu de nume. Cu toate acestea, interfața fizică nu este disponibilă în noul spațiu de nume de rețea. Mai mult, dispozitivul de loopback este activ în spațiul de nume de rețea original, dar este „în jos” în spațiul de nume de rețea copil.

Pentru a oferi o interfață de rețea utilizabilă în spațiul de nume copil, este necesar să configurați interfețe de rețea „virtuale” suplimentare care acoperă mai multe spații de nume. Odată făcut acest lucru, este posibil să se creeze punți Ethernet și chiar să direcționeze pachete între spațiile de nume. În cele din urmă, pentru ca totul să funcționeze, trebuie să ruleze un „proces de rutare” în spațiul de nume rețelei globale pentru a primi traficul de la interfața fizică și a-l direcționa prin interfețele virtuale corespunzătoare către spațiile de nume ale rețelei secundare corecte. Poate puteți vedea de ce instrumente precum Docker, care fac toate aceste sarcini grele pentru dvs., sunt atât de populare!

Spațiul de nume de rețea Linux este format dintr-un proces de rutare către mai multe spații de nume de rețea copii.

Pentru a face acest lucru manual, puteți crea o pereche de conexiuni Ethernet virtuale între un spațiu de nume părinte și un copil rulând o singură comandă din spațiul de nume părinte:

 ip link add name veth0 type veth peer name veth1 netns <pid>

Aici, <pid> ar trebui să fie înlocuit cu ID-ul procesului din spațiul de nume copil, așa cum este observat de părinte. Rularea acestei comenzi stabilește o conexiune asemănătoare conductei între aceste două spații de nume. Spațiul de nume părinte reține dispozitivul veth0 și transmite dispozitivul veth1 spațiului de nume copil. Orice lucru care intră într-unul dintre capete iese prin celălalt capăt, așa cum v-ați aștepta de la o conexiune Ethernet reală între două noduri reale. În consecință, ambelor părți ale acestei conexiuni Ethernet virtuale trebuie să li se atribuie adrese IP.

Mount Namespace

Linux menține, de asemenea, o structură de date pentru toate punctele de montare ale sistemului. Include informații precum ce partiții de disc sunt montate, unde sunt montate, dacă sunt doar pentru citire, etc. Cu spațiile de nume Linux, se poate clona această structură de date, astfel încât procesele din spații de nume diferite să poată schimba punctele de montare fără a se afecta reciproc.

Crearea unui spațiu de nume de montare separat are un efect similar cu realizarea unui chroot() . chroot() este bun, dar nu oferă o izolare completă, iar efectele sale sunt limitate doar la punctul de montare rădăcină. Crearea unui spațiu de nume de montare separat permite fiecăruia dintre aceste procese izolate să aibă o vedere complet diferită a structurii punctului de montare a întregului sistem față de cea originală. Acest lucru vă permite să aveți o rădăcină diferită pentru fiecare proces izolat, precum și alte puncte de montare care sunt specifice proceselor respective. Folosit cu grijă conform acestui tutorial, puteți evita expunerea oricărei informații despre sistemul de bază.

A învăța cum să utilizați corect spațiul de nume are multiple beneficii, așa cum este subliniat în acest tutorial despre spațiul de nume.

Indicatorul clone() necesar pentru a realiza acest lucru este CLONE_NEWNS :

 clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)

Inițial, procesul copil vede exact aceleași puncte de montare ca și procesul părinte. Cu toate acestea, fiind sub un nou spațiu de nume de montare, procesul copil poate monta sau demonta orice puncte finale dorește, iar modificarea nu va afecta nici spațiul de nume al părintelui, nici orice alt spațiu de nume de montare din întregul sistem. De exemplu, dacă procesul părinte are o anumită partiție de disc montată la rădăcină, procesul izolat va vedea exact aceeași partiție de disc montată la rădăcină la început. Dar beneficiul izolării spațiului de nume de montare este evident atunci când procesul izolat încearcă să schimbe partiția rădăcină cu altceva, deoarece modificarea va afecta doar spațiul de nume de montare izolat.

În mod interesant, acest lucru face de fapt o idee proastă să generați procesul copil țintă direct cu semnalizarea CLONE_NEWNS . O abordare mai bună este să porniți un proces special de „inițializare” cu CLONE_NEWNS , să faceți ca procesul „init” să schimbe „/”, „/proc”, „/dev” sau alte puncte de montare după cum doriți, apoi să începeți procesul țintă . Acest lucru este discutat puțin mai detaliat la sfârșitul acestui tutorial privind spațiul de nume.

Alte spații de nume

Există și alte spații de nume în care aceste procese pot fi izolate, și anume utilizator, IPC și UTS. Spațiul de nume de utilizator permite unui proces să aibă privilegii de rădăcină în spațiul de nume, fără a-i oferi acel acces la procesele din afara spațiului de nume. Izolarea unui proces de către spațiul de nume IPC îi oferă propriile resurse de comunicare între procese, de exemplu, mesajele System V IPC și POSIX. Spațiul de nume UTS izolează doi identificatori specifici ai sistemului: nodename și domainname .

Un exemplu rapid pentru a arăta cum este izolat spațiul de nume UTS este prezentat mai jos:

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }

Acest program produce următoarele rezultate:

 Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT

Aici, child_fn() tipărește numele nodename , îl schimbă cu altceva și îl tipărește din nou. Desigur, schimbarea are loc numai în noul spațiu de nume UTS.

Mai multe informații despre ceea ce oferă și izolează toate spațiile de nume pot fi găsite în tutorialul aici

Comunicare între spații de nume

Adesea este necesar să se stabilească un fel de comunicare între spațiul de nume părinte și copil. Acest lucru ar putea fi pentru a efectua lucrări de configurare într-un mediu izolat sau poate fi pur și simplu pentru a păstra capacitatea de a observa starea acelui mediu din exterior. O modalitate de a face acest lucru este să păstrați un daemon SSH care rulează în acel mediu. Puteți avea un daemon SSH separat în interiorul fiecărui spațiu de nume de rețea. Cu toate acestea, rularea mai multor demoni SSH utilizează o mulțime de resurse valoroase, cum ar fi memoria. Aici se dovedește a fi din nou o idee bună a avea un proces special de „inițializare”.

Procesul „init” poate stabili un canal de comunicare între spațiul de nume părinte și spațiul de nume copil. Acest canal poate fi bazat pe socket-uri UNIX sau chiar poate folosi TCP. Pentru a crea un socket UNIX care se întinde pe două spații de nume de montare diferite, trebuie să creați mai întâi procesul copil, apoi să creați socket-ul UNIX și apoi să izolați copilul într-un spațiu de nume de montare separat. Dar cum putem crea mai întâi procesul și să-l izolam mai târziu? Linux oferă unshare() . Acest apel de sistem special permite unui proces să se izoleze de spațiul de nume original, în loc ca părintele să izoleze copilul în primul rând. De exemplu, următorul cod are exact același efect ca și codul menționat anterior în secțiunea spațiu de nume de rețea:

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }

Și, deoarece procesul „inițial” este ceva pe care l-ați conceput, îl puteți face mai întâi să facă toate lucrările necesare și apoi să se izoleze de restul sistemului înainte de a executa copilul țintă.

Concluzie

Acest tutorial este doar o prezentare generală a modului de utilizare a spațiilor de nume în Linux. Ar trebui să vă ofere o idee de bază despre modul în care un dezvoltator Linux ar putea începe să implementeze izolarea sistemului, o parte integrantă a arhitecturii instrumentelor precum Docker sau Linux Containers. În cele mai multe cazuri, cel mai bine ar fi să utilizați pur și simplu unul dintre aceste instrumente existente, care sunt deja binecunoscute și testate. Dar, în unele cazuri, ar putea avea sens să aveți propriul mecanism personalizat de izolare a procesului și, în acest caz, acest tutorial privind spațiul de nume vă va ajuta enorm.

Se întâmplă mult mai multe sub capotă decât am tratat în acest articol și există mai multe modalități în care ați putea dori să vă limitați procesele țintă pentru mai multă siguranță și izolare. Dar, sperăm, acest lucru poate servi ca un punct de plecare util pentru cineva care este interesat să afle mai multe despre cum funcționează cu adevărat izolarea spațiului de nume cu Linux.