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.