Menné priestory

Minule som sa rozpísal o základoch ako je proces. Dnes si vďaka tomu zodpovieme základnú otázku: čo znamená, že aplikácia beží v kontajneri?

Skúsime taký experiment. Spustím si nejaký kontajner, v ktorom bude bežať akože aplikácia. Na simuláciu aplikácie použijem príkaz sleep infinity.

$ docker run --name ubuntu ubuntu:latest sleep infinity

Pozriem sa na bežiace procesy v kontajneri:

$ docker exec -it ubuntu bash
root@fa4b9f3c84f4:/# ps -fax
    PID TTY      STAT   TIME COMMAND
      7 pts/0    Ss     0:00 bash
     15 pts/0    R+     0:00  \_ ps -fax
      1 ?        Ss     0:00 sleep infinity

Zatiaľ tu nie je nič neobvyklé. Moja akože-aplikácia ma PID 1. Pozrime si procesy môjho hosťovského systému. Niekde v tom množstve procesov sa objaví:

$ ps -fax
  10550 ?        Sl     0:00 /usr/bin/containerd-shim-runc-v2 ...
  10570 ?        Ss     0:00  \_ sleep infinity
  10764 pts/0    Ss+    0:00  \_ bash

Sú to tie isté procesy, ktoré som videl v kontajneri. Ako je to možné? Moja aplikácia tu má PID 10570 a nie 1. Aj bash je viditeľný s iným PID. Súborový systém tiež nesedí. Vo vnútri kontajnera vidím úplne iný súborový systém ako je na mojom počítači. Ak by šlo o virtualizáciu, proces by patril virtuálnemu stroju. Nie môjmu počítaču. V predchádzajúcom článku som písal, že procesy zdieľajú zdroje ako je súborový systém a iné. Ako teda vytvoriť proces, ktorý si v systéme žije vo svojom izolovanom svete?

Za touto mágiou sa skrýva koncept menných priestorov - namespaces. Ide o funkcionalitu Linuxového jadra, ktorá tak trošku dokáže oklamať proces. Vieme, že aplikácie vždy pristupujú ku zdrojom cez systémové volania. Linux dokáže uzavrieť proces do svojho priestoru, kedy systémové volania budú vracať akúsi ilúziu iného systému. Napríklad volanie getpid tak vráti 1 namiesto správneho 10570.

namespaces

S mennými priestormi manipulujú systémové volania clone, setns a unshare. Posledné 2 sú dostupné aj ako príkazy terminálu. Menné priestory sú zoradene hierarchicky. Podprocesy teda budú vytvárané v tom istom mennom priestore, v akom je ich hlavný proces.

namespaces

V súčasnosti existuje 8 druhov menných priestorov: UTS, User, PID, Mount, Network, Time, IPC a Cgroup. Už z ich mien je vidieť, aké zdroje izolujú.

Anton - moje behové prostredie

Pre mňa je najlepšie vidieť, ako sa niečo také programuje. Naprogramujem si jednoduché behové prostredie. Budem ho volať Anton. Na ňom si vyskúšam, ako jednotlivé menne priestory fungujú. Základom bude nasledujúci kód:

package main

import(
	"os"
	"os/exec"
)

