Docker konténerek helyes leállítása adatvesztés nélkül

Borító kép stop táblával a pixabay.com-ról powerpointtal szerkesztve

Van egy fajta bizalmatlanság a Docker konténerekkel szemben, aminek az egyik oka az is lehet, hogy ha a Docker image-et nem jól készítjük el, vagy az indításkor rosszul használjuk a paramétereket, akár olyan jelenséghez is vezethet, hogy a konténer leállításakor adatok sérülnek és az alkalmazás legközelebb akár el sem indul. Ez persze nem egy mindennapos dolog, de tény, hogy aki konténerekben szeretne programokat futtatni, jobban meg kell ismerkedjen a Linuxszal, a processzek közti kommunikációval, signalok kezelésével és azzal, hogy ezeket hogyan befolyásolják a Dockerfile-ban definiált SHELL, ENTRPYOINT és CMD direktívák. Ha te is szeretnéd elkerülni az említett problémát, vagy csak régóta töprengsz azon, mi történik a Docker konténerek leállításakor, akkor ez a cikk neked készült.

Tartalomjegyzék

Megjegyzés Ha még nem láttad a shell signal-ok működésével és a konténeren belüli parancs összeállításával kapcsolatos cikkeket/videókat, akkor érdemes azokkal kezdeni.

A cikkben használt forráskódokat megtalálod a GitHub-on:
https://github.com/itsziget/tutorial-linux-signals/tree/step-03

Stop timeout

[Tartalom]

Nézzünk egy teljesen egyszerű példát, amikor Docker konténerben futtatok egy HTTP szervert pythonnal, majd megpróbálom leállítani a konténert. A leállítás parancsot a “time” parancsnak adom át, hogy lássam, pontosan mennyi ideig tart.

docker run -d --rm --name test python:3.8 python3 -m http.server 8080
time docker stop test

Ahelyett, hogy a konténer gyorsan leállna, várni kell 10 másodpercet. A docker stop --help parancsot futtatva láthatjuk, hogy van egy --time paraméter, aminek az alapértelmezett értéke 10 másodperc. Ezért tartott ennyi ideig a leállítás, mivel a HTTP szerver nem volt hajlandó magától leállni, így a Docker erőszakkal állította le. Ez annyit jelent, hogy ha a konténerben futó programnak 11 másodpercig tartott volna szabályosan leállni, erre nem volt lehetősége, viszont a leállításkor felülírható a 10 másodperces timeout. Ha pedig az indításkor tudjuk, hogy mennyi időre van szüksége a programnak általában a leállításhoz, már a docker run-nak is megadható a --stop-timeout paraméter.

docker run -d --rm --name test --stop-timeout 15 python:3.8 python3 -m http.server 8080
time docker stop --time 3 test

Signal küldése

[Tartalom]

Valójában, ha a docker run parancs paraméterei között a "stop"-ra keresünk, akkor találhatunk még egy fontos paramétert:

docker run --help | grep stop
#   --stop-signal string             Signal to stop a container (default "SIGTERM")
#   --stop-timeout int               Timeout (in seconds) to stop a container

A --stop-signal tehát azt határozná meg, hogy milyen signalt küldjön a konténerben futó programnak a Docker, amikor a "stop" parancsot kiadjuk. Ez alapértelmezetten a TERM vagy SIGTERM nevű signal. Ezt pedig már bemutattam korábban, tehát akkor nézzük meg, hogy hajlandó-e leállni a HTTP szerver ennek a signalnak a hatására konténer nélkül vagy sem.

python3 -m http.server 8080

Egy új terminálban pedig lekérdezzük a processz azonosítóját és a TERM signal-lal leállítjuk

pid=$(ps ax -o pid,command | grep "[p]ython3 -m http\.server 8080" | cut -d " " -f1)
kill -s TERM $pid

Erre viszont láthatóan azonnal leállt a szerver, tehát akkor mi a különbség?

1-es processzazonosító

[Tartalom]

Indítsuk el újra a Dockeres verziót és nézzünk be a konténerbe. A következő kódban a COLUMNS változót is átadjuk a konténernek, így a hoszt rendszer termináljának szélességét veszi figyelembe a konténerben futó "ps" a kimenet megjelenítésekor.

docker run -d --rm --name test python:3.8 python3 -m http.server 8080
docker exec -it -e COLUMNS="$(tput cols)" test ps x -o pid,command
#  PID COMMAND
#    1 python3 -m http.server 8080
#    8 ps x -o pid,command
time docker stop test

