Migracja Hugo z nginx:alpine na Red Hat Hardened nginx

W poprzednim wpisie opisałem, czym jest dla mnie podział na build i runtime w kontenerach. Na przykładzie Hugo bardzo dobrze widać, że generator strony jest potrzebny tylko na etapie budowania, a finalny kontener powinien zawierać już tylko gotowe pliki i serwer HTTP.

Skoro miałem już taki podział, postanowiłem pójść krok dalej i podmienić runtime mojej strony z klasycznego nginx:alpine na registry.access.redhat.com/hi/nginx:latest, czyli Red Hat Hardened nginx.

Brzmiało prosto. I w sumie było proste, ale po drodze pojawił się klasyczny szczegół: port 8080.

Punkt wyjścia

Moja strona działa jako statyczna strona zbudowana w Hugo. Pipeline buduje obraz kontenera, a następnie uruchamia go na Raspberry Pi. Całość jest wystawiona przez Traefika.

Wcześniejszy układ był prosty:

Hugo builder → public/ → nginx:alpine → port 80

Finalny etap Dockerfile/Containerfile wyglądał mniej więcej tak:

FROM nginx:alpine

COPY --from=builder /src/public/ /usr/share/nginx/html/

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

To działało poprawnie. Nginx słuchał na porcie 80, a w deployu miałem mapowanie portów w stylu:

-p ${PORT:-8080}:80

Czyli port na hoście był zależny od środowiska, ale wewnątrz kontenera ruch zawsze trafiał na port 80.

Zmiana runtime

Red Hat Hardened nginx pozwala serwować statyczne pliki z katalogu:

/usr/share/nginx/html/

Czyli dla Hugo wystarczy skopiować wygenerowany katalog public/ do tego miejsca.

Finalny etap zmieniłem na:

FROM registry.access.redhat.com/hi/nginx:latest

COPY --from=builder /src/public/ /usr/share/nginx/html/

EXPOSE 8080

Ważna różnica: ten obraz słucha na porcie 8080, a nie na 80.

To oznacza, że sama zmiana FROM nie wystarczy. Trzeba jeszcze poprawić wszystkie miejsca, które zakładały, że nginx w kontenerze działa na porcie 80.

Pełniejszy przykład Containerfile

W moim przypadku builder nadal może bazować na Alpine. To tam instaluję Hugo i buduję stronę. Runtime jest już oparty o Red Hat Hardened nginx.

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

FROM registry.access.redhat.com/hi/nginx:latest

COPY --from=builder /src/public/ /usr/share/nginx/html/

EXPOSE 8080

Warto zauważyć jedną rzecz: w finalnym obrazie nie ustawiam już ręcznie komendy startowej nginx. Korzystam z tego, co dostarcza obraz bazowy.

Nie instaluję też tzdata, nie używam apk i nie próbuję niczego dokładać do runtime. Ten kontener ma tylko serwować statyczne pliki.

Poprawka w deployu

W GitLab CI miałem fragment odpowiedzialny za uruchomienie kontenera:

deploy:
  stage: deploy-frontend
  needs: ["build", "build-backend"]
  script:
    - docker rm -f logos-hugo-$CI_COMMIT_REF_SLUG || true
    - docker run -d --restart unless-stopped --network hugo-net --name logos-hugo-$CI_COMMIT_REF_SLUG -p ${PORT:-8080}:80 ${PROJECT_NAME}:${CI_COMMIT_REF_SLUG}
    - docker network connect traefik "logos-hugo-$CI_COMMIT_REF_SLUG" || true
  rules:
    - if: '$CI_COMMIT_BRANCH == "dev"'
      variables:
        PORT: "1313"
    - if: '$CI_COMMIT_BRANCH == "itg"'
      variables:
        PORT: "8080"
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables:
        PORT: "80"

Po zmianie runtime na Red Hat Hardened nginx musiałem zmienić mapowanie portów z:

-p ${PORT:-8080}:80

na:

-p ${PORT:-8080}:8080

Czyli poprawiona wersja wygląda tak:

deploy:
  stage: deploy-frontend
  needs: ["build", "build-backend"]
  script:
    - docker rm -f logos-hugo-$CI_COMMIT_REF_SLUG || true
    - docker run -d --restart unless-stopped --network hugo-net --name logos-hugo-$CI_COMMIT_REF_SLUG -p ${PORT:-8080}:8080 ${PROJECT_NAME}:${CI_COMMIT_REF_SLUG}
    - docker network connect traefik "logos-hugo-$CI_COMMIT_REF_SLUG" || true
  rules:
    - if: '$CI_COMMIT_BRANCH == "dev"'
      variables:
        PORT: "1313"
    - if: '$CI_COMMIT_BRANCH == "itg"'
      variables:
        PORT: "8080"
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables:
        PORT: "80"

