Systémové volania a procesy

Jackie Stewart, trojnásobný šampión pretekov F1, raz povedal: Nemusíte byť konštruktér, aby ste boli pretekár, ale musíte mať sympatie k mechanike.

Jackie presne poznal svoje auto, poznal detaily a vedel ich využívať k optimálnej jazde. To mu dávalo konkurenčnú výhodu. Ak chceme skutočne rozumieť tomu, čo kontajner je, rozumieť, ako sa chová, ako ho najoptimálnejšie používať, potrebujeme poznať určité detaily zo sveta Unixu/Linuxu. Jackieho výrok môžeme, teda pretransformovať do sveta kontajnerov ako: Nemusíte byť linux kernel hacker, aby ste mohli používať kontajnery, no je dobre mať sympatie k jadru Linuxu.

Pri hrabaní sa v behových prostrediach som si uvedomil, že je potrebné rozumieť trom základným konceptom zo sveta Unixu: čo sú systémové volania, čo sú procesy, že ku všetkému vieme pristúpiť ako k súboru. O poslednom koncepte sa nebudem veľmi rozpisovať, keďže so súbormi vieme asi všetci pracovať.

Systémové volania

Systémové volania tvoria základné rozhranie, cez ktoré pristupujeme k funkciám operačného systému. Každá aplikácia, či je napísaná v C alebo v Node.JS, v konečnom dôsledku používa práve systémové volania. Určite ich poznáte. Napríklad open(), read(). Žiadna aplikácia nepristupuje k disku, ani k žiadnej inej časti priamo, ale stále skrz systémové volania a jadro operačného systému.

syscalls

Prvá verzia Unixu poskytovala 30 volaní a polovica sa týkala súborov. Dnešné jadro Linuxu disponuje viac ako 300 volaniami. Presný počet závisí od verzie jadra a distribúcie. Výborným zdrojom informácii ku konkrétnym volaniam je stránka man7.org, vytvorená Michaelom Kerriskom.

Systémové volania je možné vidieť pre akýkoľvek proces. Ich sledovaním sa môžeme dozvedieť viac o tom, ako fungujú naše aplikácie. Zoberme si veľmi jednoduchý Node.JS skript readfile.js:

fs = require(‘fs’)

fs.readFile(`readme`,`utf8`, function(err, data) {
	console.log(data)
}) 

Skript spustíme príkazom:

$ node readfile.js
Hello World

Nič prevratné. Tento skript načíta súbor readme a vypíše jeho text na výstup. Ak chceme vedieť, čo skutočne robí Node.JS, môžeme sledovať systémové volania pomocou strace:

$ strace node readfile.js
execve("/home/sn3d/.nodenv/shims/node", ["node", "readfile.js"], 0x7ffe473734b8 /* 39 vars */) = 0
...

Na výstupe máme množstvo volaní. Napríklad v mojom prípade, keďže používam nodenv, sa volá execve(), ktorý spúšťa vnorený proces /home/sn3d/.nodenv/shims/node.

Aby som nebol zahltený veľkým množstvom informácii skúsim si vytriediť len volania, ktoré otvárajú akýkoľvek súbor. Čiže sa budem pozerať po volaniach ako open(), openat(). Keďže Node.JS spúšťa niekoľko procesov, použijem aj prepínač -f, vďaka ktorému strace bude trasovať všetky podprocesy hlavného procesu.

$ strace -f -e open,openat node readfile.js
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3
...
[pid  7701] openat(AT_FDCWD, "/home/sn3d/example/readfile.js", O_RDONLY|O_CLOEXEC) = 17
...
[pid  7735] openat(AT_FDCWD, "readme", O_RDONLY|O_CLOEXEC) = 17
[pid  7701] openat(AT_FDCWD, "/dev/pts/0", O_RDWR|O_NOCTTY|O_CLOEXEC) = 17
...

Ako môžeme vidieť, Node.JS pristupuje k množstvu súborov. Niekde na konci budeme vidieť, ako sa otvára náš skript, potom readme súbor a nakoniec súbor terminálu, kde sa zapíše výstup.

Poďme si povedať aj niečo o druhom dôležitom koncepte - Procesy.

Procesy

Druhý dôležitý koncept sú procesy. Proces je v skratke bežiaca inštancia programu. Procesy sú v systéme identifikované unikátnym ID. Ide o celé číslo a v systéme nemôžeme mať procesy s rovnakým ID.

Kým vlákna, ktoré poznáme z programovania, zdieľajú spoločnú pamäť, tak pri procesoch sa Linuxové jadro stará o to, aby každý proces mal svoj vlastný pamäťový priestor, vlastné súborové deskriptory atď. Toto je základná izolácia, ktorá tu je už od nepamäti. Procesy však stále zdieľajú rôzne zdroje, ako je súborový systém, rôzne zariadenia a sieťové rozhranie.

Proces nevzniká len tak. Je vytvorený iným procesom. Procesy sú organizované v stromovej štruktúre, kde podproces preberá od rodičovského procesu určité parametre, ako je užívateľ, premenné prostredia atď. Ak dôjde k ukončeniu rodičovského procesu, operačný systém ukončí aj všetky jeho podprocesy.

Poďme sa pozrieť na procesy a programy z pohľadu systémových volaní. Keď sa spúšťa nová inštancia programu, volá sa systémové volanie execve(). Toto volanie však nevytvára nový proces. Volanie premaže pôvodný program a nahradí ho novým.

syscalls

Zoberme si krátky program v Go:

package main

import "fmt"
import "syscall"

func main() {
   syscall.Exec("/usr/bin/ping", []string{"ping", "-c", "10", "localhost"}, []string{})
   fmt.Println("The end!")
}

