barco è un progetto su cui ho lavorato per approfondire la conoscenza dei container Linux e del kernel Linux. È una semplice implementazione di un container runtime in C, scritta da zero (basandosi su altre guide presenti su Internet) usando solo C, libseccomp per i filtri seccomp, libcap per le capabilities del container, libcuni1 per i test unitari con CUnit, argtable per la gestione della CLI e un'altra libreria di terze parti per il logging. Non è pensata per un uso in produzione, bensì come strumento didattico.
I container Linux sono composti da un insieme di funzionalità del kernel Linux:
namespaces: vengono usati per raggruppare oggetti del kernel in insiemi diversi accessibili da specifici alberi di processi. Esistono diversi tipi dinamespaces, ad esempio il namespacePIDviene usato per isolare l'albero dei processi, mentre il namespacenetworkviene usato per isolare lo stack di rete.seccomp: un meccanismo del kernel Linux usato per limitare le system call che un processo può effettuare (gestito tramite syscall)capabilities: un meccanismo del kernel Linux usato per stabilire limiti su ciò che l'utente root può fare nel container (gestito tramite syscall)cgroups: vengono usati per limitare le risorse (es. memoria, I/O su disco, tempo CPU) che un processo può utilizzare (gestito tramite cgroupfs)
barco usa tutte queste funzionalità per creare un container isolato dal sistema host. È un'implementazione molto semplice, ma sufficiente per capire come funzionano i container.
barco può essere usato per eseguire bin/sh . dalla directory / come root (-u 0) con il seguente comando (opzionale -v per output verbose):
$ sudo ./bin/barco -u 0 -m / -c /bin/sh -a . [-v]
22:08:41 INFO ./src/barco.c:96: initializing socket pair...
22:08:41 INFO ./src/barco.c:103: setting socket flags...
22:08:41 INFO ./src/barco.c:112: initializing container stack...
22:08:41 INFO ./src/barco.c:120: initializing container...
22:08:41 INFO ./src/barco.c:131: initializing cgroups...
22:08:41 INFO ./src/cgroups.c:73: setting memory.max to 1G...
22:08:41 INFO ./src/cgroups.c:73: setting cpu.weight to 256...
22:08:41 INFO ./src/cgroups.c:73: setting pids.max to 64...
22:08:41 INFO ./src/cgroups.c:73: setting cgroup.procs to 1458...
22:08:41 INFO ./src/barco.c:139: configuring user namespace...
22:08:41 INFO ./src/barco.c:147: waiting for container to exit...
22:08:41 INFO ./src/container.c:43: ### BARCONTAINER STARTING - type 'exit' to quit ###
# ls
bin home lib32 media root sys vmlinuz
boot initrd.img lib64 mnt run tmp vmlinuz.old
dev initrd.img.old libx32 opt sbin usr
etc lib lost+found proc srv var
# echo "i am a container"
i am a container
# exit
22:08:55 INFO ./src/barco.c:153: freeing resources...
22:08:55 INFO ./src/barco.c:168: so long and thanks for all the fishSviluppo
Se vuoi compilare ed eseguire barco dai sorgenti, puoi farlo clonando il repository e seguendo le istruzioni nel file README. In sintesi, barco fornisce una configurazione per lo sviluppo su GitHub Codespaces usando Visual Studio Code, e il Makefile incluso può essere usato per compilare ed eseguire il progetto come segue:
$ sudo apt install -y make
$ make buildIl Makefile contiene anche altri target per eseguire i test unitari, generare la documentazione, eseguire il formatter e il linter, ecc. (la maggior parte degli strumenti è nativa del compilatore Clang). Inoltre, mentre lavoravo su barco ho studiato le best practice per la struttura dei progetti C e ho adottato la seguente:
├── .devcontainer - configuration for GitHub Codespaces
├── .github - configuration GitHub Actions and other GitHub features
├── .vscode - configuration for Visual Studio Code
├── bin - the executable (created by make)
├── build - intermediate build files e.g. *.o (created by make)
├── docs - documentation
├── include - header files
├── lib - third-party libraries
├── scripts - scripts for setup and other tasks
├── src - C source files
│ ├── barco.c - (main) Entry point for the CLI
│ └── *.c
├── tests - contains tests
├── .clang-format - configuration for the formatter
├── .clang-tidy - configuration for the linter
├── .gitignore
├── LICENSE
├── Makefile
└── README.mdCome funziona?
L'eseguibile barco è il punto di ingresso della CLI. È responsabile del parsing degli argomenti CLI, della configurazione del container e dell'esecuzione del processo container: tutto inizia da barco.c, dove ho usato argtable per il parsing degli argomenti CLI e per configurare, avviare e infine liberare il container e le altre risorse. I primi due passi verso l'esecuzione di un container sono la creazione di una coppia di socket (per comunicare con il processo container) e l'inizializzazione dello stack del container (per configurare il processo container).
La Chiamata a container_init
Dopo la configurazione iniziale, viene chiamata la funzione container_init, definita in container.c, per avviare il processo container. La funzione è relativamente semplice: chiama la system call clone con una funzione da eseguire (container_start), la configurazione dello stack e i flag appropriati (CLONE_NEWNS | CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWNET | CLONE_NEWUTS consentono un certo controllo su mount, PID, strutture dati IPC, dispositivi di rete e hostname) per creare il processo container.
La Funzione container_start
Il processo risultante è un figlio del processo barco, ed è quello che eseguirà il container. La funzione container_start è il punto di ingresso del processo container, ed è definita in container.c. È responsabile della configurazione del container, e lo fa:
impostando l'hostname
impostando la directory root (mount namespace) a quella specificata dall'utente (tramite il flag
-m)configurando il user namespace
impostando capabilities e filtri seccomp (per la sicurezza)
Il Mount Namespace
Il mount namespace viene usato per isolare i mount point del filesystem visti dal processo container. La funzione mount_set è responsabile dell'impostazione della directory root per il processo container, e lo fa chiamando la system call mount con i flag appropriati (MS_BIND | MS_REC | MS_PRIVATE) per creare un nuovo mount namespace e bind-montare la directory root a quella specificata dall'utente. Il risultato è che il processo container vedrà come root la directory specificata dall'utente e potrà accedere ai file in quella directory e nelle sue sottodirectory.
Il User Namespace
Il user namespace viene usato per isolare gli ID utente e gruppo visti dal processo container. La funzione user_namespace_init è responsabile della configurazione del user namespace, e lo fa chiamando la system call unshare con i flag appropriati (CLONE_NEWUSER) per creare un nuovo user namespace. user_namespace_init si affida a user_namespace_prepare_mappings, una funzione chiamata in barco.c e usata dal processo padre (barco) per attendere che il processo figlio (il container) richieda l'impostazione di uid e gid prima di aggiornare i file uid_map e gid_map per il container. I file uid_map e gid_map sono un meccanismo del kernel Linux per mappare uid e gid tra processo padre e figlio. Il risultato è che il processo container vedrà gli ID utente e gruppo specificati dall'utente e potrà accedere ai file in quella directory e nelle sue sottodirectory.
Capabilities e Filtri Seccomp
Il processo container è in esecuzione come root (uid 0), ma non è un utente root completo. È un utente root con capabilities limitate e filtri seccomp. La funzione sec_set_caps usa libcap per impostare le capabilities del processo container, chiamando la funzione cap_set_proc con i flag appropriati (es. CAP_MAC_OVERRIDE, CAP_MKNOD, CAP_SETFCAP, CAP_SYSLOG...). Questa configurazione fa sì che il processo container possa eseguire solo le azioni specificate dalle capabilities.
I filtri Seccomp vengono usati invece per limitare le system call che un processo (nel mio caso, il container) può effettuare. La funzione sec_set_seccomp blocca le system call sensibili basandosi sul profilo seccomp predefinito di Docker e su altre system call obsolete o pericolose. Lo fa chiamando la funzione seccomp_rule_add con i parametri appropriati per specificare come devono essere gestite le chiamate a una system call. Prendiamo il seguente come esempio:
seccomp_rule_add(ctx, SEC_SCMP_FAIL, SCMP_SYS(fchmod), 1, SCMP_A1(SCMP_CMP_MASKED_EQ, S_ISGID, S_ISGID))Questa istruzione dice al kernel Linux di bloccare le chiamate a fchmod se il bit S_ISGID è impostato nel parametro mode. Il parametro mode è il secondo parametro della system call fchmod e viene usato per impostare i permessi di un file. Il bit S_ISGID viene usato per impostare il bit setgid, che viene usato per impostare il group ID all'esecuzione. Il bit S_ISGID è impostato quando il parametro mode è S_ISGID | S_IRWXU | S_IRWXG | S_IRWXO, che è il valore predefinito per il parametro mode. Ciò significa che la system call fchmod verrà bloccata se il parametro mode ha il valore predefinito, il che avviene quando fchmod viene usata per impostare i permessi di un file. Questo è un buon esempio di come i filtri seccomp possano essere usati per bloccare system call pericolose.
Cgroups
All'avvio del container, il processo padre (barco) sta configurando i cgroup per il processo figlio. La funzione cgroups_init è responsabile della configurazione dei cgroup (versione 2), e lo fa creando la directory /sys/fs/cgroup/barcontainer (barcontainer è l'hostname del container) per il nuovo cgroup e poi scrivendo le impostazioni dei cgroup nei file appropriati nel cgroupfs. Le impostazioni dei cgroup sono:
memory.max: la quantità massima di memoria che il processo container può usarecpu.weight: il peso relativo del processo container rispetto agli altri processipids.max: il numero massimo di processi che il processo container può avviare
Impostando questi valori, il processo container sarà limitato nella quantità di memoria, tempo CPU e processi che può utilizzare.
In Attesa dell'Uscita del Container
Il container è configurato ed è ora in esecuzione. Il processo padre (barco) attende che il processo container esca, e lo fa chiamando la system call waitpid con il PID (process ID) del container. Il container usato come esempio nel file README.md esegue /bin/sh e attende l'input dell'utente. L'utente può digitare comandi, e il processo container li eseguirà. Ad esempio, l'utente può digitare ls e il processo container lo eseguirà, elencando il contenuto della directory corrente. Il processo container continuerà a girare finché l'utente non digita exit, il che causerà la terminazione del processo container. Anche il processo padre (barco) uscirà quindi.
Pulizia delle Risorse
All'uscita del processo container, anche il processo padre (barco) esce ed è responsabile della pulizia delle risorse usate dal processo container. Partendo dalla label cleanup in barco.c, il processo padre è responsabile di:
liberare lo stack del container
chiudere i socket usati per comunicare con il processo container
rimuovere il cgroup creato
liberare le strutture dati usate da
argtable
Riepilogo
I container Linux sono composti da un insieme di funzionalità del kernel Linux, e barco le usa per creare un container isolato dal sistema host. È un'implementazione molto semplice, ma sufficiente per capire come funzionano i container. Ho imparato molto lavorando a questo progetto, e spero che questo articolo possa essere utile a chiunque voglia approfondire la conoscenza dei container Linux e del kernel Linux. Se hai domande, feedback o miglioramenti che vorresti apportare, sentiti libero di contattarmi direttamente o sul repository barco!
lucavallin

