Súborový systém

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 /.

chroot

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.

reexec

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í.

<