Docker image készítése docker build parancs használata nélkül

Borító kép powerpointtal szerkesztve

A Docker build-ről nem először van szó az I.T. szigeten és nem is utoljára. De vajon érted-e, mi zajlik a build parancs mögött? Lehet, hogy elsőre úgy tűnik, erre nincs is szükség, hiszen épp az a lényeg, hogy a Docker megoldjon mindent. Ha viszont hibát kell keresni, nagyon sokat jelent, hogy tudjuk-e, mire utalhat egy hibajelenség. Hacsak nincs valamilyen fizetős támogatásunk, akkor nem árt kicsit a színfalak mögé nézni. A következőkben azt mutatom be, hogyan lehet saját buildert is készíteni, amihez persze meg kell ismerni, milyen lépéseket végez el a builder és ezek alapján milyen lépéseket kel nekünk leprogramozni. A docker build összes funkcióját nem programozzuk le, de éppen eleget ahhoz, hogy egy hiba esetén már legyen fogalmad arról, hogyad légy önmagad supportja.

A bemutatóban használt forráskód:
https://github.com/itsziget/tutorial-docker-build/tree/v1.0.0

Tartalomjegyzék

RUN utasítás és ami mögötte van

[Tartalom]

Nézzük tehát, milyen egy egyszerű Dockerfile tartalma.

Dockerfile.v1

FROM ubuntu:20.04

RUN mkdir /app
RUN echo "version=1.0" > /app/config.ini

A FROM-on kívül mindössze 2 RUN direktívát tartalmaz. Ezek mindegyike valójában elindít egy konténert, amit általában nem látunk, mert azonnal le is törli alapértelmezetten, amint a build lefutott. Ezen viszont lehet változtatni a --rm=false paraméterrel, ahol az "rm" természetesen a "remove"-ot jelenti, amit ezzel letiltunk. Értelemszerűen a RUN után írt parancs lesz lefuttatva a konténerben hasonlóan a docker run utasításhoz. Az alábbi parancs futtatásával folyamatosan lehet figyelni, hogy éppen milyen Docker konténerek vannak, ami majd jól lebuktatja a docker build parancsot.

watch --interval 1 --no-title \
  "docker container ls \
   --all \
   --no-trunc \
   --format 'table {{ printf \"%.12s\" .ID }}\t{{ .State }}\t{{ .Command }}'"

Itt lényeges, hogy használtam a --no-trunc paramétert, mert különben csak egy töredéke jelent volna meg a hosszú parancsoknak. Ez a paraméter viszont a megjelenített konténer azonosítót is befolyásolja, ezért azt a "printf" go template függvény segítségével visszaalakítottam 12 karakteresre, ami az alapértelmezett megjelenési forma.

Mindeközben egy másik terminálban a buildet futtatjuk.

DOCKER_BUILDKIT=0 \
  docker image build . \
    -t localhost/buildtest:v1 \
    -f Dockerfile.v1 \
     --rm=false \
     --no-cache

A fenti kódban a DOCKER_BUILDKIT változó 0-ra állításával a buildkitet kapcsolom ki, ami teljesen megváltoztatná a build folyamatát és nem látnánk a várt módon a konténereket, nem csak a lefutás után, de közben sem. A --no-cache paramétert is használtam, mivel enélkül, ha az adott direktíva eredménye már szerepel a cache-ben, akkor nem jön létre ismét konténer annak létrehozásához. A fenti utasítás hatására a "watch" parancs ablakában pedig valami ilyesmi lesz:

CONTAINER ID   STATE     COMMAND
f1c09b6ace9f   exited    "/bin/sh -c 'echo \"version=1.0\" > /app/config.ini'"
9a574344ad15   exited    "/bin/sh -c 'mkdir /app'"

Exec alakban írt RUN build-je

[Tartalom]

Mivel a shell alakot használtam a RUN direktívánál, azaz stringként adtam meg a parancsot, az egész a /bin/sh-nak lett átadva paraméterként. Ennek vannak olyan előnyei, hogy például az "echo" parancs kimenetét át tudtam irányítani fájlba. Ha json-ként adtam volna meg a parancsokat, egy ilyen alternatív Dockerfile lett volna a eredménye jobb híján:

Dockerfile.v2

FROM ubuntu:20.04

RUN [ "mkdir", "/app"]
RUN [ "touch", "/app/config.ini" ]
RUN [ "sed", "-i", "$ aversion=1.0", "/app/config.ini" ]

