Je čas izolovať súborový systém. Vlastný súborový systém je základom prenositeľnosti kontajnerov. Inač povedané, vďaka tomu beží na produkcii presne to, čo na vývojárskom stroji.
V minulom článku som si začal písať experimentálne behové prostredie Anton. Hoci som použil menný priestor pre procesy, príkaz ps
mi stále vracia zoznam všetkých procesov. Finta je v tom, že ps
číta informácie o procesoch z adresára /proc
. Behové prostredie zatiaľ zdieľa súborový systém, kďe /proc
obsahuje informácie o všetkých procesooch. Aby som Antona posunul o krok bližšie ku kontajnerom, potrebujem izolovať súborový systém a vytvoriť /proc
pre kontajnerizovaný proces.
Zmena koreňového (root) adresára
Vytvorenie osobitného súborového systému pre ostrieľaného Linuxáka nie je žiadna veda. Povie si: veď chroot
. A nie je ďaleko od pravdy. Systémové volanie chroot
umožní spustiť proces s vlastným root adresárom. Môj adresár ./rootfs
sa bude tváriť ako /
.
Ak by som však použil prázdny adresár, asi by všetky programy prestali fungovať. Do ./rootfs
preto musím nakopírovať nejakú distribúciu Linuxu. Použijem Alpine Linux, nech to má šmrnc kontajnérov.
$ mkdir rootfs
$ wget https://dl-cdn.alpinelinux.org/alpine/v3.13/releases/x86_64/alpine-minirootfs-3.13.2-x86_64.tar.gz
$ tar xvf alpine-minirootfs-3.13.2-x86_64.tar.gz -C ./rootfs
Do adresára ./rootfs
, ktorý bude môj nový koreňový adresár, som nakopíroval Alpine. Teraz stačí už len spustiť chroot
.
$ sudo chroot ./rootfs /bin/sh
Spustí sa nový shell
proces, s úplne iným súborovým systémom. Napríklad v /home
neuvidím adresár môjho používateľa, alebo sa nespustí go
, ktorý mam nainštalovaný v hosťovskom systéme.
/ # ls /home
/ # go
/bin/sh: go: not found
Môj koreňový adresár je teraz Alpine. Celkom jednoduché. Však?
Od chroot k pivot_root
Nebol by to svet počítačov, ak by to bolo také jednoduché. Už v predchádzajúcom článku som načrtol, že bezpečnosť kontajnerov je komplikovaná. Jednou z dôležitých úloh behového prostredia je zabrániť, aby škodlivý kód nedokázal uniknúť do hosťovského systému. Pre chroot
existuje množstvo spôsobov ako zo súborového systému uniknúť.
Prečo teda neopraviť chroot
aby bol bezpečnejší? To, čo z bezpečnostného hľadiska vyzerá ako chyba, je vlastne dizajnový zámer. Oprava by viedla k poškodeniu množstva iných nástrojov. Tak teda pribudlo nové, bezpečnejšie volanie - pivot_root
. Principiálne ide o to isté, len to má svoje špecifiká.
Začnem teda odznova s Antonom. Zoberiem kód, ktorý som začal programovať v minulom článku. Pridám nový menný priestor syscall.CLONE_NEWNS
. Tento menný priestor izoluje mount
veci. Budem ho potrebovať, aby som si mohol pripojiť nový /proc
. Bez tohto menného priestoru mi Anton nedovolí ani len spustiť mount
.
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é priestory
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
//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()
}
Kód skompilujem a spustím. Všetko ostatné budem skúšať už cez podproces vo vlastnom mennom priestore.
$ go build -o anton main.go
$ ./anton /bin/sh
anton>
Jedno zo špecifík pivot_root
je, že adresár musí byť tzv. prípojný bod. Ako teda zabezpečim taký adresár? Použijem trik s bind mount.
Čo je bind mount? Bind mount umožňuje pripojiť adresár, napr. /src
ako úplne iný adresár /dest
. Je to niečo podobné, ako vytvoriť symbolickú linku. V tomto prípade sa /dest
stáva zároveň aj prípojný bod. Pamätníci Dockeru bind mount určite poznajú. Je akýmsi predchodcom dnešných Volumes
. Ale vráťme sa k pivotovaniu. Trik je ten, že spravím bind mount sám na seba.
anton> mount --bind rootfs rootfs
Ďalšie špecifikum pivot_root
je mať nejaký adresár, kde sa zase namontuje pôvodný súborový systém. Ja som si zvolil tmp/old
.
anton> cd ./rootfs
anton> mkdir -p tmp/old
Pred samotným pivotovaním ešte pripojím nový proc
do rootfs
. Chcem predsa vyriešiť problém s ps
.
anton> mount -t proc proc proc
Keďže Anton spúšťa podproces s novým menným priestorom syscall.CLONE_NEWPID
, tak tento špeciálny adresár už nebude obsahovať procesy hosťovského systému.
Môžem začať pivotovať aktuálny rootfs
na nový koreňový adresár.
anton> pivot_root . ./tmp/old
anton> cd /
Teraz vidím už len izolovaný pohľad na procesy.
anton> ps -fa
PID USER TIME COMMAND
1 root 0:00 /bin/sh
7 root 0:00 ps -ef
Chcelo to trošku zložitejšiu prípravu ako chroot
, ale súborový systém je v takom stave, do akého by ho priviedol aj Docker. Ostáva to už len doprogramovať ako inicializáciu behového prostredia.
Reexec - maličký, no potrebný hack
V momente, keď som si myslel, že postačí malá zmena Antona, tak som narazil na problém. Kde umiestniť pivoting? Ak by som inicializačný kód vložil pred volanie cmd.Run()
, kód by bežal oproti pôvodným systémovým menným priestorom. Za volanie cmd.Run()
to tiež nemá zmysel, lebo vtedy dochádza k ukončeniu behového prostredia.
V článku o systémových volaniach a procesoch som písal o tom, ako procesy vznikajú a o volaniach fork()
, clone()
a execve()
. Ideálne miesto na inicializáciu je okamžite po clone()
, ale pred execve()
. Je to miesto, keď už mám nové menné priestory, ale ešte nebeží kód novej aplikácie. No a tu je problém. Go tieto volania spája do jedného ForkExec()
. To isté platí aj pre cmd.Run()
.
Keďže Go nedokáže spraviť čistý fork, musel som upraviť kód a implementovať reexec
. Ide o hack, ako sa dostať do miesta medzi clone()
a execve()
. Finta je v tom, že Anton sa spustí najprv v run
móde. Ten neurobí nič iné, len vytvorí menné priestory a znovu spusti podproces Antona cez /proc/self/exec
v móde reexec
. Ten už pobeží vo vlastných menných priestoroch. Tu už môžem robiť pivoting a inicializáciu.
Upravený kód vyzerá nasledovne:
package main
import (
"os"
"os/exec"
"syscall"
)
func main() {
if len(os.Args) < 2 {
panic("run?")
}
// ide o spustenie v run alebo reexec mode?
switch os.Args[1] {
case "run":
run()
case "reexec":
reexec()
default:
panic("run?")
}
}
func run() {
// pripravim si re-exec, Volám /proc/self/exec, ktorý
// ukazuje na samého seba.
cmd := exec.Command("/proc/self/exe", append([]string{"reexec"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// nastavim menne priestory pre re-exec
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
//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,
},
},
}
// zavolame reexec
cmd.Run()
}
func reexec() {
// tu je miesto na potrebnu inicializaciu
// zmodifikujem si prompt, aby som vedel rozlisit
// ci som v hotovskom systeme, alebo v behovom protredi
os.Setenv("PS1", "anton> ")
// pri dalsiom podprocese sa uz nieje potrebne
// zapodievat mennymi priestormi
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}
Začiatok funkcie reexec()
je miesto, kam poputuje všetok inicializačný kód. Ale najprv si Antona skompilujem a vyskúšam, či správne funguje. Jednou zmenou je, že ho už budem spúšťať s prídavkom run
:
$ go build -o anton main.go
$ ./anton run /bin/sh
Anton má svoj súborový systém
Teraz môžem začať písať changeFs()
funkciu, ktorá urobí všetko potrebné okolo pivotingu. Kód len kopíruje to, čo som robil na začiatku tohto článku.
func changeFs(rootfs string) error {
// pripravime novy proc v rootfs
target := filepath.Join(rootfs, "proc")
err := os.MkdirAll(target, 0755)
if err != nil {
return err
}
err = syscall.Mount("proc", target, "proc", uintptr(0), "")
if err != nil {
return err
}
// tento 'hack' je potrebny, lebo pivot_root nevie
// prehodit obycajny adresar
err := syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND|syscall.MS_REC, "")
if err != nil {
return err
}
// vytvorime docastny adresar, kde bude umiestneny stary
// suborovy system
old := filepath.Join(rootfs, "tmp", "old")
err = os.MkdirAll(old, 0700)
if err != nil {
return err
}
// prebehne samotne pivotovanie
err = syscall.PivotRoot(rootfs, old)
if err != nil {
return err
}
// vyskocime do noveho systemu
err = os.Chdir("/")
if err != nil {
return err
}
// odmontujeme stary suborovy system
err = syscall.Unmount("/tmp/old", syscall.MNT_DETACH)
if err != nil {
return err
}
// zmazemedocasny adresar
err = os.RemoveAll("/tmp/old")
if err != nil {
return err
}
return nil
}
A funkciu zavoláme pri inicializácii v reexec()
.
func reexec() {
// tu je miesto na potrebnu inicializaciu
err := changeFs("rootfs");
if err != nil {
panic("what an error!")
}
// zmodifikujem si prompt, aby som vedel rozlíšiť
// či som v hosťovskom systéme alebo v behovom protredí
os.Setenv("PS1", "anton> ")
// pri ďalsiom podprocese sa už nieje potrebné
// zapodievať mennými priestormi
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}
Teraz keď skompilujem a spustím Antona, mal by som mať izolovaný súborový systém, ktorý bude reflektovať Alpine distribúciu. Tiež ps
už vráti správny zoznam procesov v rámci môjho kontajnerizovaného procesu.
$ ./anton run /bin/sh
anton> ps -ef
PID USER TIME COMMAND
1 root 0:00 /proc/self/exe reexec /bin/sh
6 root 0:00 /bin/sh
8 root 0:00 ps -ef
Dokonca aj mount
bude pekne izolovaný a nezobrazí všetky body pripojenia hosťovského systému.
anton> mount
/dev/sda2 on / type ext4 (rw,relatime,errors=remount-ro)
proc on /proc type proc (rw,relatime)
Teraz sa už Anton začína skutočne podobať na Docker.
Záver
Práca so súborovým systémom je po menných priestoroch ďalší dôležitý koncept. Tieto znalosti vieme rovno použiť aj priamo pri práci s Dockerom. Z tohto všetkého vyplýva, že každý kontajner by sa mal nachádzať niekde na disku hosťovského systému. Docker disponuje príkazom inspect
, ktorý prezrádza všetky detaily o kontajneroch v systéme. Stačí, keď sa zameriame na parameter GraphDriver.Data
a jeho hodnoty. To nám prezradí, kde sa súborový systém skutočne nachádza.
$ docker inspect wizardly_herts | jq '.[0].GraphDriver.Data.MergedDir'
"/var/lib/docker/overlay2/0c091317c7c127a43d91db8b35a9e74c35848c487371f2888e70b7ee8499da8b/merged"
$ ls /var/lib/docker/overlay2/0c091317c7c127a43d91db8b35a9e74c35848c487371f2888e70b7ee8499da8b/merged
bin boot dev docker-entrypoint-initdb.d docker-entrypoint.sh ...
To, prečo je tu viacero adresárov je výsledkom prekrývania - overlay. O tom si však napíšeme niekedy inokedy.
Hoci Anton sa už podobá na behové prostredie, niečo tomu ešte chýba - sieť. Nabudúce sa preto zameriam na ďalší dôležitý menný priestor, ktorý sa týka sieťových rozhraní.