Punkt wyjścia
Aplikacja Next.js z bazą PostgreSQL plus kilkanaście zadań cyklicznych — każde jako osobna, płatna usługa na platformie PaaS. Wygodne na starcie, kosztowne przy skali i nieprzyjemne w diagnostyce: żeby zobaczyć, dlaczego zadanie nie wystartowało, trzeba było przeklikać się przez trzy ekrany panelu.
Co zrobiłem
- Jeden stack Compose na serwerze: aplikacja plus baza, bez własnego reverse proxy — ruch wchodzi przez wspólną bramę, która obsługuje wszystkie aplikacje na tym hoście i sama wystawia certyfikaty.
- Zadania cykliczne w aplikacji. Zamiast kilkunastu płatnych usług — scheduler wewnątrz procesu, który woła własne endpointy po pętli lokalnej, z tokenem, zapisem każdego przebiegu do tabeli i wyłącznikiem per zadanie w panelu. Historia przebiegów jest widoczna tam, gdzie się jej szuka, a nie w logach dostawcy.
- Potok CI/CD: push do gałęzi głównej buduje obraz, wypycha go do rejestru floty, a krok wdrożeniowy po SSH uruchamia migracje bazy jako osobne, jednorazowe zadanie i dopiero potem podmienia kontener aplikacji. Kolejność jest tu istotna: migracje przed podmianą, nigdy w trakcie startu aplikacji.
- Telemetria: logi i metryki idą do wspólnego stosu obserwowalności floty, z alertem, gdy zadanie cykliczne przestanie się zgłaszać.
Pułapki, które po drodze złapałem
Zmienne budowania kontra zmienne uruchomienia. Część konfiguracji Next.js wkompilowuje się w artefakt podczas budowania. Przekazanie jej dopiero przy starcie kontenera daje aplikację, która wygląda poprawnie i ma puste adresy w kodzie klienta. To jest dokładnie ten błąd, który przechodzi przez testy i wychodzi u użytkownika.
Kontener nie może być rootem. Na współdzielonym hoście obraz działający jako root to gotowy wektor ucieczki z kontenera. Każdy obraz dostaje własnego użytkownika, a prawa zapisu tylko do katalogów, które faktycznie są zapisywane w czasie działania.
Routing po unikalnej nazwie, nie po nazwie usługi. Każdy projekt Compose ma usługę o nazwie app. Na wspólnej sieci takie nazwy kolidują, a brama potrafi skierować ruch jednej aplikacji do zupełnie innej. Każda aplikacja wchodzi do sieci pod własnym, unikalnym aliasem i tylko po nim jest routowana.
Zmiany w routingu idą wyłącznie przez skrypt, który waliduje konfigurację, sprawdza unikalność aliasów, przeładowuje bramę, odpytuje wszystkie hosty i wycofuje zmianę, jeśli którykolwiek przestał odpowiadać. Ręczny reload omija każdy z tych kroków — dlatego go nie ma w procedurze.
Efekt
Koszt utrzymania spadł o rząd wielkości względem kilkunastu osobnych usług, wdrożenie trwa minuty i kończy się samo, a diagnostyka zadania cyklicznego to jedno kliknięcie w panelu zamiast szukania po logach dostawcy. Przy okazji całość da się odtworzyć na nowym serwerze z repozytorium i jednego skryptu.