Build

DOCKER_BUILDKIT=0 \
  docker image build . \
    -t localhost/buildtest:v2 \
    -f Dockerfile.v2 \
     --rm=false \
     --no-cache

Eközben a watch parancs ablakában az alábbihoz hasonló jelenik meg:

CONTAINER ID   STATE     COMMAND
0dec9af67b0a   exited    "sed -i '$ aversion=1.0' /app/config.ini"
009537754a31   exited    "touch /app/config.ini"
ca13b1945a00   exited    "mkdir /app"
f1c09b6ace9f   exited    "/bin/sh -c 'echo \"version=1.0\" > /app/config.ini'"
9a574344ad15   exited    "/bin/sh -c 'mkdir /app'"

Többféle utasítás használata

[Tartalom]

A helyzet viszont az, hogy nem csak a RUN direktívák miatt indulnak el konténerek. Valójában bármelyik direktíva egy konténer indítását is eredményezi, viszont lesznek, amik csak metaadatokat állítanak be, ezért valódi parancs nem fut majd a konténerben. Hogy akkor mégis mi, azt nézzük meg a Dockerfile.v3 használatával.

FROM ubuntu:20.04

ARG app_dir=/app

ENV version=1.0 \
    config_name=config.ini

RUN mkdir "$app_dir"
RUN echo "version=$version" > "$app_dir/$config_name"

CMD ["env"]

Itt már van ARG, ENV és CMD direktíva is. Annak ellenére, hogy a CMD-ben is parancsot adunk meg, ennek nem kell build időben futnia, tehát valójában csak metaadat lesz. Ezt ráadásul általában nem shell alakban adjuk meg. Lássuk akkor a buildet:

DOCKER_BUILDKIT=0 \
  docker image build . \
    -t localhost/buildtest:v3 \
    -f Dockerfile.v3 \
     --rm=false \
     --no-cache

Ismét lett 5 újabb konténer, ám ebből 3 nagyon furcsa shell paranccsal, amiknek az állapota ráadásul nem "exited" hanem "created", azaz sosem futottam, csak létre lettek hozva:

CONTAINER ID   STATE     COMMAND
0f2324b6ba71   created   "/bin/sh -c '#(nop) ' 'CMD [\"env\"]'"
cdb574642ea8   exited    "/bin/sh -c 'echo \"version=$version\" > \"$app_dir/$config_name\"'"
22d399354111   exited    "/bin/sh -c 'mkdir \"$app_dir\"'"
cc0d403dd1df   created   "/bin/sh -c '#(nop) ' 'ENV version=1.0 config_name=config.ini'"
3e4aa53e11c6   created   "/bin/sh -c '#(nop) ' 'ARG app_dir=/app'"
0dec9af67b0a   exited    "sed -i '$ aversion=1.0' /app/config.ini"
009537754a31   exited    "touch /app/config.ini"
ca13b1945a00   exited    "mkdir /app"
f1c09b6ace9f   exited    "/bin/sh -c 'echo \"version=1.0\" > /app/config.ini'"
9a574344ad15   exited    "/bin/sh -c 'mkdir /app'"

A #(nop) valójában csak egy komment a shellben, ahol a "nop" a "no operation" rövidítése, azaz akkor sem futtatott volna semmit, ha a konténer el is indul, mivel a csak egy kommentet tartalmazó szkriptnek adja át paraméterül a metaadat definícióját.

Image history lekérdezése

[Tartalom]

Tudjuk, hogy minden konténerhez egy image is tartozik, amiből létrehoztuk. A következő image listából látszik, hogy tényleg minden ideiglenes konténerhez létrejött egy image is.

docker image ls --all

Itt fontos a --all paraméter, különben az ideiglenes konténerekből létrejött image-ek nem jelennének meg.

REPOSITORY            TAG       IMAGE ID       CREATED          SIZE
localhost/buildtest   v3        8f1aad1750cd   3 minutes ago    72.8MB
<none>                <none>    454de17b2b2e   3 minutes ago    72.8MB
<none>                <none>    a66c12b47355   3 minutes ago    72.8MB
<none>                <none>    4e1f6025a35c   3 minutes ago    72.8MB
<none>                <none>    e5cc8f6ebbb3   3 minutes ago    72.8MB
localhost/buildtest   v2        5aa8350c2891   5 minutes ago    72.8MB
<none>                <none>    594c4ab64112   5 minutes ago    72.8MB
<none>                <none>    f8b86868aff2   5 minutes ago    72.8MB
localhost/buildtest   v1        ac80a8836633   20 minutes ago   72.8MB
<none>                <none>    1a5c9ef0a7c2   20 minutes ago   72.8MB
ubuntu                20.04     ba6acccedd29   6 weeks ago      72.8MB

