Czym naprawdę są kontenery

Prawdopodobnie każdy zainteresowany jakkolwiek tematami IT słyszał już o czymś takim jak kontenery i od razu wie, że chodzi o takie rzeczy jak docker albo kubernetes. Wiadomo, że są obrazy w repozytoriach i można je łatwo pobierać i instalować.

Kontenery przedstawiane są jako lekkie maszyny wirtualne. Taka wirtualka, która nie marnuje RAM-u na utrzymywanie całego systemu operacyjnego gościa – kilka takich “wirtualek” (kontenerów) współdzieli system operacyjny, ale zapewnia separację dla uruchamianych wewnątrz procesów.

Oprogramowanie takie jak docker (zwłaszcza z jego modułem swarm) oraz kubernetes, to całkiem spore kombajny robiące mnóstwo rzeczy, których zakres funkcjonalności przekracza znacznie sam temat uruchamiania kontenera. Do tego obrosły jeszcze w cała gamę narzędzi, które wspierają administratora (lub developera) w procesach wdrażania, monitorowania i zarządzania usługami. Powstały również rozwiązania dla systemów innych niż Linux, np. Docker Desktop dla Windows, czy też jego odpowiednik dla urządzeń Apple'a.

Internet pełen jest artykułów, poradników, prezentacji etc. opisujących zalety konteneryzacji i ogólnie są to bardzo przydatne materiały, ale przy okazji każdej technologii dobrze jest (czasem) wiedzieć czym ona naprawdę jest i jak działa. Taka wiedza przydaje się zwłaszcza, gdy coś przestanie działać i żaden “wizard” i “kreator” nie wiedzieć czemu akurat nie potrafi pomóc.

Czym zatem jest naprawdę kontener i co dzieje się pod tymi wszystkimi warstwami skądinąd bardzo przydatnych narzędzi?

Jak nie trudno się domyślić, mam zamiar odpowiedzieć na to pytanie, ale żeby nie zanudzić ewentualnego czytelnika, zminimalizuję teorię i postaram się przedstawić praktyczny przykład utworzenia kontenera korzystając wyłącznie z dwóch narzędzi dostępnych praktycznie w każdym systemie Linux.

Tymi narzędziami są:

Zatem wracam do pytania...

Co to jest kontener?

Na poziomie systemu operacyjnego, kontener to nic innego jak jeden lub kilka procesów, które zostały uruchomione taki sposób, żeby nie współdzielić globalnych przestrzeni nazw z innymi procesami.

O co chodzi z tymi przestrzeniami nazw?

Na to pytanie odpowiada dokumentacja linuksa, man 7 namespaces:

A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource. Changes to the global resource are visible to other processes that are members of the namespace, but are invisible to other processes. One use of namespaces is to implement containers.

Zatem system operacyjny ma ileś typów zasobów i te zasoby dostępne są dla procesu w ramach przestrzeni nazw. Jeśli utworzymy dla procesu dedykowaną przestrzeń nazw, to nie będzie on widział identyfikatorów zasobów z przestrzeni globalnej i nie będzie mógł jej zmieniać. Dla określonego typu zasobów, proces ma swój odizolowany świat i nie widzi niczego poza nim.

Te typy zasobów, to:

Czym zatem jest kontener? To proces, któremu wydzielimy namespace'y.