W praktyce daje to:

dev   → host 1313 → kontener 8080
itg   → host 8080 → kontener 8080
main  → host 80   → kontener 8080

Bad Gateway w Traefiku

Po zmianie kontener działał, ale na środowisku ITG pojawił się Bad Gateway.

To był bardzo dobry moment, żeby docenić środowisko integracyjne. Produkcja została nietknięta, a problem wyszedł tam, gdzie powinien.

Szybkie sprawdzenie konfiguracji Traefika pokazało, że w dynamicznym configu dalej był zaszyty port 80:

url: "http://logos-hugo-itg:80"

A ponieważ Traefik gada z kontenerem po sieci Dockerowej, to nie interesuje go mapowanie portów na hoście. On musi trafić bezpośrednio w port, na którym usługa słucha wewnątrz kontenera.

Po zmianie runtime powinno być:

url: "http://logos-hugo-itg:8080"

Analogicznie dla produkcji:

url: "http://logos-hugo-main:8080"

To jest ważne rozróżnienie:

-p 1313:8080

dotyczy ruchu z hosta do kontenera.

Natomiast Traefik używa adresu kontenera i jego portu wewnętrznego:

http://logos-hugo-itg:8080

Po poprawieniu portu w konfiguracji Traefika i restarcie proxy ITG zaczęło działać poprawnie.

Testy po migracji

Po zmianach sprawdziłem podstawowe rzeczy:

docker ps

W kontenerach było widać już nowy układ portów:

logos-hugo-dev    8080/tcp, 0.0.0.0:1313->8080/tcp

Do tego standardowy test HTTP:

curl -I http://localhost:1313/

oraz test przez Traefika dla środowiska ITG.

Sprawdziłem też formularz kontaktowy oraz wygląd strony na smartfonie. Formularz działał, a na mobile wszystko wyglądało tak jak wcześniej.

To ważne, bo przy takich zmianach łatwo skupić się tylko na kontenerach i portach, a zapomnieć, że finalnie ma działać cała strona, nie tylko sam nginx.

Co właściwie się zmieniło?

Przed migracją:

builder:
- Alpine
- Hugo
- curl
- tar
- źródła strony

runtime:
- nginx:alpine
- port 80
- gotowe pliki strony

Po migracji:

builder:
- Alpine
- Hugo
- curl
- tar
- źródła strony

runtime:
- Red Hat Hardened nginx
- port 8080
- gotowe pliki strony

Najważniejsza zmiana jest w runtime. Finalny kontener jest bardziej minimalistyczny i bliższy zasadzie: ma zawierać tylko to, co jest potrzebne do działania usługi.

Hugo nadal zostaje w builderze. Do runtime trafia tylko katalog public/.

Wnioski

Ta migracja była mała, ale bardzo pouczająca.

Po pierwsze, podział na build i runtime naprawdę ma sens. Hugo jest narzędziem do budowania strony, a nie elementem potrzebnym do jej działania.

Po drugie, zmiana obrazu bazowego potrafi mieć konsekwencje w kilku miejscach naraz. W tym przypadku najważniejszą zmianą był port 8080.

Po trzecie, trzeba pamiętać, że czym innym jest mapowanie portu na hoście, a czym innym port usługi widziany przez Traefika w sieci kontenerowej.

Po czwarte, warto mieć środowisko ITG. To właśnie tam wyszedł Bad Gateway, a produkcja mogła spokojnie poczekać.

Całość sprowadziła się do kilku praktycznych zmian:

1. Zmiana finalnego obrazu na registry.access.redhat.com/hi/nginx:latest
2. Skopiowanie /src/public/ do /usr/share/nginx/html/
3. Zmiana portu kontenera z 80 na 8080
4. Poprawienie docker run w GitLab CI
5. Poprawienie service URL w Traefiku
6. Testy na ITG przed produkcją

Efekt końcowy: moja statyczna strona Hugo działa na Red Hat Hardened nginx, formularz kontaktowy nadal działa, mobile wygląda poprawnie, a ja mam kolejną praktyczną lekcję z kontenerów.

Zero slajdów. Sama praktyka.