A fenti listában az "IMAGE ID" oszlop értékeit összevetve az image-ek listájával látható, hogy melyek azok az image-ek, amik egy adott buildhez tartoznak. Ha arra vagy kíváncsi, egy konkrét image milyen további image-ekből épült fel, a docker image history ezt is megmutatja:

docker image history localhost/buildtest:v3
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
8f1aad1750cd   3 minutes ago    /bin/sh -c #(nop)  CMD ["env"]                  0B        
454de17b2b2e   3 minutes ago    |1 app_dir=/app dir /bin/sh -c echo "version…   12B      
a66c12b47355   3 minutes ago    |1 app_dir=/app dir /bin/sh -c mkdir "$app_d…   0B        
4e1f6025a35c   3 minutes ago    /bin/sh -c #(nop)  ENV version=1.0 config_na…   0B        
e5cc8f6ebbb3   3 minutes ago    /bin/sh -c #(nop)  ARG app_dir=/app dir         0B        
ba6acccedd29   6 weeks ago      /bin/sh -c #(nop)  CMD ["bash"]                 0B        
<missing>      6 weeks ago      /bin/sh -c #(nop) ADD file:5d68d27cc15a80653…   72.8MB

Azt is le lehet kérdezni, hogy egy adott image melyik konténerből lett létrehozva:

docker image inspect localhost/buildtest:v3 --format '{{ .ContainerConfig.Cmd }}'

Az eredmény pedig:

