Tutorial: Utwardzanie pliku Docker Compose do pracy bez nadzoru na Raspberry Pi

Spis treści
Plik Compose uruchamiający stos pisze się w dziesięć minut. Plik Compose utrzymujący ten stos przy życiu przez rok potrzebuje około sześciu linii więcej na usługę – a każda z tych linii istnieje z powodu określonego sposobu, w jaki rzeczy się przewracają.
Poniżej przejście po nich w kolejności, w jakiej zwykle stają się potrzebne – co mniej więcej odpowiada kolejności, w jakiej natrafia na nie maszyna pozostawiona sama sobie.

Linia rozstrzygająca o powrocie
Kontener, który się zatrzymał, pozostaje zatrzymany. Po przerwie w zasilaniu, po awarii, po restarcie usługi – dopóki żadna reguła restartu nie mówi inaczej.
services:
app:
restart: unless-stopped
Trzy wartości warto rozróżnić. no jest domyślne i oznacza, że nic nie wraca. always uruchamia kontener ponownie w każdym przypadku, także po zatrzymaniu go ręcznie i późniejszym restarcie maszyny – i tak kontener celowo wyłączony z użycia pojawia się o trzeciej nad ranem. unless-stopped robi to samo z wyjątkiem właśnie tego przypadku i jest właściwym ustawieniem domyślnym dla maszyny, której nikt nie obserwuje.
Dwa szczegóły stojące za tym. Docker odczekuje między próbami restartu coraz dłużej, podwajając od 100 milisekund do minuty – kontener w pętli awarii nie kładzie więc maszyny, ale też nie przestaje próbować, a nic na to nie wskazuje. I reguła restartu działa wyłącznie przy kontenerze, który się kończy. Proces zawieszony przy działającym kontenerze to dokładnie przypadek następnego rozdziału.
Badanie kondycji, które coś bada
Zawieszona aplikacja jest gorsza od takiej, która padła, bo wszystko wokół uważa ją za działającą. Kontener działa, port jest otwarty, a żądania idą w próżnię.
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
Ostatniej linii brakuje najczęściej i to ona wywołuje najwięcej zamieszania. W czasie start_period nieudane badanie nie liczy się do retries. Bez niej usługa potrzebująca czterdziestu sekund na wstanie zostaje po drodze uznana za chorą i zrestartowana – bez końca.
To, co badanie bada, waży więcej niż samo jego istnienie. Żądanie na / dowodzi, że serwer WWW odpowiada, a robi to również wtedy, gdy stojące za nim połączenie z bazą zniknęło godzinę temu. Punkt końcowy dotykający części podatnych na awarię warto napisać właśnie w tym celu – i powinien odpowiadać szybko, bo biegnie co trzydzieści sekund przez cały czas pracy maszyny.
Badanie kondycji umożliwia ponadto wypowiedź o kolejności, której sam depends_on nie stawia. Bez warunku Compose uruchamia zależność i natychmiast idzie dalej; z warunkiem czeka.
app:
depends_on:
db:
condition: service_healthy
Skutek dla szczupłych obrazów: curl często nie jest zawarty, a badanie, którego nie da się wykonać, liczy się jako nieudane. Albo badanie używa czegoś, co w obrazie jest – wget -q --spider albo polecenia samej aplikacji – albo obraz dostaje pakiet, którego inaczej by nie potrzebował.
Dzienniki, które nie zapełnią karty
Domyślny sterownik dzienników pisze do pliku JSON rosnącego bez ograniczeń. Na serwerze z dużym dyskiem jest to nieporządek; na Pi z kartą pamięci jest to przyczyna awarii, bo pełny system plików kładzie całą maszynę, a nie tylko gadatliwy kontener.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Trzydzieści megabajtów na usługę wystarcza z zapasem dla wszystkiego, co nie jest właśnie badane. Ustawienie należy do każdej usługi, a nie tylko do tej gadatliwej, bo gadatliwą bywa zwykle ta usługa, która kiedyś zaczyna wypisywać ślad błędu co sekundę.
Ten sam limit da się ustawić raz dla całej maszyny w /etc/docker/daemon.json, i to lepsze dla niego miejsce – obowiązuje jednak tylko dla kontenerów utworzonych później, istniejący stos trzeba więc utworzyć na nowo, aby zadziałał.
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Tam, gdzie problemem jest nie miejsce, lecz same zapisy, karta pamięci zyskuje na wyprowadzeniu dzienników poza system plików – driver: journald przekazuje je do systemd, któremu da się kazać trzymać je w pamięci operacyjnej.
Limity pamięci i pułapka właściwa dla Pi
Maszyna z czterema gigabajtami i bez pliku wymiany nie ma zapasu na wyciek. To, który proces ginie przy braku pamięci, rozstrzyga jądro – i regularnie nie jest to proces winny.
deploy:
resources:
limits:
memory: 512M
cpus: "1.5"
Limit zamienia awarię całej maszyny w awarię jednego kontenera: winny kontener zostaje zakończony i uruchomiony ponownie przez własną regułę restartu, a reszta działa dalej. To wynik znacznie lepszy, a limit opłaca się także wtedy, gdy liczba jest zgadywana – hojny limit wciąż ogranicza szkodę.
A teraz część dotycząca wyłącznie Raspberry Pi. W Raspberry Pi OS grupa kontrolna pamięci jest domyślnie wyłączona, a limit pamięci w pliku Compose zostaje wtedy przyjęty bez skargi i pozostaje bez skutku. Ostrzeżenie pojawia się raz przy starcie usługi i nigdy więcej.
# dopisac do jedynej linii w /boot/firmware/cmdline.txt, potem restart
cgroup_enable=memory cgroup_memory=1
# potem ta linia musi zniknac:
docker info 2>&1 | grep -i "no memory limit support"
Plik zawiera dokładnie jedną linię, a znak końca linii dopisany przez edytor czyni maszynę niezdolną do startu – dopisek należy więc na koniec istniejącej linii, oddzielony spacją.
Przypięcie obrazu, by restart nie był aktualizacją
Tag jest wędrującym wskaźnikiem. postgres:16 oznacza w tym miesiącu co innego niż w poprzednim, a chwili zmiany nikt nie wybiera – jest nią moment, w którym kontener przypadkiem powstaje na nowo.
image: postgres:16.4@sha256:f8cd4e0b9b0d15b6bbbcbbdf8c5f8c8b6c6b3e9b7cbb4f2d1e3a5c7d9f0b2a46
Digest jest niezmienny, przez co z „ponownego utworzenia kontenera” staje się czynność o znanym wyniku. Tag zostaje przed nim jako dokumentacja – sam digest jest nieczytelny, a plik pełen sum kontrolnych nikomu nie mówi, co działa.
Digest używanego już obrazu jest o jedno polecenie stąd.
docker image inspect postgres:16.4 --format '{{index .RepoDigests 0}}'
Ostrzeżenie co do połączenia z automatycznymi aktualizacjami. Narzędzie planowo pobierające nowe obrazy i przypięty digest to przeciwieństwa – jedno i drugie naraz oznacza, że digest po cichu wygrywa, podczas gdy narzędzie aktualizujące melduje sukces. Albo wersje wędrują według planu, albo wędrują, gdy ktoś tak postanowi; oba rozwiązania da się obronić, posiadanie jednego z nich przez przypadek już nie.
Do tego samego bloku należą jeszcze dwie linie i nic nie kosztują. Kontener nigdy niepiszący do własnego systemu plików może to powiedzieć, a temu, który nie musi stawać się użytkownikiem uprzywilejowanym, da się oznajmić, że nigdy nim nie będzie.
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
user: "1000:1000"
Sprawdzenie pliku przed uruchomieniem
Compose rozwija zmienne, scala pliki uzupełniające i wstawia wartości domyślne – a wynik często nie jest tym, co plik zdaje się mówić. Jedno polecenie wypisuje to, co faktycznie zostanie uruchomione.
docker compose config
W tym wyniku warto szukać trzech rzeczy. Pustej wartości tam, gdzie powinna stać zmienna – nieustawiona zmienna staje się bez komunikatu pustym ciągiem znaków, a brakujące hasło czyta się często jako brak hasła. Obrazu bez digestu. Oraz usługi bez restart, bez healthcheck albo bez logging, co oko w pliku z ośmioma usługami łatwo przeoczy.
To ostatnie da się sprawdzić maszynowo, i to się opłaca, bo jest to dokładnie ten rodzaj pominięcia, który powstaje przy szybkim dodaniu usługi.
docker compose config --format json | python3 -c '
import json, sys
stack = json.load(sys.stdin)
for name, svc in stack["services"].items():
fehlt = [f for f in ("restart", "healthcheck", "logging") if f not in svc]
if "@sha256:" not in svc.get("image", ""):
fehlt.append("digest")
print(f"{name:20s} {\", \".join(fehlt) if fehlt else \"ok\"}")
'
Po pierwszym przebiegu do rutyny należy ostatnie sprawdzenie i nie potrzebuje żadnego narzędzia: celowo zrestartować maszynę i zobaczyć, co wraca. Stos, który nigdy nie przeżył restartu, nie jest sprawdzony – a poranek po przerwie w zasilaniu nie jest porą, by się o tym dowiadywać.