A HTTP szerver az 1-es azonosítót kapta. Linuxon az 1-es piddel rendelkező processz egyfajta mindenható a saját kis univerzumában, ami jelen esetben a konténer. Nem lehet csak úgy leállítani, hacsak közvetlenül le nem kezeli a TERM signalt. Ezt pedig a Pythonból futtatott HTTP szerver nem teszi meg, így erőszakkal kellett leállítani a KILL signallal.

Mi lehet akkor a megoldás?

  • Vagy le kell kezelnünk a saját programunkban a signalokat
  • Vagy el kell érnünk, hogy a program ne az 1-es azonosítót kapja, de mégis megkapja a „docker stop” által küldött signalt is.

PID névtér elhagyása

[Tartalom]

A konténerben a processz (PID) névtér miatt van, hogy 1-essel kezdődik újra a processzeknek a számozása és a gépen levő többi processzt nem is láthatjuk a konténerből. Kérhetjük viszont a hoszt rendszer PID névterét, bár ezzel elveszítünk egy fajta izolációt.

docker run -d --rm --name test --pid host python:3.8 python3 -m http.server 8080
docker exec -it -e COLUMNS="$(tput cols)" test ps x -o pid,command
time docker stop test

Emiatt a http szerverünk nem lesz még a konténeren belül sem 1-es azonosítójú, tehát működik az alapértelmezett action a TERM signalra.

Init processz használata

[Tartalom]

A hoszt rendszer processz névterének használata nem lehet mindig megoldás, hiszen nem véletlenül szeretnénk a hosztot elrejteni a konténerekben futó programok elől. Nyilván egy általunk írt programnál le tudjuk kezelni a signalokat, de már kész binárisokat nem fogunk módosítani. Marad tehát, hogy egy olyan processzt indítunk, ami az 1-es azonosítót kaphatja, és elindítja a signalokat nem kezelő programot egy 1-nél nagyobb pid-del. Erre pedig vannak kész megoldások.

Tini program használata

[Tartalom]

Először is szükségünk lesz egy Dockerfile-ra mondjuk a „docker/tini” mappában.

FROM python:3.8

RUN apt-get update \
 && apt-get install -y --no-install-recommends tini

ENTRYPOINT [ "tini", "--" ]

CMD ["python3", "-m", "http.server", "8080"]

Ezzel telepítjük a „tini” programot és beállítjuk entrypoint-nak azért, hogy akár a CMD direktívában megadott, akár a „docker run” -nak átadott utasítás a tininek legyen átadva paraméterként. De nem muszáj így írni, akár a CMD-nek is átadhatnánk együtt az egész parancsot. Ezután pedig nincs más hátra, mint buildelni az image-et, futtatni és megnézni, mik futnak a konténerben.

cd docker/tini
docker build -t localhost/http-server .
docker run -d --rm --name test localhost/http-server
docker exec -it -e COLUMNS="$(tput cols)" test ps x -o pid,command
#  PID COMMAND
#    1 tini -- python3 -m http.server 8080
#    7 python3 -m http.server 8080
#    8 ps x -o pid,command
time docker stop test

A tini program megkapja az 1-es azonosítót, de el is kapja a signalokat és továbbítja a htttp szerver felé, ami már leállhat az alapértelmezett action hatására.

Init flag használata

[Tartalom]

Valójában még a tini programot sem kell telepíteni a konténerben. A Docker 1.13-as verziója óta, tehát 2017-től létezik a „docker run”-hoz az --init flag, ami gondoskodik a tini elindításáról.

docker run -d --rm --name test --init python:3.8 python3 -m http.server 8080
docker exec -it -e COLUMNS="$(tput cols)" test ps x -o pid,command
#  PID COMMAND
#    1 /sbin/docker-init -- python3 -m http.server 8080
#    8 python3 -m http.server 8080
#  212 ps x -o pid,command
time docker stop test

Itt viszont már „docker-init” néven szerepel a tini.

A HTTPD szerver kezeli a signalokat

[Tartalom]

Vannak olyan programok, amiknél nem kell trükköznünk, mert alapból kezelik a signalokat. Ilyen például a httpd. A következő kód pedig megmutatja, hogy bár 1-es azonosítót kapott, mégis gyorsan leállt. A HTTPD dokumentációja pontosan leírja, melyik signal milyen célt szolgál.

docker run -d --rm --name test httpd:2.4
docker exec -it -e COLUMNS="$(tput cols)" test bash -c 'apt-get update && apt-get install -y --no-install-recommends procps && ps x -o pid,command'
#  PID COMMAND
#    1 httpd -DFOREGROUND
#   93 ps x -o pid,command
time docker stop test

HTTPD wrapper szkript

[Tartalom]

