Pracuję obecnie nad projektem który byłby świetnym kandydatem na obraz Dockera. Pojawił się jednak problem aplikowania migracji. Operacja ta najczęściej jest częścią procesu CI/CD i to tam zaleca się jej wykonanie. Musiałem zatem wymyślić obejście i chciałbym się nim z wami podzielić. Poniższa historia bazuje na NextJS 14 oraz Prisma.
Słowem wyjaśnienia dla osób które nie spotkały się z terminem migracji. Podczas wytwarzania oprogramowania często musimy zmieniać strukturę bazy danych, aby ta odpowiadała naszym potrzebom. Przykładowo możemy dodać kolumnę createdAt
i wypełniać ją automatycznie gdy tworzony jest rekord. Taka zmiana wymaga od nas utworzenia kodu SQL który będzie zawierał odpowiednie instrukcje dodające kolumnę do tabeli. Taki plik nazywamy migracją. Biblioteki ORM potrafią generować takie pliki za nas i aplikować je na istniejącej bazie danych. Informacja o zaaplikowanych migracjach zapisywana jest w bazie danych w oddzielnej tabeli. Najczęściej znajdziemy tam nazwę migracji (często jest to nazwa pliku SQL) oraz czasami datę jej aplikacji. Pozwala to mechanizmom migracyjnym na weryfikację które migracje należy zaaplikować. Gdy kolejne wersje naszego oprogramowania nie zmieniają struktury danych to nie aplikuje się żadna nowa migracja, ponieważ wszystkie zostały już zaaplikowane.
No dobrze, zatem musiałem przenieść polecenie aplikujące migracje z procesu CI/CD bezpośrednio do obrazu Dockera. Pojawiło się jednak jedno ale. Polecenia RUN
w Dockerfile
są wykonywane podczas budowania obrazu. Oznacza to, że nasza migracja zostanie zaaplikowana w tym momencie. Do obrazu musiałbym podać adres bazy, a to wyklucza re-używalność tego obrazu. Obraz powinien się zbudować bez jakiejkolwiek konfiguracji. Działanie aplikacji powinno być kontrolowane zmiennymi środowiskowymi dostarczanymi przez osobę korzystającą z niego, a nie w procesie budowania. To tak jakby producent telefonów wprowadzał kontakty w momencie instalowania oprogramowania na telefonie.
Plik Dockerfile
kończy się poleceniem ENTRYPOINT
lub CMD
, a te polecenia z kolei wykonują się w momencie uruchamiania się kontenera wykorzystującego dany obraz. To tutaj powinniśmy zaaplikować migracje ponieważ mamy dostęp do zmiennych środowiskowych podanych przez użytkownika. Jest jednak jedno ale. Mój Dockerfile
zakończony był poleceniem CMD ["node", "server.js"]
. Kluczowe jest aby to proces node
był ostatnim procesem uruchamianym przez polecenie CMD
. W przeciwnym wypadku żądanie zakończenia procesu zostanie zignorowane podczas pracy kontenera (CTRL + C
, lub docker stop container_name
). To jest duży problem, ponieważ każdorazowa aktualizacja obrazu będzie wymagała od nas ręcznego wchodzenia na maszynę i zabijania procesu poleceniem docker kill container_name
. Przy małej skali i kiedy sami kontrolujemy aplikację to nie jest wielki problem, ale jeśli chcę opublikować obraz jako open-source, to nie jest to najlepsze wyjście.
Tak jak wspomniałem wyżej node
musi być ostatnim poleceniem, co w praktyce oznacza, że polecenia poprzedzające mogą być dowolne. Musiałem zatem wprowadzić małą modyfikację. Po pierwsze w głównym katalogu stworzyłem sobie plik init.sh
, który będzie moim miejscem na dodawanie kolejnych poleceń w miarę potrzeb. Następnie ten plik powinien być skopiowany do ostatecznego obrazu (korzystam z multi-stage-build, oraz standalone output, aby zredukować rozmiar obrazu. Pierwszy stage
kopiuje wszystkie pliki z repozytorium, instaluje paczki, buduje aplikację NextJS, a następnie wrzuca pliki standalone
do .next/standalone
). Musimy też nadać mu prawa do wykonywania, ponieważ bez tego skrypt nie zostanie wykonany.
COPY --from=builder --chown=nextjs:nodejs /app/init.sh ./
RUN chmod +x ./init.sh
Prisma powinna być zgodnie ze sztuką dodana w sekcji devDependencies
, lecz to zalecenie w tym przypadku zignorowałem, ponieważ tej paczki potrzebuję w ostatecznym obrazie. Pamiętajmy że NODE_ENV
ustawione na production
pominie paczki z devDependencies
, a w tym przypadku tak by było. Dodatkowo nie kopiowałem do ostatecznego obrazu katalogu prisma
, a ten zawiera wygnerowane migracje. Musiałem zatem dodać ten katalog, skopiować package-lock.json
którego standalone
output nie kopiuje oraz zainstalować paczki.
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./
COPY package-lock.json ./
RUN npm ci
Teraz nasz obraz jest gotowy do zbudowania. Pozostało wypełnić plik init.sh
kodem który ma być wykonany w momencie uruchamiania się kontenera.
npx prisma migrate deploy
Ten kod się wykona, ponieważ posiada katalog prisma
zawierający migracje oraz paczka Prisma jest zainstalowana w obrazie. Co jednak jak operacja ta się nie powiedzie? Nic. Kontener nie wystartuje, a użytkownik będzie musiał użyć innej wersji obrazu.
Dzięki temu mogłem pozbyć się aplikowania migracji z procesu CI/CD, a Papertrail wyświetli mi logi z aplikowania migracji.

Teraz mój kod odpowiedzialny za aktualizację aplikacji jedynie podbija wersję obrazu i przekazuje zmienne środowiskowe z GitLab-a. W momencie skalowania aplikacji każda instancja we własnym zakresie sprawdzi czy wszystkie wymagane migracje zostały zaaplikowane. Nie przeszkadza mi to, ponieważ wszystkie będą korzystać z tego samego obrazu. Zapewne dałoby się zoptymalizować jeszcze, aby npm ci
nie musiał instalować wszystkich paczek, lecz nie chciałem na to poświęcać czasu w tym momencie. W kolejnych iteracjach sprawdzę czy npm install prisma
spowoduje zainstalowanie paczki w wersji zdefiniowanej w package.json
.
Tymczasem dziękuję za uwagę. Do następnego!