Ansia da separazione: un tutorial per isolare il tuo sistema con gli spazi dei nomi Linux
Pubblicato: 2022-03-11Con l'avvento di strumenti come Docker, Linux Containers e altri, è diventato semplicissimo isolare i processi Linux nei loro piccoli ambienti di sistema. Ciò consente di eseguire un'intera gamma di applicazioni su una singola macchina Linux reale e garantire che nessuna di esse possa interferire tra loro, senza dover ricorrere all'utilizzo di macchine virtuali. Questi strumenti sono stati un enorme vantaggio per i provider PaaS. Ma cosa succede esattamente sotto il cofano?
Questi strumenti si basano su una serie di funzionalità e componenti del kernel Linux. Alcune di queste funzionalità sono state introdotte abbastanza di recente, mentre altre richiedono ancora la patch del kernel stesso. Ma uno dei componenti chiave, l'utilizzo degli spazi dei nomi Linux, è stata una funzionalità di Linux sin dal rilascio della versione 2.6.24 nel 2008.
Chiunque abbia familiarità con chroot
ha già un'idea di base di cosa possono fare gli spazi dei nomi Linux e di come utilizzare lo spazio dei nomi in generale. Proprio come chroot
consente ai processi di vedere qualsiasi directory arbitraria come radice del sistema (indipendente dal resto dei processi), gli spazi dei nomi Linux consentono anche di modificare in modo indipendente altri aspetti del sistema operativo. Ciò include l'albero dei processi, le interfacce di rete, i punti di montaggio, le risorse di comunicazione tra processi e altro ancora.
Perché utilizzare gli spazi dei nomi per l'isolamento dei processi?
In un computer a utente singolo, un unico ambiente di sistema potrebbe andare bene. Ma su un server, in cui si desidera eseguire più servizi, è essenziale per la sicurezza e la stabilità che i servizi siano il più possibile isolati l'uno dall'altro. Immagina un server che esegue più servizi, uno dei quali viene compromesso da un intruso. In tal caso, l'intruso potrebbe essere in grado di sfruttare quel servizio e raggiungere gli altri servizi e potrebbe persino compromettere l'intero server. L'isolamento dello spazio dei nomi può fornire un ambiente sicuro per eliminare questo rischio.
Ad esempio, utilizzando lo spazio dei nomi, è possibile eseguire in sicurezza programmi arbitrari o sconosciuti sul server. Di recente, c'è stato un numero crescente di concorsi di programmazione e piattaforme "hackathon", come HackerRank, TopCoder, Codeforces e molti altri. Molti di loro utilizzano pipeline automatizzate per eseguire e convalidare i programmi inviati dai concorrenti. Spesso è impossibile conoscere in anticipo la vera natura dei programmi dei concorrenti e alcuni possono persino contenere elementi dannosi. Eseguendo questi programmi in uno spazio dei nomi completamente isolato dal resto del sistema, il software può essere testato e convalidato senza mettere a rischio il resto della macchina. Allo stesso modo, i servizi di integrazione continua online, come Drone.io, recuperano automaticamente il tuo repository di codice ed eseguono gli script di test sui propri server. Ancora una volta, l'isolamento dello spazio dei nomi è ciò che rende possibile fornire questi servizi in modo sicuro.
Gli strumenti di namespace come Docker consentono anche un migliore controllo sull'utilizzo delle risorse di sistema da parte dei processi, rendendo tali strumenti estremamente popolari per l'uso da parte dei provider PaaS. Servizi come Heroku e Google App Engine utilizzano tali strumenti per isolare ed eseguire più applicazioni server Web sullo stesso hardware reale. Questi strumenti consentono loro di eseguire ogni applicazione (che potrebbe essere stata distribuita da un numero qualsiasi di utenti diversi) senza preoccuparsi che una di esse utilizzi troppe risorse di sistema o interferisca e/o sia in conflitto con altri servizi distribuiti sulla stessa macchina. Con tale isolamento del processo, è persino possibile avere stack completamente diversi di software (e versioni) di dipendenza per ogni ambiente isolato!
Se hai utilizzato strumenti come Docker, sai già che questi strumenti sono in grado di isolare i processi in piccoli "contenitori". L'esecuzione di processi nei contenitori Docker è come eseguirli in macchine virtuali, solo che questi contenitori sono significativamente più leggeri delle macchine virtuali. Una macchina virtuale in genere emula un livello hardware sopra il tuo sistema operativo, quindi esegue un altro sistema operativo su quello. Ciò ti consente di eseguire processi all'interno di una macchina virtuale, in completo isolamento dal tuo sistema operativo reale. Ma le macchine virtuali sono pesanti! I container Docker, d'altra parte, utilizzano alcune funzionalità chiave del tuo sistema operativo reale, inclusi gli spazi dei nomi, e garantiscono un livello di isolamento simile, ma senza emulare l'hardware ed eseguire un altro sistema operativo sulla stessa macchina. Questo li rende molto leggeri.
Spazio dei nomi di processo
Storicamente, il kernel Linux ha mantenuto un singolo albero dei processi. L'albero contiene un riferimento a ogni processo attualmente in esecuzione in una gerarchia padre-figlio. Un processo, dato che dispone di privilegi sufficienti e soddisfa determinate condizioni, può ispezionare un altro processo allegandogli un tracciante o addirittura essere in grado di ucciderlo.
Con l'introduzione degli spazi dei nomi Linux, è diventato possibile avere più alberi di processo "nidificati". Ciascun albero dei processi può avere un insieme di processi completamente isolato. Ciò può garantire che i processi appartenenti a un albero di processi non possano ispezionare o uccidere - in effetti non possono nemmeno conoscere l'esistenza di - processi in altri alberi di processo fratelli o padre.
Ogni volta che un computer con Linux si avvia, inizia con un solo processo, con identificatore di processo (PID) 1. Questo processo è la radice dell'albero dei processi e avvia il resto del sistema eseguendo gli opportuni lavori di manutenzione e avviando i demoni/servizi corretti. Tutti gli altri processi iniziano sotto questo processo nell'albero. Lo spazio dei nomi PID consente di creare un nuovo albero, con il proprio processo PID 1. Il processo che esegue questa operazione rimane nello spazio dei nomi padre, nell'albero originale, ma rende il figlio la radice del proprio albero di processo.
Con l'isolamento dello spazio dei nomi PID, i processi nello spazio dei nomi figlio non hanno modo di conoscere l'esistenza del processo padre. Tuttavia, i processi nello spazio dei nomi padre hanno una visione completa dei processi nello spazio dei nomi figlio, come se fossero qualsiasi altro processo nello spazio dei nomi padre.
È possibile creare un insieme nidificato di spazi dei nomi figlio: un processo avvia un processo figlio in un nuovo spazio dei nomi PID e quel processo figlio genera un altro processo in un nuovo spazio dei nomi PID e così via.
Con l'introduzione degli spazi dei nomi PID, un singolo processo può ora avere più PID associati, uno per ogni spazio dei nomi in cui rientra. Nel codice sorgente di Linux, possiamo vedere che una struttura denominata pid
, che prima teneva traccia di un solo PID, ora tiene traccia di più PID attraverso l'uso di una struttura denominata 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 };
Per creare un nuovo spazio dei nomi PID, è necessario chiamare la chiamata di sistema clone()
con un flag speciale CLONE_NEWPID
. (C fornisce un wrapper per esporre questa chiamata di sistema, e così fanno molti altri linguaggi popolari.) Mentre gli altri spazi dei nomi discussi di seguito possono anche essere creati usando la chiamata di sistema unshare()
, uno spazio dei nomi PID può essere creato solo nel momento in cui un nuovo il processo viene generato usando clone()
. Una volta chiamato clone()
con questo flag, il nuovo processo inizia immediatamente in un nuovo spazio dei nomi PID, sotto un nuovo albero di processi. Questo può essere dimostrato con un semplice programma 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 ed esegui questo programma con i privilegi di root e noterai un output simile a questo:
clone() = 5304 PID: 1
Il PID, come stampato da child_fn
, sarà 1
.
Anche se questo codice tutorial dello spazio dei nomi sopra non è molto più lungo di "Hello, world" in alcune lingue, dietro le quinte sono successe molte cose. La funzione clone()
, come ci si aspetterebbe, ha creato un nuovo processo clonando quello corrente e ha iniziato l'esecuzione all'inizio della funzione child_fn()
. Tuttavia, nel farlo, ha staccato il nuovo processo dall'albero dei processi originale e ha creato un albero dei processi separato per il nuovo processo.
Prova a sostituire la funzione static int child_fn()
con la seguente, per stampare il PID padre dalla prospettiva del processo isolato:
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
L'esecuzione del programma questa volta produce il seguente output:
clone() = 11449 Parent PID: 0
Si noti come il PID padre dal punto di vista del processo isolato sia 0, indicando nessun genitore. Prova a eseguire di nuovo lo stesso programma, ma questa volta rimuovi il flag CLONE_NEWPID
dalla chiamata alla funzione clone()
:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
Questa volta, noterai che il PID padre non è più 0:
clone() = 11561 Parent PID: 11560
Tuttavia, questo è solo il primo passaggio del nostro tutorial. Questi processi hanno ancora accesso illimitato ad altre risorse comuni o condivise. Ad esempio, l'interfaccia di rete: se il processo figlio creato sopra fosse in ascolto sulla porta 80, impedirebbe a tutti gli altri processi del sistema di essere in grado di ascoltarlo.
Spazio dei nomi di rete Linux
È qui che diventa utile uno spazio dei nomi di rete. Uno spazio dei nomi di rete consente a ciascuno di questi processi di visualizzare un insieme completamente diverso di interfacce di rete. Anche l'interfaccia di loopback è diversa per ogni spazio dei nomi di rete.
L'isolamento di un processo nel proprio spazio dei nomi di rete implica l'introduzione di un altro flag nella chiamata alla funzione 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; }
Produzione:

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
Cosa sta succedendo qui? Il dispositivo fisico ethernet enp4s0
appartiene allo spazio dei nomi di rete globale, come indicato dallo strumento "ip" eseguito da questo spazio dei nomi. Tuttavia, l'interfaccia fisica non è disponibile nel nuovo spazio dei nomi di rete. Inoltre, il dispositivo di loopback è attivo nello spazio dei nomi di rete originale, ma è "inattivo" nello spazio dei nomi di rete figlio.
Per fornire un'interfaccia di rete utilizzabile nello spazio dei nomi figlio, è necessario configurare interfacce di rete "virtuali" aggiuntive che si estendono su più spazi dei nomi. Una volta fatto, è quindi possibile creare bridge Ethernet e persino instradare pacchetti tra i namespace. Infine, per far funzionare tutto, un "processo di routing" deve essere in esecuzione nello spazio dei nomi di rete globale per ricevere traffico dall'interfaccia fisica e instradarlo attraverso le interfacce virtuali appropriate agli spazi dei nomi di rete figlio corretti. Forse puoi capire perché strumenti come Docker, che fanno tutto questo lavoro pesante per te, sono così popolari!
Per fare ciò manualmente, puoi creare una coppia di connessioni Ethernet virtuali tra uno spazio dei nomi padre e uno spazio dei nomi figlio eseguendo un singolo comando dallo spazio dei nomi padre:
ip link add name veth0 type veth peer name veth1 netns <pid>
Qui, <pid>
dovrebbe essere sostituito dall'ID processo del processo nello spazio dei nomi figlio come osservato dal genitore. L'esecuzione di questo comando stabilisce una connessione simile a una pipe tra questi due spazi dei nomi. Lo spazio dei nomi padre conserva il dispositivo veth0
e passa il dispositivo veth1
allo spazio dei nomi figlio. Tutto ciò che entra da una delle estremità, esce dall'altra estremità, proprio come ci si aspetterebbe da una vera connessione Ethernet tra due nodi reali. Di conseguenza, a entrambi i lati di questa connessione Ethernet virtuale devono essere assegnati indirizzi IP.
Montare lo spazio dei nomi
Linux mantiene anche una struttura dati per tutti i punti di montaggio del sistema. Include informazioni come quali partizioni del disco sono montate, dove sono montate, se sono di sola lettura, ecc. Con gli spazi dei nomi Linux, è possibile clonare questa struttura dati, in modo che i processi con spazi dei nomi diversi possano modificare i punti di montaggio senza influenzarsi a vicenda.
La creazione di uno spazio dei nomi di montaggio separato ha un effetto simile all'esecuzione di un chroot()
. chroot()
è buono, ma non fornisce un isolamento completo e i suoi effetti sono limitati solo al punto di montaggio root. La creazione di uno spazio dei nomi di montaggio separato consente a ciascuno di questi processi isolati di avere una vista completamente diversa della struttura del punto di montaggio dell'intero sistema da quella originale. Ciò consente di avere una radice diversa per ogni processo isolato, nonché altri punti di montaggio specifici di tali processi. Usato con cura in questo tutorial, puoi evitare di esporre qualsiasi informazione sul sistema sottostante.
Il flag clone()
richiesto per ottenere ciò è CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
Inizialmente, il processo figlio vede esattamente gli stessi punti di montaggio del processo padre. Tuttavia, trovandosi in un nuovo spazio dei nomi di montaggio, il processo figlio può montare o smontare qualsiasi endpoint desideri e la modifica non influirà né sullo spazio dei nomi del genitore, né su qualsiasi altro spazio dei nomi di montaggio nell'intero sistema. Ad esempio, se il processo padre ha una particolare partizione del disco montata alla radice, il processo isolato vedrà la stessa identica partizione del disco montata nella radice all'inizio. Ma il vantaggio di isolare lo spazio dei nomi di montaggio è evidente quando il processo isolato tenta di modificare la partizione di root in qualcos'altro, poiché la modifica influenzerà solo lo spazio dei nomi di montaggio isolato.
È interessante notare che questo rende effettivamente una cattiva idea generare il processo figlio di destinazione direttamente con il flag CLONE_NEWNS
. Un approccio migliore è avviare uno speciale processo "init" con il flag CLONE_NEWNS
, fare in modo che il processo "init" modifichi "/", "/proc", "/dev" o altri punti di montaggio come desiderato, quindi avviare il processo di destinazione . Questo è discusso un po' più in dettaglio verso la fine di questo tutorial sullo spazio dei nomi.
Altri spazi dei nomi
Esistono altri spazi dei nomi in cui questi processi possono essere isolati, vale a dire utente, IPC e UTS. Lo spazio dei nomi utente consente a un processo di avere i privilegi di root all'interno dello spazio dei nomi, senza dargli l'accesso ai processi al di fuori dello spazio dei nomi. L'isolamento di un processo tramite lo spazio dei nomi IPC gli fornisce le proprie risorse di comunicazione tra processi, ad esempio messaggi IPC e POSIX di System V. Lo spazio dei nomi UTS isola due identificatori specifici del sistema: nodename
e domainname
.
Di seguito viene mostrato un rapido esempio per mostrare come viene isolato lo spazio dei nomi UTS:
#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; }
Questo programma produce il seguente output:
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
Qui, child_fn()
stampa il nodename
, lo cambia in qualcos'altro e lo stampa di nuovo. Naturalmente, la modifica avviene solo all'interno del nuovo spazio dei nomi UTS.
Maggiori informazioni su ciò che tutti gli spazi dei nomi forniscono e isolano possono essere trovate nel tutorial qui
Comunicazione tra spazi dei nomi
Spesso è necessario stabilire una sorta di comunicazione tra il genitore e lo spazio dei nomi figlio. Questo potrebbe essere per eseguire un lavoro di configurazione all'interno di un ambiente isolato, o può semplicemente essere per mantenere la capacità di sbirciare nelle condizioni di quell'ambiente dall'esterno. Un modo per farlo è mantenere un demone SSH in esecuzione all'interno di quell'ambiente. Puoi avere un demone SSH separato all'interno di ogni spazio dei nomi di rete. Tuttavia, avere più demoni SSH in esecuzione utilizza molte risorse preziose come la memoria. È qui che avere uno speciale processo di "inizializzazione" si rivela di nuovo una buona idea.
Il processo "init" può stabilire un canale di comunicazione tra lo spazio dei nomi padre e lo spazio dei nomi figlio. Questo canale può essere basato su socket UNIX o può anche utilizzare TCP. Per creare un socket UNIX che si estende su due diversi spazi dei nomi di montaggio, è necessario prima creare il processo figlio, quindi creare il socket UNIX e quindi isolare il figlio in uno spazio dei nomi di montaggio separato. Ma come possiamo creare prima il processo e isolarlo in seguito? Linux fornisce unshare()
. Questa speciale chiamata di sistema consente a un processo di isolarsi dallo spazio dei nomi originale, invece di fare in modo che il genitore isoli il figlio in primo luogo. Ad esempio, il codice seguente ha esattamente lo stesso effetto del codice menzionato in precedenza nella sezione dello spazio dei nomi di rete:
#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; }
E poiché il processo "init" è qualcosa che hai escogitato, puoi prima farlo fare tutto il lavoro necessario, quindi isolarsi dal resto del sistema prima di eseguire il figlio di destinazione.
Conclusione
Questo tutorial è solo una panoramica di come utilizzare gli spazi dei nomi in Linux. Dovrebbe darti un'idea di base di come uno sviluppatore Linux potrebbe iniziare a implementare l'isolamento del sistema, parte integrante dell'architettura di strumenti come Docker o Linux Containers. Nella maggior parte dei casi, sarebbe meglio utilizzare semplicemente uno di questi strumenti esistenti, che sono già noti e testati. Ma in alcuni casi, potrebbe avere senso avere il tuo meccanismo di isolamento del processo personalizzato e, in tal caso, questo tutorial sullo spazio dei nomi ti aiuterà enormemente.
C'è molto di più da fare sotto il cofano di quanto ho trattato in questo articolo e ci sono altri modi in cui potresti voler limitare i processi di destinazione per una maggiore sicurezza e isolamento. Ma, si spera, questo può servire come un utile punto di partenza per qualcuno che è interessato a saperne di più su come funziona davvero l'isolamento dello spazio dei nomi con Linux.