Előfordulhat viszont viszont, hogy szeretnénk a httpd indítása előtt elvégezni pár módosítást, esetleg konfigurációs fájl beállítást, vagy jelen példában egyszerűen csak az index.html lecserélését. Erre is van megoldás, de sajnos el is lehet rontani egy már jól működő image-et. Mutatok pár példát jó és rossz megoldásra is. A Dockerfile a következő lesz a "docker/httpd" mappában:

FROM httpd:2.4

RUN apt-get update \
 && apt-get install -y --no-install-recommends procps

ARG VERSION
ARG STOPSIGNAL=SIGWINCH

COPY start-${VERSION?}.sh /start.sh
RUN chmod +x /start.sh

STOPSIGNAL ${STOPSIGNAL}

CMD ["/start.sh"]

  • A procps csomag telepítésével lesz "ps" parancs a konténerben, amivel nézegethetjük a processzek listáját.
  • Az ARG direktívával a docker build-nek adhatunk át paramétereket, amivel különböző image-eket tudunk generálni.
  • A VERSION alapján a "start-wrong.sh", "start-exec.sh" vagy "start-trap.sh" lesz felmásolv az image-be.
  • A STOPSIGNAL direktívának az azonos nevű build agumentum változóját adjuk át, ezzel a "docker run"-nál is megadtahtó stop signalt előre definiálva. Ez már az eredeti image-ben is benne van fix értékkel, de szükségünk lesz majd később a változtatására

A "test.sh" szkript, ami a konténerek törlésében, buildelésében és indításában segít, a következőképpen néz ki:

#!/bin/bash

set -eu

VERSION=$1
STOPSIGNAL=$2

root="$(cd "$(dirname "$0")" && pwd)/../.."

docker rm -f test 2>/dev/null
docker build -t localhost/httpd:$VERSION \
 --build-arg VERSION=$VERSION \
 --build-arg STOPSIGNAL=SIG$STOPSIGNAL \
 .
docker run \
   --name test \
   -v $root/tmp:/usr/local/apache2/htdocs/downloads \
   localhost/httpd:$VERSION

Itt tehát a signal nevében a "SIG" prefix elhagyható lesz a második paraméterben, az első paraméter pedig a felmásolandó script nevének vége (wrong, exec vagy trap).

HTTPD szerver shell szkriptbe ágyazva hibásan

[Tartalom]

Lehetne egy ilyen start szkriptet írni "start-wrong.sh" néven:

#!/bin/bash

echo "HELLO" > "/usr/local/apache2/htdocs/index.html"
httpd -D FOREGROUND

Semmi mást nem tesz, mind módosítja az index.html tartalmát, majd elindítja a HTTPD szervert. A baj csak az, hogy a signalokat a "docker stop"-tól a bash szkript kapja meg, az viszont nem kapja el őket és így nem is továbbítja a HTTPD felé. Ez ki is derül, amint indítjuk a konténert a test.sh szkripttel:

./test.sh wrong WINCH
docker exec -it -e COLUMNS="$(tput cols)" test ps x -o pid,command
#  PID COMMAND
#    1 /bin/bash /start.sh
#    8 httpd -D FOREGROUND
#   93 ps x -o pid,command
time docker stop test

Így ismét 10 másodperc alatt áll le a szerver, mivel az 1-es számú processz a bash szkript, tehát ez tényleg egy rossz megoldás volt, de jöjjenek a jók!

HTTPD szerver shell szkriptbe ágyazva exec-kel

[Tartalom]

Az előző problémára megoldás a bash-ban az „exec” utasítás használata. Az új, „start-exec.sh” nevű start szkript-ben a httpd indítását tehát "exec" utasítással kell kezdeni a "start-exec.sh" szkriptben:

#!/bin/bash

echo "HELLO" > "/usr/local/apache2/htdocs/index.html"
exec httpd -D FOREGROUND

Ennek hatására a httpd át fogja venni a szülő processz helyét az 1-es azonosítóval. A következő parancsokkal indítva a konténert, majd a processzeket listázva látható is, hogy a "bash" szkriptnek már nyoma sincs, így a "httpd" a saját módszerével kezelheti a signalokat:

./test.sh exec WINCH
docker exec -it -e COLUMNS="$(tput cols)" test ps x -o pid,command
#  PID COMMAND
#    1 httpd -D FOREGROUND
#   91 ps x -o pid,command
time docker stop test

Ahogy pedig az várható volt, a konténer rövid időn belül leállt.

HTTPD shell szkriptbe ágyazva trap-pel

[Tartalom]