Tento program spustí 10x ping a na konci vypíše krátky text. Keď program skompilujeme a spustíme, text The end! neuvidíme. Nič, čo nasleduje za volaním syscall.Exec sa už nevykoná:

$ go build -o app
$ ./app 
PING localhost (127.0.0.1) 56(84) bytes of data.
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.080 ms
64 bytes from localhost (127.0.0.1): icmp_seq=2 ttl=64 time=0.098 ms
64 bytes from localhost (127.0.0.1): icmp_seq=3 ttl=64 time=0.098 ms
64 bytes from localhost (127.0.0.1): icmp_seq=4 ttl=64 time=0.099 ms
64 bytes from localhost (127.0.0.1): icmp_seq=5 ttl=64 time=0.099 ms
64 bytes from localhost (127.0.0.1): icmp_seq=6 ttl=64 time=0.099 ms
64 bytes from localhost (127.0.0.1): icmp_seq=7 ttl=64 time=0.100 ms
64 bytes from localhost (127.0.0.1): icmp_seq=8 ttl=64 time=0.100 ms
64 bytes from localhost (127.0.0.1): icmp_seq=9 ttl=64 time=0.099 ms
64 bytes from localhost (127.0.0.1): icmp_seq=10 ttl=64 time=0.100 ms

--- localhost ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9198ms
rtt min/avg/max/mdev = 0.080/0.097/0.100/0.005 ms

To je spôsobené práve tým, ako systémové volanie execve() funguje. Pôvodný app program totiž bude v pamäti premazaný a nahradený ping programom v tom istom procese. Keď ping ukončí svoju prácu, tak Linux už nevráti späť riadenie našej app, lebo tá už v tom čase v pamäti neexistuje. Poďme si program spustiť ešte raz a v druhej konzole sa pozrime na procesy cez ps -fax :

$ ps -fax
 ...
 5678 pts/1    Ss     0:01          \_ -zsh
 6640 pts/1    S+     0:00              \_ ping -c 10 localhost
 ...

Človek by očakával, že proces 6640 by mal patriť app, ale namiesto toho je tam ping. To je dôsledok toho, že došlo k nahradeniu pôvodného programu app. To si môžeme ešte overiť cez strace

$ strace -f -e clone,execve ./app
execve("./app", ["./app"], 0x7ffe11850b88 /* 39 vars */) = 0
...
[pid  7781] clone(child_stack=0xc000050000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM strace: Process 7783 attached
...
[pid  7781] execve("/usr/bin/ping", ["ping", "-c", "10", "localhost"], 0xc00000e028 /* 0 vars */ <unfinished ...>
...
PING localhost (127.0.0.1) 56(84) bytes of data.
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.101 ms

Trasovanie nám prezradí, že Go vytvorí niekoľko vlákien cez clone() s príznakom CLONE_THREAD. Potom je vidieť, ako sa volá execve() s PID 7781, čo je vlastne pôvodný proces. No a potom už preberá kontrolu program ping.

Aby sme tomu zamedzili, je potrebné vytvoriť podproces pomocou systémového volania fork/clone, a až v podprocese zavolať execve. Toto sa zvykne volať aj spawn. V Go je volanie clone() spojené s execve() vo funkcii syscall.ForkExec().

syscalls

Upravme program nasledovne:

package main

import "fmt"
import "syscall"
import "time"

func main() {

   pid, _ := syscall.ForkExec("/usr/bin/ping", []string{"ping", "-c", "10", "localhost"}, nil)

   // waiting for child process
   for {
      var s syscall.WaitStatus
      wpid, err := syscall.Wait4(pid, &s, syscall.WNOHANG, nil)
      if err != nil || wpid != 0 {
         break
      }
      time.Sleep(500 * time.Millisecond)
   }

   // child process is dead, we can continue
   fmt.Println("The end!")
}

Teraz program skompilujeme a spustíme:

$ go build -o app
$ ./app 
The end!

Na výstupe neuvidíme klasicky ping výstup. Program totiž vytvoril podproces. Ten má svoje vlastné stdin ,stdout a stderr. To je súčasť izolácie. Náš program však pokračuje ďalej, čaká 10 sekúnd a potom sa zobrazí krátky text. Keď si v druhej konzole opäť pozrieme procesy, uvidíme, že ping je podprocesom našej aplikácie app:

$ ps -fax
...
2332 pts/0    Ss     0:04  |       \_ -zsh
7135 pts/0    Sl+    0:00  |           \_ ./app
7140 pts/0    S+     0:00  |               \_ ping -c 10 localhost
...

A do tretice, čo ukáže strace?

$ strace -f -e clone,execve ./app
...
[pid  7925] clone(child_stack=NULL, flags=CLONE_VM|CLONE_VFORK|SIGCHLD strace: Process 7930 attached
...
[pid  7930] execve("/usr/bin/ping", ["ping", "-c", "10", "localhost"],
...

Tu nastáva zmena. Pribudlo systémové volanie clone() s príznakom CLONE_VFORK. Operačný systém vytvára nový podproces s ID 7930 a v vzápätí sa volá execve(), ale už v novom podprocese. Pôvodný proces tak nieje premazaný a čaká, kedy dôjde k ukončeniu podprocesu. Potom kód pokračuje vypisaním textu The end!.

Záver

Hoci to znie nudne a na prvý pohľad to s kontajnermi má len pramálo spoločné, opak je pravdou. Je dôležité poznať koncept systémových volaní, aby sme rozumeli bezpečnostným rizikám, s ktorými sa pri kontajneroch môžeme stretnúť. Ešte dôležitejšie je vedieť, čo proces je. Vďaka tomu lepšie pochopíme, čo presne kontajner je. Hlavne, pochopíme, aký je zásadný rozdiel medzi kontajnermi a virtuálnou mašinou. Ale o tom všetkom až v ďalšom článku.

<