V tomto článku sa pozriem bližšie v akej forme je kontajner distribuovaný. Vytvorim si vlastný obraz aby som lepšie pochopil jeho štruktúru.
Aby človek vedel kontajner zostaviť na svojom počítači, následne ho preniesť na server a tam ho pustiť rovnakým spôsobom, je potrebné mať jeden dôležitý element. A to je akýsi balíček alebo obraz - image. Zabaliť aplikáciu nieje žiaden prevratný nový koncept. Takých riešení je hromada. Či už je to RPM balíček alebo aj taký JAR súbor. Lenže všetky tieto koncepty trpia nejakým problémom. JAR je určený len pre Java aplikácie. RPM je zase typický len pre určitú rodinu Linux distribúcii.
Kontajnery idú trochu iným smerom. Smerom hermetických obrazov. Čo to znamená? Že obraz nemá žiadne závislosti. Namiesto závislosti sa používajú vrstvy. Čo to v skutočnosti je? Aký je formát? Je to jeden súbor, ktorý sa kopíruje od programátora na cieľový systém?
OCI - Open Container Initiative
Skôr, ako začnem rozoberať konkrétny formát kontajnerov, musím napísať, čo je Open Container Initiative, pretože OCI je dôležitým míľnikom v histórii kontajnerov.
V roku 2013 do stojatých vôd kontajnerov prišiel Docker. Celé IT zažívalo revolúciu aká tu dlho nebola. Vznikol úplne nový trh. Ako to už v takých situáciách býva, každý si chcel uchmatnúť, čo najväčší podiel z tohto koláča.
V roku 2015 si, ale sadli za spoločný stôl vtedajší konkurenti Docker a CoreOS. Namiesto toho, aby navzájom súťažili, ohlásili vznik Open Container Initiative. Snahu o štandardizáciu celého ekosystému. Tento moment sa často označuje aj ako koniec kontajnerových vojen.
Úlohou bolo zadefinovať, čo vlastne kontajner je a vytvoriť otvorené štandardy pre všetkých vendorov. Jedine tak mohla byť zachovaná portabilita. Nebolo to všetko až také ružové. Na začiatku OCI pripomínalo skôr pakt Molotov Ribbentrop. S odstupom času sa OCI podarilo zadefinovať 3 dôležité štandardy:
- runtime-spec - štandard behového prostredia
- image-spec - štandard formátu obrazu kontajnera
- distribution-spec - štandard distribúcie obrazov
S OCI runtime-spec sme sa už stretli. Tento štandard hovorí, ako má behové prostredie spustiť kontajner. Ako základ štandardu poslúžil Dockerovský libcontainer
. Referenčnou implementáciou je behové prostredie runc
. Mimochodom, to je ten config.json
, do ktorého som chodil hľadať odpovede v minulých článkoch.
Druhý je image-spec
. Tento štandard zase hovorí z čoho pozostáva obraz kontajnera, definuje jeho formát. A práve o obraze kontajnera sa dnes budeme baviť.
Najnovším prírastkom je distribution-spec. Jeho prvá verzia bola skompletizovaná len tento rok. Táto špecifikácia štandardizuje, akým spôsobom sa obraz kontajnera dostane do registra pomocou push
, ako sa z registra vytiahne pomocou pull
. Štandardizuje API rozhranie registrov.
Merkleho strom
Spravím si takú malú odbočku do sveta dátových štruktúr. Totiž formát OCI obrazu je organizovaný ako Merkleho strom.
Čo je to za strom? Ide o dátovú štruktúru, podobnú klasickému stromu. Rozdiel je však v tom, že každý uzol je identifikovaný hash hodnotou súčtu svojich podriadených uzlov. Znie to komplikovane, ale ide o veľmi jednoduchý koncept.
Predstavme si uzol A ktorý má podriadené uzly B a C. Tie sú identifikované ako hash hodnoty: h(B) a h(C). Uzol A je identifikovaný ako hash hodnota A = h(B + C). A takto to ide postupne smerom dole až na koniec, k listom stromu.
Načo je dobre použiť práve takýto strom? Merkleho strom takto vie zabezpečiť integritu a validitu dát. Dáta v strome nieje možné zmeniť. Ak by sa zmenil nejaký uzol, je potrebné prepočítať každý rodičovský uzol. Je to výborný spôsob, ako predísť poškodeným dátam, ako zabrániť, aby niekto podvrhol škodlivý kód a tiež spôsob, ako zredukuje pamäťové nároky.
Vďaka týmto vlastnostiam je Merkleho strom často používanou dátovou štruktúrou v rôznych programoch a systémoch. Používa ho Git, Mercurial, rôzne NoSQL databázy ako Cassandra či Riak. Je používaný v p2p sietiach, v rôznych blockchain technológiách. Ale najdôležitejšie pre nás, je základom pre obraz kontajnera.
Vrstvy
Konečne sa dostávam k tomu, ako vyzerá OCI obraz. Z minulého článku o Overlay už vieme, že súborový systém kontajnera je rozdelený do vrstiev. Ja si teraz vytvorím jednoduchý obraz kontajnera, ktorý sa bude skladať z dvoch vrstiev. V spodnej vrstve je Apline Linux distribúcia:
$ curl https://dl-cdn.alpinelinux.org/alpine/v3.14/releases/x86_64/alpine-minirootfs-3.14.2-x86_64.tar.gz --output layer1.tar.gz
V druhej vrstve budem mať nejakú fiktívnu aplikáciu. V mojom prípade je to jednoduchý script, ktorý vypíše na výstup obsah adresára. Aby to bolo zaujímavejšie, ten bude definovaný cez premennú prostredia.
$ cat <<EOF > app.sh
#!/bin/sh
echo "Hello Container"
ls \$APPDATA
EOF
$ chmod u+x ./app.sh
$ tar -czvf layer2.tar.gz ./app.sh
Obsah kontajnera by sme mali. Každá vrstva je reprezentovaná tar.gz
archívom, kvôli distribúcii. Lenže ja som pred chvíľou hovoril niečo o merkleho strome a o tom, že každý uzol je identifikovaný hash hodnotou. Preto si premenujem súbory na ich sha256 hodnotu:
$ mv layer1.tar.gz `sha256sum ./layer1.tar.gz | cut -d ' ' -f 1`
$ mv layer2.tar.gz `sha256sum ./layer2.tar.gz | cut -d ' ' -f 1`
V mojom adresári sa teraz nachádza dvojica súborov, ktorých mená sú nič nehovoriace čísla:
$ ls -l
total 2676
-rw-r--r-- 1 sn3d users 2729839 Nov 11 13:15 4591f811a5515b13d60ab76f78bb8fd1cb9d9857a98cf7e2e5b200e89701e62c
-rw-r--r-- 1 sn3d users 158 Nov 11 13:16 770799bc671cea03c0e218581f81097f3f486e96dd44a1d27558c814baab6ba4
OCI Konfigurácia
Kontajner nieje len o súboroch. Aký proces sa má spustiť? Aký je CMD alebo ENTRYPOINT? Aké premenné prostredia sa majú nastaviť? Čo VOLUME? OCI špecifikácia tieto dáta volá konfigurácia. Vytvorím si config.json
:
{
"architecture": "amd64",
"os": "linux",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"APPDATA=/var/lib/app/data"
],
"Entrypoint": [
"/app.sh"
],
"Volumes": {
"/var/lib/app/data"
}
}
}
OCI špecifikácia obrazu presne hovorí, ktorý parameter je k akému účelu. Taktiež hovorí, že je potrebné stále uviesť architecture
, os
a rootfs
. Parameter config
je dobrovoľný, no potrebný. Tu som si zadefinoval premennú prostredia APPDATA
, ktorá je potrebná pre môj script. Okrem toho je tu Volume
a Entrypoint
, ktoré sú paralelou VOLUME
a ENTRYPOINT
z Dockerfile
. Ešte mi chýba rootfs
. Jeho obsahom majú byť tzv. diff_id
pre každú vrstvu.
Nejde však o sha256 hodnotu zbaleného archívu, ktorú som použil ako meno súborov. Je potrebné archív dekomprimovať, vypočítať hash. Parameter diff_id
je hash hodnota pred kompresiou. K tomu použijem nasledujúci príkaz pre spodnú vrstvu:
$ gunzip -c 4591f811a5515b13d60ab76f78bb8fd1cb9d9857a98cf7e2e5b200e89701e62c | sha256sum | cut -d ' ' -f 1
8fa0564be498726f0acf229989fe99fa13bdb3b24b4615e4352c430c9047bcdd
A ešte pre vrstvu s aplikáciou:
$ gunzip -c 770799bc671cea03c0e218581f81097f3f486e96dd44a1d27558c814baab6ba4| sha256sum | cut -d ' ' -f 1
6a36e05db8f896e37da3b4f736ff1641da8f96c0c8cf3d04f51bc44bdf134a90
Tieto hodnoty následne doplním do config.json
ako hodnoty diff_id
v rootfs
:
{
"architecture": "amd64",
"os": "linux",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"APPDATA=/var/lib/app/data"
],
"Entrypoint": [
"/app.sh"
],
"Volumes": {
"/var/lib/app/data"
}
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:8fa0564be498726f0acf229989fe99fa13bdb3b24b4615e4352c430c9047bcdd",
"sha256:6a36e05db8f896e37da3b4f736ff1641da8f96c0c8cf3d04f51bc44bdf134a90"
]
}
}
Aj tento súbor premenujem na hash hodnotu, ale bez kompresie.
$ mv config.json `sha256sum ./config.json | cut -d ' ' -f 1`
OCI Manifest
Teraz by to chcelo polepiť vrstvy aj konfiguráciu dokopy. Pre tento účel OCI špecifikácia definuje manifest.
Vytvorím si manifest.json
:
{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:6368168a85d5bb71f601369d1e673453b05d4dbaec47caa0d22326310ca2de2e",
"size": 522
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:4591f811a5515b13d60ab76f78bb8fd1cb9d9857a98cf7e2e5b200e89701e62c",
"size": 2729839
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:70799bc671cea03c0e218581f81097f3f486e96dd44a1d27558c814baab6ba4",
"size": 167
}
]
}
Manifest referuje na moje dve vrstvy a konfiguráciu.
Okrem referencie je tu aj zaujímavý parameter mediaType
. OCI špecifikácia presne definuje, aké typy môžeme použiť. Vďaka tomuto parametru nástroje vedia, ako sa vysporiadať s referenciou. Takto môžeme použiť napríklad iný typ kompresie. Samozrejme manifest.json
tiež prevediem a premenujem na hash hodnotu:
$ mv manifest.json `sha256sum ./manifest.json | cut -d ' ' -f 1`
OCI Index
Posledným krokom je vytvorenie indexu. Index je niečo ako manifest manifestov. Načo je to dobre? Môžeme mať kontajner, ktorý je určený pre rôzne platformy napr amd64
a arm64
. V takom prípade budeme potrebovať manifest.json
pre každú platformu. V indexe potom len povieme, ktorý manifest je určený ktorej platforme. Index je akýsi main()
kontajnera.
Najprv však jeden maličký krok. Všetky súbory, ktoré majú názov ako sha256 hodnotu, presuniem do podadresára blobs/sha256
:
$ mkdir -p blobs/sha256
$ mv * ./blobs/sha256
Vytvorím ešte súbor index.json
podľa špecifikácie, hneď vedľa blobs
adresára:
{
"schemaVersion": 2,
"manifests": [
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:dd9a79294602d8362ec9c621f979e222226bd9769fd01a04e416abe997735539",
"size": 611,
"platform": {
"architekture": "amd64",
"os": "linux"
},
"annotations": {
"org.opencontainers.image.ref.name": "mycontainer:latest"
}
]
}
Okrem očakávanej referencie na manifest je tu jeden zaujímavý parameter alebo skôr pole parametrov - annotations
. Ide o voľnú časť, ktorá je ideálna ako miesto pre rôzne metadata. OCI špecifikácia definuje aj zopár štandardných anotácii ako práve použité org.opencontainers.image.ref.name
.
Posledný a najjednoduchší súbor, ktorý budem potrebovať je oci-layout
:
{"imageLayoutVersion": "1.0.0"}
Ten je veľmi jednoduchý a okrem verzie neobsahuje nič viac. Takto by som mal mať pripravené správne usporiadanie súborov a adresárov - tzv. image layout.
Všetko to zabalíme do komprimovaného tar-u:
$ tar -czvf mycontainer.tar.gz *
A môj prvý ručne vytvorený obraz je na svete.
Nebol by to život…
Nebol by to život ak by boli veci jednoduché. Človek by povedal, že už len stači importovať vytvorený obraz do Dockera. Docker disponuje dvojicou príkazov import
a load
. Skúsil som najprv naivne import
.
$ docker import container.tar.gz mycontainer:latest
sha256:1a911d35e192c1bc8587a47ae2be690a1261ad27d4a23b461843d1536ebb9d27
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
mycontainer latest 1a911d35e192 2 seconds ago 2.73MB
Docker rozpoznal môj nový obraz. Vyzerá to, že obraz bol importovaný. Lenže keď som sa snažil spustiť kontajner, dostal som sériu nepríjemných chýb:
$ docker run mycontainer:latest
docker: Error response from daemon: No command specified.
See 'docker run --help'.
$ docker run -it mycontainer:latest /bin/sh
docker: Error response from daemon: failed to create shim: OCI runtime create failed: container_linux.go:380: starting container process caused: exec: "/bin/sh": stat /bin/sh: no such file or directory: unknown.
ERRO[0000] error waiting for container: context canceled
Táto chyba hovorí, že kontajner nepozná /bin/sh
. Pomocou docker inspect
som sa lepšie pozrel na to, čo to ten Docker naimportoval:
$ docker inspect mycontainer:latest | jq '.[0].RootFS'
{
"Type": "layers",
"Layers": [
"sha256:1b3eb4a3b3b16b92434f29969d150f01e83f7c57c463e2d9d9a1806782ee40a3"
]
}
Tento kontajner obsahuje len jednu vrstvu. Môj obraz je ale zložený z dvoch vrstiev. Čo sa v tejto vrstve nachádza? Pozrel som sa na Overlay
a UpperDir
.
$ docker inspect mycontainer:latest | jq '.[0].GraphDriver.Data.UpperDir'
"/var/lib/docker/overlay2/0d68f0b3089a04288bc179dd7b6625d6c925665ba944e9f76ca1e6ac352e0a87/diff"
$ sudo ls /var/lib/docker/overlay2/0d68f0b3089a04288bc179dd7b6625d6c925665ba944e9f76ca1e6ac352e0a87/diff
blobs index.json oci-layout
Obsah kontajnera ma prekvapil. Namiesto alpine
distribúcie sa v kontajneri nachádzal obsah OCI obrazu. Dockerovský import
funguje inač ako som si myslel. Vytvorí úplne nový kontajner pre ľudovolný importovaný obsah. Čiže archív, ktorý sa snažim importovať by mal obsahovať rootfs
a nie OCI obraz.
Docker poskytuje aj druhý spôsob - load
.
$ docker load --input container.tar.gz
open /var/lib/docker/tmp/docker-import-755494528/blobs/json: no such file or directory
Zvláštna chyba o neexistujúcom json
. Najprv som si myslel, že som vyrobil zlý obraz. Potreboval som nejak overiť, či je teda môj obraz správny alebo nie. OCI poskytuje nástroj na prácu s obrazmi - oci-image-tool. Okrem iného vie zvalidovať obraz. Tak som si prekontroloval čo som stvoril:
$ oci-image-tool validate --type config blobs/sha256/8df2fec3206be8a1ebda9dba3811ef4a73711507c92aed407190271b810a8060
blobs/sha256/8df2fec3206be8a1ebda9dba3811ef4a73711507c92aed407190271b810a8060: OK
$ oci-image-tool validate --type imageIndex index.json
index.json: OK
Validation succeeded
$ oci-image-tool validate --type image .
autodetected image file type is: imageLayout
oci-image-tool: No ref specified, verify all refs
.: OK
Validation succeeded
Skontroloval som manifest, index aj štruktúru obrazu. Všetko je v poriadku. WTF?
Posledný výstrel. Skusim si nahrať do Dockera nejaký overený OCI obraz. Napríklad nginx:latest
. Na stiahnutie OCI obrazu som použil ďalší nástroj - Skopeo. Tento užitočný nástroj ktorý umožnuje robiť rôzne operácie s registrami kontajnerov. Napríklad aj stiahnuť kontajner ako OCI obraz.
$ skopeo copy docker://docker.io/library/nginx:latest oci:nginx:latest
$ cd ngxing
$ ls
blobs index.json oci-layout
Skopeo stiahol obraz ako neskomprimovaný adresár - OCI image layout. Obraz som si skomprimoval do tar.gz
archívu a opäť použil docker load
.
$ tar -czvf nginx.tar.gz *
$ docker load --input nginx.tar.gz
open /var/lib/docker/tmp/docker-import-557659615/blobs/json: no such file or directory
Bohužial dospel som k rovnakej chybovej správe. Takto som aspoň s kľudným svedomím mohol povedať, že môj OCI obraz je v poriadku. Chyba je zrejme niekde v Dockeri.
Na scénu prichádza Podman
Už dlhšiu dobu koketujem s Podmanom ako alternatívou ku Dockeru. Tak ma napadlo skúsiť môj OCI obraz s Podmanom.
$ podman load --input container.tar.gz
Getting image source signatures
Copying blob 4591f811a551 done
Copying blob 770799bc671c done
Copying config 8df2fec320 done
Writing manifest to image destination
Storing signatures
Loaded image(s): localhost/mycontainer:latest
Fíha, zatiaľ všetko vyzerá byť v poriadku. Môj obraz sa nahral do Podmana. Dostupný je ako localhost/mycontainer:latest
. Podman správne vyčítal meno kontajnera z anotácie v index.json
.
$ podman images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/mycontainer latest 8df2fec3206b About a minute ago 5.88 MB
Neváham a z malým nadšením idem spustiť kontajner. Teraz by to mohlo fungovať:
$ podman run --mount=type=bind,source=./,destination=/var/lib/app/data mycontainer:latest
Hello Container
blobs
container.tar.gz
index.json
oci-layout
Vyzerá to, že som úspešne vytvoril svoj prvý ručne vyrobený OCI obraz. Ak si spomínate na script, tak mi dokonca zafungoval aj volume
a premenna prostredia $APPDATA
Ešte niečo málo o Skopeo
Dobrým spôsobom ako sa naučiť niečo o OCI image je aj odsledovanie už existujúcich obrazov kontajnerov. Práve na to je výborny už spomínaný Skopeo. Jednoducho si môžem stiahnuť ľubovolný obraz a investigovať jeho štruktúru. Idealne je si stiahnuť všetko pomocou --all
:
$ skopeo copy --all docker://docker.io/library/nginx:latest
Ak ale potrebujem len rýchlo zinvestigovat konfiguráciu obrazu bez zdĺhavého sťahovania, môžem použiť skopeo inspect --config
:
$ skopeo inspect --config docker://docker.io/library/nginx:latest
{
"created": "2021-11-17T10:38:14.652464384Z",
"architecture": "amd64",
"os": "linux",
"config": {
"ExposedPorts": {
"80/tcp": {}
},
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.21.4",
"NJS_VERSION=0.7.0",
"PKG_RELEASE=1~bullseye"
],
...
Záver
Zostaviť si vlastný obraz kontajnera mi pomohlo pochopiť jeho štruktúru a jej nástrahy. OCI formát však nieje jediný vo svete kontajnerov. Často sa používa aj Docker Image Manifest V2. Ide o veľmi podobný formát, s menšími rozdielnostami. OCI image vychádza práve z Docker formátu. Docker formát však nieje tak otvorený ako OCI image.
Obraz kontajnera je veľmi dôležitý element vo svete kontajnerov. Rozumieť OCI, ale aj Docker formátom je životne dôležité pre rôzne nástroje z oblasti bezpečnosti. Či už ide o skenovanie, podpisovanie. V poslednej dobe sa dosť intenzívne hovorí o Software Bill of Materials - SBOM. SBOM je akýsi recept, účet, čo kontajner obsahuje. Aký software, v akej verzii, s akými závislosťami a s akými zraniteľnosťami. Je to téma blízka hlavne tým, čo sa pohybujú v regulovaných odvetviach. A práve SBOM dosť rezonoval aj na tohtoročnom OCI summite. Možno sa čoskoro dočkáme ďalších zmien na ceste k bezpečnejším kontajnerom.