[/bin/sh -c #(nop)  CMD ["env"]]

Ez pedig pont az, amit az image history "Created by" oszlopában is lehetett látni.

Saját builder készítése bash-ben

[Tartalom]

Ezek után a kérdés, lehet-e ezen információk birtokában Dockerfile nélkül is image-et készíteni. A válasz pedig: igen, lehet. Ehhez már csak annyit kell tudni, hogy a docker container commit paranccsal tudunk elmenteni konténert image-ként. A lépések tehát a következők a saját builder írásához:

  • Hozd létre a konténert az image-ből, amit a FROM utasításban is használnál, vagy az előző utasítás eredményeként létrejött image-ből.
  • Ha programot szeretnél futtatni a konténerben a telepítés egy lépéseként, akkor indítsd is el, metaadatok esetén hagyd "created" állapotban.
  • Ha másolni szeretnél fájlokat az image-be, akár az el sem indított konténerbe is lehetséges a docker container cp paranccsal.
  • Mentsd el a konténert image-ként a docker container  commit paranccsal. Itt opcionálisan a -c paraméterrel még metaadatokat is változtathatsz. A CMD utasítás értékének beállítása például ezzel történhet.
  • Adj nevet az image-nek a docker image tag paranccsal. Az utolsó kivéve a töbinek nincs szüksége névre.

A következőkben pedig egy Bash szkriptben írt buildert mutatok be.

Ha rettentően bonyolult a megoldás, az persze nem sokat segít, ezért képzeld el, hogy nem kell új, teljesen egyedi utasításokat megtanulni a már ismert FROM, ENV és például RUN, hanem szinte ugyanazt írhatod, mint eddig, csak elé kell tenni, hogy: build_layer "$image_id". Ezek után egy image buildelése például így nézne ki:

build_layer "$image_id" FROM "ubuntu:20.04"
build_layer "$image_id" ARG app_dir=/app
build_layer "$image_id" ENV version=1.0 config_name=config.ini
build_layer "$image_id" RUN /bin/sh -c 'export app_dir=/app && mkdir $app_dir'
build_layer "$image_id" RUN /bin/sh -c 'export app_dir=/app && echo "version=$version" > "$app_dir/$config_name"'
build_layer "$image_id" RUN /bin/sh -c 'apt-get update && apt-get install nano'
build_layer "$image_id" CMD '["env"]'

Ez persze csak az egyes rétegek elkészítéséhez a függvények hívása. Természetesen a függvényt meg kell írni és lesz még néhány sor kód azon kívül is, ami a Dockerhez hasonló inforációkat ír ki a build után, illetve az utolsó image-hez egy nevet is rendel. Minden build_layer függvényhívás megváltoztatja a globális $image_id változó értékét, amikor pedig a legelső utasítást adjuk meg a FROM kulcsszóval, a FROM utáni image névből fogja kitalálni annak az azonosítóját. Ez az a lépés, amikor nem is kell semmi mást sem tenni.

Feltűnhet még, hogy az argumentumokat másképpen adtam át, mint ahogy azt a korábbi vizsgálódások alapján sejteni lehetett volna. A cél viszont nem az, hogy leprogramozzuk a Dockert, hanem hogy hasonló eredményt érjünk el. A /bin/sh elé márpedig nem tehetem az változó beállítását, mivel ott még nincs shell és a Docker fájlnévként értelmezné az értékadást.

A függvény elején az $image_id és az $instructionváltozókat állítom be, ahol az utóbbiban lesz például a RUN kulcsszó, ami alapján eldönthető később, hogy hogyan kell kezelni az azt követő összes további argumentumot, amit pedig az $args tömbbe fogok betölteni:

function build_layer() {
  local image="$1"
  local instruction="$2"
  shift 2

  local args=("$@")
 
  # ....
}

Ami nagyjából mindegyik utasításban közös, hogy létre fog jönni egy konténer, csak nem mindegyik fog elindulni. Így tehát egy egyszerű "case" elágazással különböző paraméterezéssel ugyan, de megszerzem a konténer azonosítóját:

  case "$instruction" in
    FROM)
      image_id=$(docker image inspect "${args[0]}" --format '{{ .Id }}')
      ;;
    ARG)
      container_id=$(docker container create "$image" /bin/sh -c '#(nop)' "$instruction ${args[*]}")
      ;;
    ENV)
      env=()
      for e in "${args[@]}"; do
        env+=(-e "$e");
      done
      container_id=$(docker container create "${env[@]}" "$image" /bin/sh -c '#(nop)' "$instruction ${args[*]}")
      ;;
    CMD)
      container_id=$(docker container create "$image" /bin/sh -c '#(nop)' "$instruction ${args[*]}")
      change=(-c "$instruction ${args[*]}")
      ;;
    RUN)
      container_id=$(docker container create "$image" "${args[@]}")
      ;;
    *)
      >&2 echo "Invalid instruction: $instruction"
      return 1
      ;;
  esac
  case "$instruction" in
    ARG) container_id=$(docker container create "$image" # ... ); ;;
    ENV) container_id=$(docker container create "${env[@]}" "$image" # ... ); ;;
    CMD) container_id=$(docker container create "$image" # ... ); ;;
    RUN) container_id=$(docker container create "$image" # ... ); ;;
    # ....
  esac

Látható, hogy igazából csak az ENV a kakukktojás, mivel nem csak egy parancsot kell megadni a konténernek, hanem változókat is be kell állítani. Itt persze még akkor hiányzik a FROM utasítás, aminél viszont pont az image azonosítót kell megszerezni, hiszen itt nem volt előző ID, csak az image neve, mint a FROM argumentuma és ezen a ponton még nem kell konténert indítani, majd csak a következő utasításnál.

  case "$instruction" in
    FROM) image_id=$(docker image inspect "${args[0]}" --format '{{ .Id }}')
    ARG) container_id=$(docker container create "$image" # ... ); ;;
    ENV) container_id=$(docker container create "${env[@]}" "$image" # ... ); ;;
    CMD) container_id=$(docker container create "$image" # ... ); ;;
    RUN) container_id=$(docker container create "$image" # ... ); ;;
    # ....
  esac

Megjegyzés: A fenti kód az átláthatóság kedvéért csak a parancsok elejét tartalmazza.

Mindezek után, ha RUN utasításról volt szó, akkor el is kell indítani a konténert az előtérben:

  if [[ "$instruction" == "RUN" ]]; then
    docker container start -a "$container_id"
  fi

Ha pedig van konténer azonosító, az azt jelenti, hogy azt a konténert el kell menteni egy image-ként:

  if [[ -n "$container_id" ]]; then
    image_id=$(docker container commit "${change[@]}" "$container_id")
  fi

Persze ez egyedül akkor nem fog futni, ha az utasítás a FROM volt, de pont ez volt a cél. És máris itt van az image azonosító, ami jelenleg egy lobális változó a szkripten belül, tehát a függvény meg tudja változtatni, hogy a következő utasítás aramétereként az új érték legyen elérhető. Nyilván, mivel a változó globális, akár meg is lehetne szüntetni a paramétert, így viszont a függvény önmagában is képes bármelyik image-ből újat készíteni, ahol pontosan látszik, hogy melyik ID-t adtuk át.

Nem említettem még a change változót, ami jelen egy tömb, így ha az üres, nem lesz átadva semmi a commitnak, ha viszont tartalmazza például, hogy -c 'CMD ["env"]', akkor az új image-ben futtatandó alapértelmezett parancsot is beállítja az adott rétegben metaadatként.

Van még egy rész a függvényen kívül, ami fontos lesz, ez pedig az image-hez egy név rendelése, hiszen eddig csak azonosítókkal dolgoztunk:

if [[ -n "$target_image_name" ]]; then
  docker image tag "$image_id" "$target_image_name"
  echo "Successfully tagged $target_image_name"
fi

Ezen kívül még van néhány kiírás, tehát a teljes szkript most már törölt részek nélkül az alábbi:

#!/usr/bin/env bash

set -eu -o pipefail

function build_layer() {
  local image="$1"
  local instruction="$2"
  shift 2

  local args=("$@")

  local env
  local start=0
  local container_id=""
  local change=()

  ((++step))
  echo "Step $step : $instruction ${args[@]}"

  case "$instruction" in
    FROM)
      image_id=$(docker image inspect "${args[0]}" --format '{{ .Id }}')
      ;;
    ARG)
      container_id=$(docker container create "$image" /bin/sh -c '#(nop)' "$instruction ${args[*]}")
      ;;
    ENV)
      env=()
      for e in "${args[@]}"; do
        env+=(-e "$e");
      done
      container_id=$(docker container create "${env[@]}" "$image" /bin/sh -c '#(nop)' "$instruction ${args[*]}")
      ;;
    CMD)
      container_id=$(docker container create "$image" /bin/sh -c '#(nop)' "$instruction ${args[*]}")
      change=(-c "$instruction ${args[*]}")
      ;;
    RUN)
      container_id=$(docker container create "$image" "${args[@]}")
      ;;
    *)
      >&2 echo "Invalid instruction: $instruction"
      return 1
      ;;
  esac

  if [[ -n "$container_id" ]]; then
    printf " ---> Running in %.12s\n" "$container_id"
  fi
 
  if [[ "$instruction" == "RUN" ]]; then
    docker container start -a "$container_id"
  fi

  if [[ -n "$container_id" ]]; then
    image_id=$(docker container commit "${change[@]}" "$container_id")
  fi
  printf ' ---> %.12s\n' "$(echo $image_id | cut -d: -f2)"
}