Weźmy na przykład przestrzenie PID, Mount i Network. Taki proces w wyizolowanych przestrzeniach dostanie PID 1 (jeśli jest pierwszym procesem w tej przestrzeni) – zupełnie jak proces init w przestrzeni globalnej. Jeśli ma oddzielną przestrzeń Mount, to znaczy, że może mieć zupełnie własną strukturę plików, poczynając od katalogu głównego. W przestrzeni Network będzie widział inne interfejsy sieciowe, z innymi adresami i może na nich otwierać porty, które nie będą kolidowały z otwartymi portami w innych przestrzeniach (czyli robię dwie przestrzenie i w obu uruchamiam nginx'a na porcie 80 i oba procesy uruchamiają się i działają jednocześnie).

Dokładając kolejne przestrzenie rzeczywiście dochodzimy do poziomu, gdzie świat procesu jest prawie tak autonomiczny jak w przypadku maszyn wirtualnych (o ile oczywiście w implementacji Linuksa nie ma błędów i proces nie ma możliwości odkłamania tej rzeczywistości).

W zasadzie mechanizm działania kontenerów powinien być już jasny. Taki docker czy kubernetes (a dokładniej containerd, z którego korzystają, a jeszcze dokładniej program runc, który jest uruchamiany podczas startu każdego kontenera) właśnie zajmuje się na samym dole wydzieleniem namespaceów dla nowego kontenera i uruchomieniem w tej dedykowanej przestrzeni głównego procesu naszego kontenera.

Ale żeby to powtórzyć nie jest potrzebny ani docker, ani containerd, ani nawet wspomniany runc (kiedyś napiszę o tych narzędziach).

Za cały proces wydzielania przestrzeni nazw odpowiada praktycznie jedna funkcja systemowa Linuksa: unshare. A wspomniane na wstępie narzędzie o tej nazwie jest wygodną nakładką, dzięki której można z niej skorzystać z poziomu shella i nie trzeba od razu programować w C.

Coś o tworzeniu procesów w Linuksie

Jak można przeczytać w każdej mądrej książce o Uniksie, w tym systemie każdy nowy proces powstaje poprzez uruchomienie funkcji systemowej fork (wyjątkiem jest pierwszy proces – ten z pid'em 1; jego jakoś magicznie tworzy kernel).

Funkcja fork tworzy koncepcyjnie idealną kopię procesu, który ją wywołał – współdzielą wszystko – otwarte pliki, pamięć, kontekst użytkownika (realny i efektywny UID etc).

Jedyna różnica, która się między nimi pojawia (o ile o czymś nie zapomniałem) to PID i PPID (parent pid). W momencie powrotu z tej funkcji, do procesu rodzica zwracany jest pid nowoutworzonego procesu potomka, a w procesie potomka zwracane jest zero. Dzięki temu, w kodzie wiemy, w którym procesie jesteśmy (bo wszystko – np. wartości zmiennych – jest takie samo). Oczywiście PID procesu rodzica jest niezmieniony, a potomek dostał swój nowy pid i jego parent pid wskazuje na proces rodzica.

W pewnym momencie w Linuksie taki mechanizm okazał się dobry, ale niewystarczający – Linux zaczął wspierać wątki.

Powstała więc bardziej ogólna funkcja systemowa clone, w której można wyspecyfikować co proces potomny ma współdzielić ze swoim przodkiem, a co ma mieć osobne. Stary fork jest szczególnym przypadkiem wywołania clone (w kernelu oba syscalle obsługuje ten sam kod).

A potem (nie analizowałem dokładnej chronologii) powstała kolejna funkcja systemowa – unshare – która pozwala w większym stopniu odciąć się od procesu nadrzędnego. Już nie tylko w momencie wykonywania forka.

I na wykorzystaniu tej funkcji skupię się w części praktycznej tego artykułu.

Prymitywny kontener

Kontenery znane są przede wszystkim z tego, że mają osobny system plików (wczytywany z obrazu), ale to jest akurat najbardziej czasochłonne w przygotowaniu – trzeba mieć albo statycznie zlinkowany program, albo przygotować całą strukturę /usr/lib z bibliotekami, a na koniec jeszcze zatroszczyć się o zawartość katalogów /etc czy /dev, bez których niewiele rzeczy zadziała. A ogólnie jest to najmniej interesujące, bo po prostu można zamontować gdzieś cała strukturę systemu plików (np. z obrazu ISO) i tu się kończy magia osobnego filesystemu.

Przy okazji – wyizolowanie systemu plików to najstarszy klocek “konteneryzacji”. We wszystkich implementacjach Unix jest polecenie chroot, które właśnie odpowiada za uruchomienie procesu z innym systemem plików.

Skupię się więc na uruchomieniu procesu w jego własnej przestrzeni identyfikatorów procesów (PIDy) a potem dodam do tego wyizolowaną sieć.

Zatem “pierwszy kontener”:

sudo unshare -p -f bash
# echo $$
1
# 

Pierwsza linijka to uruchomienie bash'a w wydzielonej przestrzeni PID, o czym mówi parametr -p. Parametr -f instruuje program unshare, że powinien zrobić fork'a przed uruchomieniem docelowego programu, co z różnych powodów jest bardzo wskazane.

Tu od razu skierowanie do manuala programu unshare. Jak widać z listy parametrów możemy nim wydzielić każdą dostępną w systemie przestrzeń.

Wracając do powyższej sesji z pracy kontenera: polecenie echo $$ (czyli wyświetl mój pid, gdzie “mój” dotyczy uruchomionego shella) pokazuje “1”. Sukces! Shell działa we własnej przestrzeni identyfikatorów procesów.

To sprawdźmy jakie inne procesy są (nie powinno być żadnego):

# ps ax

I tu niespodzianka – ps pokazuje wszystkie procesy w systemie. Co ciekawe pid 1 wcale nie jest naszym bashem, tylko programem /sbin/init. Wyjaśnienie tego jest dość proste, jeśli wiemy skąd program ps bierze informacje o procesach – z katalogu /proc (i wszystkich podkatalogów, których nazwy są numerkami – każdy numerek to pid programu).

Czyli widać procesy, bo ps może je przeczytać z katalogu /proc, co nie znaczy, że nasz proces naprawdę widzi te PIDy. To jest łatwo sprawdzić wybierając sobie dowolny pid i wykonując polecenie kill:

# kill 4451
bash: kill: (4451) - Nie ma takiego procesu
#

Polecenie ps wypisało miedzy innymi jakiś proces o PID 4451, ale próba wysłania sygnału do tego procesu kończy się błędem – w kontenerze takiego procesu nie ma.

Te błędne informacje z polecenia ps jest bardzo łatwo poprawić i unshare ma na to specjalną opcję: --mount-proc=/proc. Dodanie tego parametru spowoduje, że w przestrzeni nazw naszego procesu zostanie zamontowany jego własny katalog /proc, który będzie prawidłowo opisywał jego rzeczywistość.

Zatem wychodzę z mojego kontenera poleceniem exit i uruchamiam jeszcze raz:

$ sudo unshare -p --mount-proc=/proc -f bash
#echo $$
1
# ps ax
  PID TTY      STAT   TIME COMMAND
    1 pts/3    S      0:00 bash
    8 pts/3    R+     0:00 ps ax
# 

I tak oto mam kontener! W moim kontenerze jest tylko proces bash i procesy, które z niego uruchamiam. Innych procesów nie widać.

Uruchomienie polecenia ps poza kontenerem (w innym terminalu) pokaże, że system ma o wiele więcej uruchomionych procesów i pokaże również tego basha, który jest potomkiem programu unshare i wcale nie ma PIDu różnego 1.

Przy okazji tak mimochodem wydzieliliśmy mu również osobną przestrzeń montowania – ma filesystem identyczny z globalnym ale z jedną różnica – jego katalog /proc jest inny niż /proc w systemie.

Sieć w kontenerze

Dodanie sieci od naszego kontenera wymaga kilku dodatkowych poleceń, ale wcale nie będzie dużo bardziej skomplikowane.

Po pierwsze zobaczmy jakie interfejsy sieciowe widać w kontenerze:

# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
....
#

Ogólnie – te same, które są w systemie. Uruchamiam więc jeszcze raz kontener ale z dodatkowym parametrem -n, co spowoduje wydzielenie dla procesu osobnej przestrzeni nazw dla sieci:

# exit
$ sudo unshare -p --mount-proc=/proc -f -n bash
# ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
#

I teraz jest już tylko urządzenie loopback – nie jest nawet podniesione: state DOWN. Sieć nie jest zainicjalizowana.

Do kontenera (czyli namespace procesu) można dodawać interfejsy sieciowe za pomocą polecenie ip:

sudo ip link set dev <device> netns <namespace> 

Zamiast device podajemy faktyczną nazwę urządzenia (np. eth2). Pewnym problemem jest kwestia nazwy namespace: utworzona przestrzeń jest anonimowa i nie ma nazwy (ip netns ls nie pokaże jej). Ale nie jest to dużym problemem. Zamiast nazwy można podać pid (ten “prawdziwy”, z głównej przestrzeni nazw) procesu wewnątrz kontenera. Wystarczy za pomocą htop top czy ps axf odszukać proces bash, który jest potomkiem programu unshare. W moim wypadku jest to 5030.

I jeszcze druga kwestia – jaki interfejs przypisać od kontenera. W zasadzie da się dowolny, ale żeby miało to bardziej praktyczne zastosowanie można wykorzystać specjalny interfejs wirutalny – veth.

Zacznijmy od jego utworzenia. Robimy to “poza kontenerem”.

$ sudo ip link add siec0 type veth peer name siec1

Polecenie ip link pokaże nam, że interfejs został utworzony (a nawet dwa interfejsy!):

169: siec1@siec0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 2a:14:f2:52:01:0d brd ff:ff:ff:ff:ff:ff
170: siec0@siec1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether aa:e1:2b:2a:b3:27 brd ff:ff:ff:ff:ff:ff

Interfejs veth jest specyficzny. Otóż posiada dwa końce (nadałem im nazwy siec0 i siec1). Wysłanie ramki na jeden z końców powoduje, że ramka pojawia się natychmiast na drugim. Jak widać – system widzi oba końce pod nazwami siec0@siec1 i siec1@siec0.

Zaleta tego interfejsu jest taka, że końce mogą być w różnych przestrzeniach nazw. Przypisuję jeden z końców do mojego kontenera (którego przestrzeń nazw jest identyfikowana pidem procesu, który w nim działa):

sudo ip link set dev siec0 netns 5030

Ponowne wywołanie ip link pokaże, że interfejs siec0 (id 170) zniknął, a interfejs siec0 (id 169) nieznacznie zmienił nazwę:

169: siec1@if170: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 2a:14:f2:52:01:0d brd ff:ff:ff:ff:ff:ff link-netnsid 3

Za to uruchomienie ip link w kontenerze pokaże:

# ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
170: siec0@if169: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether aa:e1:2b:2a:b3:27 brd ff:ff:ff:ff:ff:ff link-netnsid 0

Jak widać, interfejs o id 170 został przeniesiony do kontenera (a dokładniej do jego przestrzeni nazw).

Jak wykorzystać ten interfejs? Można zrobić most (bridge). W systemie (w globalnej przestrzeni nazw) wyknuję kolejno:

$ sudo ip link add most type bridge
$ sudo ip link set master most dev siec1

Pierwsze polecenie tworzy most, a drugie przypina do niego urządzenie siec1 (czyli koniec veth, widoczny w systemie). Teraz należy nadać adres.

$ sudo ip addr add 10.11.0.1/24 dev most
$ sudo ip link set dev most up
$ sudo ip link set dev siec1 up

Kolejno:

W kontenerze natomiast uruchamiam:

# ip addr add 10.11.0.2/24 dev siec0
# ip link set dev siec0 up

Nadawany jest adres dla interfejsu siec0 (inny niż mostu, ale z tej samej podsieci) i również podnoszony jest interfejs.

Od tego momentu polecenie ping 10.11.0.1 uruchomione na kontenerze oraz ping 10.11.0.2 uruchomione poza kontenerem pokazuje, że pakiety docierają na drugi koniec.

Co więcej, jeśli w kontenerze uruchomię nc -l 80 (netcat – otwarcie portu 80), to poza kontenerem tego otwartego portu nie widać (nie pojawia się w wynikach polecenia sudo ss -tnlp), ale można się z nim połączyć na adresie kontenera, tj. 10.11.0.2.

Mam pełnoprawny kontener z wirtualną siecią.

Co dalej?

Można w ten sam sposób tworzyć kolejne kontenery. Dla każdego dodawać kolejne urządzenie veth, dołączone jednym końcem do mostu.

W systemie można włączyć przekazywanie pakietów i dodać maskaradę w iptables, co spowoduje, że kontener będzie mógł komunikować się nie tylko z innymi kontenerami i systemem, ale także z siecią LAN (i Internetem).

Można również za pomocą iptables i jego tablicy nat przekierować przychodzące pakiety do uruchomionych kontenerów.

Na koniec można też wykorzystać system plików OverlayFS i utworzyć dla kontenera warstwy struktur katalogów ze wspólną bazą read only.

Dokładnie takie kroki wykonywane są przez serwer dockera, gdy uruchamiamy polecenie docker run.

Doker przykrywa to zgrabnym zestawem parametrów w CLI. Ale pod spodem wykonuje się właśnie to, co opisałem w niniejszym artykule i jak widać, nie jest to zbyt skomplikowane.

Tagi: #kontener #docker #kubernetes #linux