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
- Exec alakban írt RUN build-je
- Többféle utasítás használata
- Image history lekérdezése
- Saját builder készítése bash-ben
- Végszó
RUN utasítás és ami mögötte van
[Tartalom]
Nézzük tehát, milyen egy egyszerű Dockerfile tartalma.
Dockerfile.v1
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.
"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 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:
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
RUN [ "mkdir", "/app"]
RUN [ "touch", "/app/config.ini" ]
RUN [ "sed", "-i", "$ aversion=1.0", "/app/config.ini" ]
Build
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:
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.
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 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:
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.
Itt fontos a --all
paraméter, különben az ideiglenes konténerekből létrejött image-ek nem jelennének meg.
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:
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:
Az eredmény pedig:
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" 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 $instruction
vá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:
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:
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
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.
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:
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:
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:
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:
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:
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.