target_image_name="${1:-}"
image_id=""
step=0

build_layer "$image_id" FROM "ubuntu:20.04"
build_layer "$image_id" ARG app_dir=/app
build_layer "$image_id" ENV version=1.0 config_name=config.ini
build_layer "$image_id" RUN /bin/sh -c 'export app_dir=/app && mkdir $app_dir'
build_layer "$image_id" RUN /bin/sh -c 'export app_dir=/app && echo "version=$version" > "$app_dir/$config_name"'
build_layer "$image_id" RUN /bin/sh -c 'apt-get update && apt-get install nano'
build_layer "$image_id" CMD '["env"]'

printf 'Successfully built %.12s\n' $(echo $image_id | cut -d: -f2)

if [[ -n "$target_image_name" ]]; then
  docker image tag "$image_id" "$target_image_name"
  echo "Successfully tagged $target_image_name"
fi

A futtatás pedig a következőképpen történik:

./build.sh localhost/buildtest:v4

A kimeneten az egyik feltűnő különbség az lesz, hogy a build lépések számlálásánál nem jelenítem meg, hogy hány van összesen, csak hogy hányadiknál tart éppen.

Végszó

[Tartalom]

Bátorítok mindenkit, hogy módosítgassa kedvére a szkriptet tanulási céllal. Házi feladat a COPY utasítás implementálása. Ez nem csak egy jó mód arra, hogy közelebb kerüljünk a build működésének megértéséhez, de szükség esetén valóban használható egy olyan build processz megvalósítására, amit nem támogat még a Dockerfile szintaktikája. Persze ha egy mód van rá, én javaslom szkriptben csak azt az extra információt összeállítani, am aztán változóként átadható és továbbra is használható a Dockerfile.

Kategóriák: 
Megosztás/Mentés