func main() {
	// zmodifikujem si prompt, aby som vedel rozlíšiť 
	// či som v hosťovskom systéme alebo v behovom protredí
	os.Setenv("PS1", "anton> ")	
	
	// pripravim si podproces a presmerujem 
	// štandardné vstupy a výstupy
	cmd := exec.Command(os.Args[1], os.Args[2:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	
	// sputenie podprocesu
	cmd.Run()
}

Tento jednoduchý program nerobí nič iné, len spustí iný program ako jeho podproces. V predchádzajúcom článku som používal syscall.ForkExec() . Tu používam Go variantu cmd.Command(). Táto funkcia mi umožní jednoducho presmerovať štandardné vstupy a výstupy stdin, stoud, strerr. To je z dôvodu, aby som z podprocesom vedel komunikovať. Skompilujem a spustím si Antona s podprocesom /bin/sh.

$ go build -o anton 
$ ./anton /bin/sh
anton>

Menný priestor - User

Skúsme sa pozrieť, pod akým používateľom môj podproces beží.

anton> whoami
zdenkov

anton> hostname
MY-DESKTOP

Nič prevratné sa neudialo. Anton a jeho podproces zdieľajú spoločné zdroje ako používateľ, sieťové rozhranie, hostname. Antona upravím teda tak, aby podproces mal svoj vlastný používateľský priestor.

package main

import(
	"os"
	"os/exec"
	"syscall"
)

func main() {
	// zmodifikujem si prompt, aby som vedel rozlíšiť 
	// či som v hosťovskom systéme alebo v behovom protredí
	os.Setenv("PS1", "anton> ")	
	
	// pripravim si podproces a presmerujem 
	// štandardné vstupy a výstupy
	cmd := exec.Command(os.Args[1], os.Args[2:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// nový podproces bude mať vlastné menný
	// priestor pre použivateľa
	cmd.SysProcAttr = &syscall.SysProcAttr {
		Cloneflags: syscall.CLONE_NEWUSER,
	}
	
	// sputenie podprocesu
	cmd.Run()
}

Go mi umožňuje ovplyvniť systémové volanie clone pomocou štruktúry cmd.SysProcAttr .Parametrom Cloneflags zadefinujem príznaky, ktoré hovoria aké nové menné priestory chcem vytvoriť pre podproces. V tomto prípade použijem syscall.CLONE_NEWUSER, ktorý povie jadru Linuxu aby proces používal vlastný používateľský priestor. Malé upozornenie: tento parameter je dostupný len pre Linux platformu a pre Windows neexistuje. Skompilujem Antona a opäť spustím bash ako podproces.

$ go build -o anton 
$ ./anton /bin/sh

Pod akým používateľom teraz beží môj podproces?

anton> whoami
nobody
anton> id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

Používateľ nobody je trošku divočina, ale nie je to môj zdenkov. Skúsim si v bash podprocese vytvoriť nejaký súbor. Súbor je vytvorený s nobody vlastníkom.

anton> touch helloworld
anton> ls -l
-rw-rw-r-- 1 nobody nogroup      0 helloworld
-rw-rw-r-- 1 nobody nogroup    605 main.go

V rámci môjho behového prostredia to vyzerá v poriadku. Proces však zdieľa môj súborový systém. Aké prístupové práva bude mať tento súbor v hosťovskom systéme? Aké práva Linux skutočne nastavil?

$ ls -l
-rw-rw-r-- 1 zdenkov zdenkov      0 helloworld
-rw-rw-r-- 1 zdenkov zdenkov    605 main.go

Linux na pozadí premapoval nobody na moje UID. V skutočnosti teda podproces beží stále pod mojím používateľom zdenkov.

Ako ale premapovať nobody na niečo zmysluplné? Napríklad root ? V Linuxe je všetko súbor. Pre mapovanie existuje špeciálny súbor /proc/<pid>/uid_map. Stačí poznať PID podprocesu kontajnera a môžeme si mapovanie prezerať a meniť. V Go nepotrebujeme zasahovať priamo do súboru. Je k tomu už pripravená štruktúra syscall.SysProcIDMap. Upravím teda Antona tak, že pridám mapovanie používateľa a skupiny:

package main

import (
   "os"
   "os/exec"
   "syscall"
)

func main() {
   // zmodifikujem si prompt, aby som vedel rozlíšiť
   // či som v hosťovskom systéme alebo v behovom protredí
   os.Setenv("PS1", "anton> ")

   // pripravim si podproces a presmerujem
   // štandardné vstupy a výstupy
   cmd := exec.Command(os.Args[1], os.Args[2:]...)
   cmd.Stdin = os.Stdin
   cmd.Stdout = os.Stdout
   cmd.Stderr = os.Stderr

   // nový podproces bude mať vlastné menný
   // priestor pre použivateľa
   cmd.SysProcAttr = &syscall.SysProcAttr{
      Cloneflags: syscall.CLONE_NEWUSER,

      //mapovanie použivateľa
      UidMappings: []syscall.SysProcIDMap{
         {
            ContainerID: 0,
            HostID:      os.Getuid(),
            Size:        1,
         },
      },

      // mapovanie skupiny
      GidMappings: []syscall.SysProcIDMap{
         {
            ContainerID: 0,
            HostID:      os.Getuid(),
            Size:        1,
         },
      },
   }

   // sputenie podprocesu
   cmd.Run()
}

Parameter ContainerID určuje UID vo vnútri menného priestoru a HostID určuje skutočné UID. V tomto prípade sa teda moje UID namapuje na UID 0, čo je v Linuxe root. To isté platí aj o skupinách. Skompilujem Antona, spustím opäť bash a poďme sa pozrieť, pod akým užívateľom pobeží môj podproces:

$ go build -o anton 
$ ./anton /bin/sh

anton> whoami
root

anton> id
uid=0(root) gid=0(root) groups=65534(nogroup)

Už je to lepšie. Zbavil som sa nogroup a mám root-a. Je to však skutočný root? Vytvorím si súbor, ukončím Antona a pozriem sa, aké práva budú súboru pridelené:

anton> touch helloworld
anton> ls -l
-rw-rw-r-- 1 root root      0 helloworld
-rw-rw-r-- 1 root root    931 main.go

anton> exit

$ ls -l
-rw-rw-r-- 1 zdenkov zdenkov      0 helloworld
-rw-rw-r-- 1 zdenkov zdenkov    931 main.go

Mapovanie teda funguje ako som očakával. Používateľ root je skôr akýsi pseudo-root. Akýkoľvek neprivilegovaný používateľ sa takto vie stať rootom. Toto je veľmi zaujímavý moment. Tento princíp umožňuje beh tzv. “rootless” kontajnerov.

user_namespace

Docker štandardne tento menný priestor nepoužíva. Namiesto toho beží priamo ako root používateľ. To má svoje výhody. Kontajnery tak bežia hladko bez akýchkoľvek reštrikcii. Sú tu ale veľké bezpečnostné riziká. Ak útočník unikne z kontajneru do hosťovského systému, nielenže získava root práva v hosťovskom systéme, ale aj vo všetkých bežiacich kontajneroch. Tomu sa v Dockeri dá zabrániť použitím userns-remap funkcionality.

S používateľským menným priestorom som nezačal náhodou. S mennými priestormi totiž môže manipulovať len root. Jedinou výnimkou je práve používateľský menný priestor. Ten mi umožnil získať potrebné práva, aby som mohol používať ďalšie menné priestory.

Menný priestor - PID

Na plnohodnotné behové prostredie mi používateľský menný priestor nestačí. Skúsim si teraz izolovať procesy. Podprocesu pridám nový PID menný priestor. K tomu súži príznak syscall.CLONE_NEWPID:

   // nový podproces bude mať vlastné menný
   // priestor pre použivateľa a PID
   cmd.SysProcAttr = &syscall.SysProcAttr{
      Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWPID,

      //mapovanie použivateľa
      UidMappings: []syscall.SysProcIDMap{

Antona opäť skompilujem, spustím si bash a pozriem sa na aktuálne PID:

anton> echo $$
1

Môj proces má teraz hodnotu 1. Pomaličky som sa teda dostal k tomu, čo som demonštroval na začiatku. Môj podproces má svoj izolovaný pohľad na procesy. Je to presne to, čo sme si demonštrovali na začiatku so sleep infinity. Tu sa dnes ale zastavíme.

Záver

Už asi začíname chápať rozdielom medzi virtualizáciou a kontajnermi. Kontajner stále beží na tom istom systéme a využíva to isté jadro. Najlepšie to vystihla Liz Rice. Tá poukázala na trochu mylnú terminológiu. Nemali by sme to volať kontajner, ale presnejšie by bolo označenie kontajnerovaný proces. Kontajner je nakoniec Linux proces, ktorý beží na hosťovskom systéme ale s limitovaným pohľadom na zdroje.

Oproti virtualizácii nedochádza žiadnemu k bootovaniu ďalšieho jadra, ani k žiadnej emulácii hardware, k žiadnemu alokovaniu pamäti pre rôzne podporné procesy ako systemd. To je aj dôvod, prečo je štart kontajnerov rýchlejší a kontajnery sú ľahšie oproti virtualizácii.

Prečo som v poslednom príklade nepoužil ps ale echo $$?. Je to zámer. Napriek tomu, že proces má svoj vlastný menný priestor, a PID je 1, ps stále vráti všetky procesy s ich pôvodnými ID. Prečo? Naznačím dôvod - všetko je súbor. Tí, čo sa hrabu v Linuxe dlhšie, už asi tušia :)

Ale o tom si povieme až v ďalšom článku.

<