Igazából az exec utasítás használata már megoldotta minden problémánkat, amennyiben az execnek átadott program kezeli a signalokat, de ha nem ez a helyzet és nem elég az --init flag mert indításkori módosításra is szükség van, vagy egynél több programot szeretnél a háttérben indítani, akkor jó tudni, hogy exec hiányban hogyan működik a wrapper szkript. Igaz, a több processz indítását érdemes elkerülni, de ha mégsem lehet, akkor is használható a "supervisord".

A HTTPD ugyan kezeli a signalokat, de ha nem ő az 1-es processz, akkor továbbítani kell neki minden signalt. Ehhez nem elég a trap és a kill parancs használata, ahogyan korábban bemutattam. Nézzük a problémákat.

  • A HTTPD docker konténer nem a TERM, hanem a WINCH signalra áll le, amit a HTTPD arra használ, hogy a leállás előtt megvárja az éppen folyamatban levő HTTP kérések befejezését, például egy nagy fájl letöltését.
  • Az init szkriptünket életben kell tartani, különben az egész konténer leáll
  • Az életben tartáshoz a ciklusban futó sleep helyett a beépített wait parancsot használjuk, ami várakozik a háttérben futó processzekre anélkül, hogy akadályozná, késleltetné a signalok kezelését.
  • A wait parancs viszont akkor is leáll, ha egy trap-pel kezelt signalt küldünk, ezért ezt kezelni kell és egy újabb wait-et indítani.
  • A WINCH signal az esetünkben a fentiek mellett is (amennyire én tudom) csak akkor lesz kezelve, ha a kérdéses processz már terminált, ami nyilván nem segít, ezért a WINCH signalt egy másikra kell cserélni a Dockerile STOPSIGNAL direktívájában, majd átalakítani WINCH-re.
#!/bin/bash

set -m

echo "HELLO" > "/usr/local/apache2/htdocs/index.html"

httpd -D FOREGROUND & pid=$!

for signal in TERM USR1 HUP; do
  trap "kill -s $signal $pid" $signal
done

# USR2 converted to WINCH
trap "kill -s WINCH $pid" USR2

status=999
while true; do
  if (( $status <= 128 )); then
    # Status codes larger than 128 indicates a trapped signal terminated the wait command (128 + SIGNAL).
    # In any other case we can stop the loop.
    break
  fi
  wait -f
  status=$?
  echo exit status: $status
done

További releváns parancsok

[Tartalom]

A "docker stop" mellett létezik egy "docker restart" parancs is. Bár a HTTPD dokumentációban olvasható, hogy milyen signallal lehet újraindítani, a Docker újraindítás alatt a leállítást és az újboli elindítást érti. Ez sok esetben egyenértékű művelet lehet, de amennyiben nem célod a konténert leállítani, mert például egy másk terminálból is be vagy lépve és folymatban van egy művelet, akkor a "restart" helyett érdemes inkább a megfelelő signalokat elküldeni a konténeren belüli processznek.

Szükség esetén bármilyen signalt lehet küldeni a Dockerrel is. Ehhez viszont a "docker kill" parancsot kell használni. A következő példában a TERM signalt küldöm a "test" nevű konténernek függetlenül attól, hogy mi az alapértelmezett stop signal.

docker kill -s TERM test

Természetesen a Docker sem fog önmagában 100%-os rendelkezésreállást biztosítani, bármit is hallottál róla. Attól, hogy a konténer nem áll le, a folyamatban levő kérések megszakadhatnak, ezért ismerni kell a használt szoftvereket és továbbra is mérlegelni kell mikor és milyen parancsokat érdemes kiadni.

Végszó

[Tartalom]

Több megoldás is van tehát a Docker konténerek helyes leíllítására, de minden esetben azt kell szem előtt tartani, hogy milyen igényei vannak a szoftvernek, amit konténerizálunk vagy éppen kész image-ből kiegészítünk és biztosítanunk kell a signalok megfelelő útját. Enélkül akár kárt is okozhatunk magunknak vagy egy ügyfélnek. Mielőtt élesben használsz egy Docker image-et, érdemes teszt környezetben letesztelni, stabilan működik-e a leállítás. A saját kiegészítéseket pedig szintén alaposan át kell gondolni, mielőtt a Dockert vagy a konténerizált alkalmazást okolod.

Végezetül hadd mutassak egy fotót magamról tartalom előkészítés és videókészítés közben, ahogy 162-edszerre is belebakizok a bemutatóba vagy elromlik valami a környezetemben:

villanykörtében villámlás a pixabay.com-ról PIRO4D felhasználótól

Ha hasznosnak találod a tartalmakat, kérlek, jelezd egy like-kal, vagy kommenttel, hogy tudjam, az eredmény minden fáradságot megér.

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