OCI Image

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:

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.

merklee tree

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.

oci config

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.

oci manifest

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.

oci layout

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.

<