Build vs runtime w kontenerach — zrozumiałem to na przykładzie Hugo
Od jakiegoś czasu moja strona działa w kontenerze. Nie jest to żaden wielki system, tylko statyczna strona zbudowana w Hugo i serwowana przez Nginx. Prosty układ: Hugo generuje HTML, CSS i resztę plików, a Nginx wystawia je na świat.
Niby nic specjalnego, ale przy okazji dłubania przy obrazach kontenerowych zacząłem lepiej rozumieć bardzo ważne rozróżnienie: build i runtime.
Wcześniej oczywiście kojarzyłem, że istnieją wieloetapowe Dockerfile/Containerfile. Wiedziałem też, że można coś zbudować w jednym etapie, a potem skopiować wynik do drugiego. Ale dopiero na przykładzie własnej strony w Hugo zobaczyłem, jak praktyczne i sensowne jest takie podejście.
Build, czyli warsztat
Etap builda to dla mnie taki warsztat.
To tutaj potrzebuję narzędzi. W przypadku Hugo potrzebuję samego Hugo, plików źródłowych strony, konfiguracji, motywu, Markdownów i wszystkiego, co jest wymagane do wygenerowania końcowego efektu.
W dużym uproszczeniu wygląda to tak:
Markdown + motyw + config
↓
Hugo
↓
public/
Hugo bierze moje pliki źródłowe i generuje katalog public/. To właśnie w nim znajduje się gotowa strona: HTML, CSS, JavaScript, obrazki i inne statyczne pliki.
Na tym etapie mogę mieć w obrazie dodatkowe narzędzia. Mogę mieć curl, tar, manager pakietów, shella i wszystko, co jest potrzebne do przygotowania strony.
Ale to jest tylko warsztat. On jest potrzebny do zbudowania produktu, a nie do jego późniejszego działania.
Runtime, czyli gotowy produkt
Runtime to już zupełnie inna historia.
Jeżeli Hugo wygenerowało mi katalog public/, to finalny kontener nie potrzebuje już Hugo. Nie potrzebuje też Markdownów, źródeł strony, konfiguracji builda ani narzędzi do pobierania paczek.
Runtime ma tylko jedno zadanie: serwować gotowe pliki.
Czyli w moim przypadku finalny kontener powinien zawierać:
nginx + /usr/share/nginx/html/
I tyle.
Nie musi być tam Hugo. Nie musi być curl. Nie musi być tar. Nie musi być manager pakietów. Nie musi być cała zawartość repozytorium. Nie musi być „warsztatu w salonie”.
To porównanie bardzo mi się spodobało:
build = warsztat z narzędziami
runtime = gotowy produkt
Na produkcji nie trzymam betoniarki, wiertarki i rusztowania. Na produkcji ma stać gotowy dom.
Dlaczego to ma sens?
Im mniej rzeczy w finalnym kontenerze, tym lepiej.
Po pierwsze, obraz jest prostszy. Jest w nim tylko to, co realnie jest potrzebne do działania usługi.
Po drugie, zmniejsza się powierzchnia ataku. Jeżeli w kontenerze nie ma shella, managera pakietów, kompilatorów i przypadkowych narzędzi, to potencjalny atakujący ma mniej możliwości kombinowania.
Po trzecie, mniej pakietów to mniej potencjalnych podatności. Skanery bezpieczeństwa nie muszą krzyczeć o połowie systemu operacyjnego, którego moja statyczna strona i tak nie potrzebuje.
Po czwarte, wymusza to porządek. Nie ma ręcznego poprawiania czegoś w działającym kontenerze. Jeżeli coś ma się zmienić, to zmieniam Dockerfile/Containerfile, buduję nowy obraz i wdrażam go ponownie.
To jest podejście, które coraz bardziej mi odpowiada:
Kontener nie jest małą maszyną wirtualną.
Kontener jest opakowaniem na konkretną usługę.
Przykład z Hugo
Mój układ dla Hugo można sprowadzić do dwóch etapów.
Pierwszy etap buduje stronę:
FROM alpine:3.20 AS builder
ARG ENV=dev
ARG HUGO_VERSION=0.147.5
ARG HUGO_ARCH=linux-arm64
RUN apk --no-cache add curl tar ca-certificates \
&& curl -L -o /tmp/hugo.tar.gz "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz" \
&& tar -xzf /tmp/hugo.tar.gz -C /tmp \
&& mv /tmp/hugo /usr/local/bin/hugo \
&& chmod +x /usr/local/bin/hugo \
&& rm -rf /tmp/*
WORKDIR /src
COPY . .
RUN if [ ! -f config/${ENV}.toml ]; then \
echo "ERROR: config/${ENV}.toml not found"; \
exit 1; \
fi
RUN hugo --config config/${ENV}.toml --minify
Ten etap może mieć narzędzia. Tutaj instaluję Hugo, kopiuję źródła strony i generuję katalog public/.
Drugi etap jest już runtime:
FROM nginx:alpine
COPY --from=builder /src/public/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
W tym etapie nie interesuje mnie już Hugo. Kopiuję tylko wynik builda, czyli katalog public/.
To jest właśnie sedno multi-stage build:
COPY --from=builder /src/public/ /usr/share/nginx/html/
Finalny obraz nie dziedziczy całego warsztatu. Dostaje tylko to, co jawnie skopiuję z poprzedniego etapu.
Co trafiło do runtime?
W finalnym kontenerze nie potrzebuję:
- Hugo
- plików Markdown
- katalogu .git
- konfiguracji builda
- curl
- tar
- narzędzi do instalowania paczek
Potrzebuję tylko:
- nginx
- wygenerowanych plików z katalogu public/
I to jest bardzo przyjemna zmiana w myśleniu. Zamiast robić kontener jako mały serwer, robię go jako minimalne opakowanie na konkretną usługę.
A co z debugowaniem?
Oczywiście jest też druga strona medalu.
Im bardziej minimalny runtime, tym mniej wygodne debugowanie. W klasycznym obrazie często można wejść do środka:
docker exec -it kontener sh
albo:
docker exec -it kontener bash
W bardziej minimalistycznych obrazach może się okazać, że nie ma ani bash, ani sh, ani curl, ani managera pakietów. I to na początku może boleć.
Ale z drugiej strony — czy produkcyjny kontener naprawdę ma być miejscem, w którym coś ręcznie naprawiam?
Coraz bardziej skłaniam się do odpowiedzi: nie.
Jeżeli potrzebuję debugowania, mogę mieć osobny obraz debugowy, osobny etap buildera albo testować lokalnie na mniej restrykcyjnym obrazie. Natomiast finalny runtime powinien być możliwie prosty i przewidywalny.
Dlaczego akurat teraz mnie to zainteresowało?
Temat wrócił do mnie przy okazji Red Hat Hardened Images. Są to minimalne obrazy kontenerowe, które z założenia mają mieć ograniczoną powierzchnię ataku i zawierać mniej zbędnych elementów.
Przy statycznej stronie w Hugo taki pomysł pasuje idealnie.
Hugo buduje stronę, a finalny kontener tylko ją serwuje. To aż się prosi o podział:
builder → Hugo i źródła strony
runtime → minimalny nginx i gotowy katalog public/
Najpierw dobrze było jednak poukładać sobie w głowie samo rozróżnienie na build i runtime. Bez tego łatwo traktować kontener jak małą maszynę wirtualną, do której wrzuca się wszystko „bo może się przyda”.
Podsumowanie
Najważniejsza lekcja z tego ćwiczenia jest prosta:
Build to etap przygotowania.
Runtime to etap działania.
W przypadku Hugo build potrzebuje generatora strony, źródeł, konfiguracji i narzędzi. Runtime potrzebuje tylko serwera HTTP i gotowych plików.
To podejście daje porządek, mniejszy obraz, mniejszą powierzchnię ataku i lepszą powtarzalność wdrożeń.
A przede wszystkim zmienia sposób myślenia o kontenerach.
Kontener nie musi być małym serwerem z całym warsztatem w środku. Kontener ma uruchomić konkretną usługę i nie pyskować.
W kolejnym wpisie opisuję już konkretną migrację Hugo z nginx:alpine na Red Hat Hardened nginx. Przy okazji pojawił się klasyczny problem z portem 8080, Traefikiem i Bad Gateway.
Jeśli podobny bałagan pojawia się przy aplikacji na VPS-ie, opisałem zakres pomocy na stronie Docker, reverse proxy i wdrożenia aplikacji.
Zero slajdów. Sama praktyka.
