CodeGym /Kursy /Docker for Spring /Exec-form ENTRYPOINT dla Javy

Exec-form ENTRYPOINT dla Javy

Docker for Spring
Poziom 4, Lekcja 2
Dostępny

1. Kontener startuje i dziwnie się zatrzymuje

Rozdzielenie komendy uruchomienia i parametrów jest już przydatne, ale to jeszcze za mało. Kontener może startować tym samym java -jar app.jar, a zatrzymywać się dziwnie, jeśli między Dockerem a JVM pojawi się shell.

Niemal u każdego początkującego w Dockerze zdarza się ta sama historia. Buduje obraz, uruchamia kontener, widzi upragnione „Started … in 2.3 seconds" w logach i myśli: „No to wszystko, zwycięstwo, dalej można nie ruszać". A potem robi docker stop, i kontener albo zatrzymuje się zbyt długo, albo „umiera gwałtownie", albo w logach nie ma normalnego zakończenia. I to nie kaprys Dockera — to konsekwencja tego, który proces wewnątrz kontenera jest uważany za główny.

Kontener — to nie „mini-VM", lecz proces lub grupa procesów, którymi Docker zarządza z zewnątrz. Dla Dockera ważny jest jeden konkretny proces: ten, który jest uważany za główny, zazwyczaj PID 1 wewnątrz kontenera. Jeśli przypadkowo uczyniłeś głównym procesem powłokę (sh), a Java jest jej „dzieckiem", to przy zatrzymaniu kontenera tak naprawdę rozmawiasz nie z JVM, lecz z powłoką. A powłoka, delikatnie mówiąc, nie zawsze „przekazuje słuchawkę" dalej.

Wyobraź sobie, że próbujesz zadzwonić do przyjaciela, ale zamiast niego na telefon odpowiada automat. Automat może być bardzo uprzejmy, ale nie jest zobowiązany przekazywać Twojej prośby do adresata. Właśnie shell-form ENTRYPOINT bardzo często działa jak taki automat.

2. Mechanika docker stop: SIGTERM i SIGKILL

Żeby zrozumieć, dlaczego forma ENTRYPOINT jest tak ważna, trzeba na chwilę zajrzeć w mechanikę zatrzymywania kontenera. Tu nie ma żadnej „magii Dockera", to po prostu procesy Linuksa i sygnały. Docker domyślnie próbuje być uprzejmy: najpierw prosi proces o poprawne zakończenie, a dopiero potem, jeśli jest ignorowany, stosuje brutalną siłę.

W typowym scenariuszu docker stop działa tak: Docker wysyła sygnał SIGTERM do głównego procesu kontenera, następnie czeka pewien czas (domyślnie około 10 sekund), a jeśli proces się nie zakończył — wysyła SIGKILL. SIGTERM — to „proszę, zakończ się normalnie", SIGKILL — to „powiedziałem, koniec, wyłączamy światło", i nie można go przechwycić, przetworzyć ani „poprosić, żeby jeszcze chwilę poczekał".

Przykład polecenia z jawnym timeoutem:

 
  # Jawnie zadajemy czas, który Docker poczeka po SIGTERM
docker stop --time 30 <container-name>
# Docker najpierw wyśle SIGTERM do PID 1 kontenera, potem poczeka do 30 sekund
 

Dlaczego jest to ważne właśnie dla Java/Spring Boot? Bo serwis Spring Boot — to nie jeden „println i wyjście". Ma serwer www, pulę wątków, możliwe zadania w tle, kontekst Spring, i to wszystko umie się zamykać poprawnie, ale tylko jeśli aplikacji dano szansę. SIGTERM właśnie tę szansę daje: Spring zaczyna zamykać kontekst i zwalniać zasoby. SIGKILL szansy nie daje — JVM po prostu przestaje istnieć, jakby ktoś wyrwał wtyczkę z gniazdka. Technicznie obrazowo, ale co do efektu — bardzo bliskie.

I kluczowe pytanie tego wykładu: kto dokładnie otrzyma SIGTERM od Dockera? Odpowiedź: proces z PID 1 wewnątrz kontenera. Dlatego dalej musimy zrozumieć, kto jest PID 1 i jak może zostać „podrobiony" bez Twojej wiedzy.

3. PID 1 i sygnały Dockera

Wewnątrz zwykłego systemu Linux PID 1 — to zazwyczaj systemd lub inny system init. Uruchamia serwisy, pilnuje procesów, „zbiera" zakończone procesy potomne i w ogóle jest głównym dyspozytorem. W kontenerze wszystko jest prostsze i jednocześnie niebezpieczniejsze: PID 1 — to ten proces, który uruchomiłeś jako pierwszy.

Jeśli w Dockerfile napisałeś ENTRYPOINT tak, że Docker de facto uruchamia powłokę (/bin/sh -c ...), to PID 1 — to powłoka. A Java staje się PID 2 lub PID 7 — nieważne, ważne że nie pierwsza. I wtedy przy docker stop sygnał wysyłany jest do powłoki, a nie do JVM.

Tę różnicę wygodnie zobaczyć na schemacie:

flowchart TD
    A[docker stop] -->|SIGTERM| B[PID 1 w kontenerze]
    B --> C{Kto jest PID 1?}
    C -->|exec-form| D["java -jar app.jar
otrzymuje SIGTERM"] C -->|shell-form| E["/bin/sh -c ...
otrzymuje SIGTERM"] E --> F["java -jar app.jar
może nie otrzymać SIGTERM w porę"]

Zwróć uwagę na słowo „może". Niekiedy shell naprawdę przekazuje sygnał dalej, niekiedy nie, niekiedy robi to częściowo, niekiedy zabija proces potomny po swojemu, niekiedy po prostu czeka. W efekcie zachowanie zatrzymania kontenera staje się nie inżynierskie — „rozumiem, co teraz się stanie" — lecz wróżbiarskie: „a dziś zakończy się ładnie czy nie?" W kursie od razu zakazujemy wróżenia, bo wróżenie — to droga do dziwnych błędów i wiecznego „u mnie na laptopie działa".

4. Shell-form vs exec-form ENTRYPOINT

Teraz będzie kluczowy zwrot: Dockerfile pozwala opisać ENTRYPOINT w dwóch formach. Zewnętrznie wyglądają podobnie, ale tworzą różne drzewa procesów. Początkujący często wybiera shell-form, bo „tak jest prościej" i „tak piszą w artykułach". A potem dziwi się, dlaczego zatrzymanie kontenera zachowuje się jak kapryśny kot: dziś łagodny, jutro drapie.

Shell-form wygląda tak (jedna linia, jak zwykłe polecenie):

 
  # Shell-form: Docker prawie zawsze uruchomi polecenie przez powłokę (/bin/sh -c)
ENTRYPOINT java -jar app.jar
 

Exec-form wygląda tak (tablica argumentów):

 
  # Exec-form: Docker uruchamia proces bezpośrednio, bez /bin/sh -c
ENTRYPOINT ["java", "-jar", "app.jar"]
 

Najważniejsze: shell-form prawie zawsze oznacza, że Docker uruchomi powłokę, w przybliżeniu odpowiadające:

/bin/sh -c "java -jar app.jar"

A exec-form oznacza, że Docker uruchamia proces bezpośrednio, bez pośredniej powłoki. Stąd wynikają różne losy sygnałów i różna przewidywalność zachowania.

Poniżej — krótka tabela, żeby utrwalić myśl nie tylko słowami, ale i oczami:

Co porównujemy shell-form exec-form
Zapis w Dockerfile ENTRYPOINT java -jar app.jar ENTRYPOINT ["java","-jar","app.jar"]
Kto często staje się PID 1 /bin/sh (powłoka) java (JVM)
Jak przychodzą sygnały docker stop najpierw do powłoki, dalej „jak wyjdzie" bezpośrednio do JVM
Zmienne środowiskowe wewnątrz komendy łatwo podstawiać ($VAR) bezpośrednio nie podstawiają się (i to normalne)
Przewidywalność zachowania średnia/niska wysoka

I właśnie tu pojawia się typowa pułapka. Ludzie wybierają shell-form dla wygody: „a jak podstawię zmienną?" albo „jak zrobię ładną komendę w jednej linii?" W świecie Java to szczególnie kuszące, bo chce się używać czegoś w stylu $JAVA_OPTS. Ale priorytet dla serwisu Java jest inny: najpierw potrzebny jest poprawny cykl życia procesu, a dopiero potem wygodne kanały dla parametrów JVM. Inaczej jedna „wygodna" linia shell-form natychmiast przywraca nas do problemu sygnałów.

5. Shell-form i sygnały dla Javy

Na razie może to wyglądać jak stylistyczna wojna: „nawiasiki czy bez nawiasików". W rzeczywistości chodzi o zachowanie systemu przy zatrzymaniu. I Java cierpi tu szczególnie zauważalnie, bo serwis Spring Boot — to długo działający proces, który powinien umieć ładnie się kończyć.

Scenariusz awarii jest zazwyczaj taki. Uruchamiasz kontener, wszystko działa. Potem robisz docker stop. Docker wysyła SIGTERM do PID 1. Jeśli PID 1 — to sh, to właśnie sh decyduje, co zrobić dalej. Może zakończyć się sam. Może poczekać, aż zakończy się proces potomny. Może nie przekazać SIGTERM do potomka, czyli JVM. Może przekazać, ale nie tak, jak myślisz. W efekcie proces Java pozostaje żywy, kontener nie zatrzymuje się w porę, a Docker po timeoucie wysyła SIGKILL całej historii. Wychodzi nie poprawne zakończenie, lecz „wyłączono bezpiecznik".

Dlaczego to szczególnie nieprzyjemne dla Spring Boot? Bo przy normalnym SIGTERM Spring może zdążyć zrobić przydatne rzeczy: zatrzymać serwer www, poprawnie zakończyć kontekst, zamknąć zasoby, dopisać logi. Nawet jeśli nasz serwis dydaktyczny jest teraz w trybie standalone i bez bazy, nawyk „zamykać się normalnie" jest ważny od samego początku. W prawdziwych serwisach nieprawidłowy scenariusz stop może zostawić niedopisany plik eksportu, urwane żądanie, dziwny stan cache albo po prostu „zagubione" ostatnie linie logów — a właśnie one często są najbardziej użyteczne.

Żeby poczuć związek „sygnał → zakończenie" na poziomie Javy, wystarczy nawet mały przykład. W Spring można zawiesić metodę, która zostanie wywołana przy zakończeniu kontekstu. To nie „skomplikowany Spring", lecz po prostu obserwowalny marker: czy aplikacja miała szansę zatrzymać się poprawnie.

Przykład małego komponentu:

 
  import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

// Ten komponent potrzebny jest tylko jako marker: widzimy log — znaczy Spring miał szansę poprawnie się zakończyć
@Component
class ShutdownMarker {
    // Zwykły logger, żebyśmy zauważyli zdarzenie w logach kontenera
    private static final Logger log = LoggerFactory.getLogger(ShutdownMarker.class);

    @PreDestroy // Zostanie wywołany przy zamykaniu kontekstu Spring (zazwyczaj po SIGTERM)
    void onShutdown() {
        log.info("Shutdown: Spring context is closing"); // Zobaczysz w logach przy normalnym SIGTERM
    }
}
 

Jeśli kontener zatrzymuje się poprawnie (SIGTERM dotarł do JVM, Spring zaczął się zamykać), ta linia niemal na pewno pojawi się w logach. Jeśli zaś kontener „zabito" przez SIGKILL, aplikacja nie zdążyła poprawnie zakończyć kontekstu, i tej linii nie zobaczysz. I to już nie filozofia i nie styl — to praktyczna diagnostyka: „aplikacja sama się zatrzymała" kontra „wyłączono ją z zewnątrz".

Jeszcze jeden ważny niuans: czasem ludzie próbują „oszukać" i piszą tablicę JSON, ale wewnątrz i tak uruchamiają shell:

 
  ENTRYPOINT ["sh", "-c", "java -jar app.jar"]
 

Wygląda jak exec-form, ale sens — to ten sam shell-form, tylko w nowym opakowaniu. PID 1 znowu będzie sh. Czyli jeśli widzisz sh -c w ENTRYPOINT, uznaj, że wróciłeś do pierwotnego problemu — tylko teraz starannie zamaskowanego.

6. Exec-form ENTRYPOINT dla Spring Boot jar

Teraz utrwalamy regułę, która będzie czerwoną nicią całego kursu: dla serwisu Java/Spring Boot ENTRYPOINT piszemy tylko w exec-form. Potrzebujemy, żeby głównym procesem kontenera był właśnie java, bez pośredników. To daje przewidywalny scenariusz stop i sprawia, że kontener jest uczciwy: Docker wysłał sygnał — JVM go dostała.

Minimalny fragment Dockerfile dla naszego Container-Ready Catalog Service wygląda tak:

 
  # Bazowy obraz runtime: tu ważny jest nie rozmiar, lecz poprawny cykl życia procesu
FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app

# Ścieżkę do jar przekazujemy przez ARG, żeby COPY było elastyczniejsze w przykładzie
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

# Ważne: exec-form, żeby PID 1 był java, i SIGTERM trafiał bezpośrednio do JVM
ENTRYPOINT ["java", "-jar", "app.jar"]
 

Zwróć uwagę: to nie Dockerfile, który już rozwiązuje w ogóle wszystko. To właśnie prawidłowy wariant z punktu widzenia cyklu życia procesu. Nie chowamy java za sh, nie robimy „wygodnych linii" kosztem zachowania i nie zamieniamy uruchomienia w mały teatr cieni.

I tak, możesz zapytać: „A gdzie parametry JVM, gdzie profil, gdzie port?" Doskonałe pytanie, ale teraz ważne jest, żeby nie mieszać warstw. Najpierw utrwalamy, kto jest procesem i jak jest zarządzany sygnałami. Inaczej każde następne ustawienie będzie budowane na wadliwym starcie.

Psychologicznie to przypomina dobry kod Java: najpierw robimy poprawny model, a potem dodajemy udogodnienia. Inaczej dostajemy Thread.sleep(20000) w kodzie, tylko w świecie Dockerfile.

7. Skrypt w ENTRYPOINT: tylko przez exec

Czasem zespół chce startować aplikację przez mały skrypt. Nie dlatego że „tak modnie", lecz np. żeby wypisać kilka linii diagnostycznych, sprawdzić istnienie pliku, stworzyć katalog lub po prostu mieć jeden punkt wejścia. Skrypt sam w sobie nie jest złem. Złem — gdy na zawsze pozostaje PID 1 i zamienia się w kolejnego pośrednika, który nie jest zobowiązany prawidłowo obsługiwać sygnałów.

Prawidłowa idea jest prosta: jeśli uruchamiasz Javę przez skrypt, to wewnątrz skryptu trzeba zrobić exec. W shellu oznacza to: „zastąp bieżący proces skryptu procesem java". Wtedy PID 1 stanie się Java, i sygnały będą trafiać tam, gdzie trzeba.

Minimalny entrypoint.sh:

 
  #!/bin/sh
set -e

# Mała diagnostyka: warto widzieć, że start poszedł
echo "Starting Container-Ready Catalog Service..."

# Kluczowy moment: exec zastępuje proces skryptu procesem java, żeby PID 1 stał się JVM
exec java -jar app.jar
 

Najważniejsze tu — exec java .... Bez exec skrypt pozostanie głównym, a Java będzie procesem potomnym. Czyli własnymi rękami przywrócisz ten sam problem, od którego właśnie uciekłeś.

Dockerfile przy tym też pozostaje w exec-form (tylko teraz uruchamiamy skrypt):

 
  WORKDIR /opt/app

# Kopiujemy skrypt entrypoint do obrazu
COPY entrypoint.sh entrypoint.sh
# Robimy skrypt wykonywalnym, inaczej ENTRYPOINT nie będzie mógł go uruchomić
RUN chmod +x entrypoint.sh

# To exec-form: Docker uruchamia skrypt bezpośrednio (ale wewnątrz skryptu i tak potrzebny exec java)
ENTRYPOINT ["./entrypoint.sh"]
 

Tu ważny jest subtelny moment. ENTRYPOINT ["./entrypoint.sh"] — to exec-form, ale PID 1 na starcie i tak będzie skrypt. Dlatego krytyczne jest, żeby skrypt zrobił exec java ..., inaczej — znowu pośrednik. To przypomina sytuację w Javie, gdy napisałeś ładny interfejs, a w środku i tak wywołujesz brudną metodę bezpośrednio. Zewnętrznie architektura jest, faktycznie — nie ma.

Gdy java uczciwie staje się PID 1, kolejna pułapka nie leży już w scenariuszu stop, lecz w parametrach uruchomienia. Port, profil i opcje JVM trzeba umieć zmieniać tak, żeby nie wracać do obrazka z sh -c i nie psuć normalnego cyklu życia kontenera.

8. Diagnostyka: kto jest PID 1 w kontenerze

Gdy coś zachowuje się dziwnie, u początkującego programisty często włącza się tryb „magia": „Docker jest jakiś krzywy", „u mnie Windows", „to przez Spring". W rzeczywistości problem często można zobaczyć bardzo przyziemnie: po prostu sprawdzić, jakie procesy realne są uruchomione wewnątrz kontenera i który z nich jest główny. I to nie rytuał DevOps, lecz to samo podejście co w Javie: najpierw obserwujemy, potem wyciągamy wnioski.

Na przykład, kontener można uruchomić z nazwą, żeby nie kopiować ID, a potem sprawdzić procesy:

 
  # Uruchamiamy kontener z nazwą, żeby łatwiej było do niego odwoływać się poleceniami docker top/logs/stop
docker run --name catalog -p 8080:8080 docker-java-catalog-service

# Patrzymy na procesy wewnątrz kontenera: idealnie PID 1 — to java
docker top catalog
# Idealnie powinieneś zobaczyć, że głównym procesem jest java
 

Jeśli zobaczyłeś, że tam żyje sh -c i obok java, to prawie zawsze sygnał, że używasz shell-form lub zamaskowanego shell przez sh -c. Jeśli zaś na liście widać, że uruchomiony jest java -jar app.jar, prawdopodobieństwo prawidłowego scenariusza stop jest znacznie wyższe.

Jeszcze jedna przydatna myśl: jeśli kontener długo się nie zatrzymuje i w końcu „umiera gwałtownie", często oznacza to, że nie zareagował na SIGTERM w porę, i Docker przeszedł do SIGKILL. I to nie zawsze „zły kod aplikacji". Niekiedy problem po prostu polega na tym, że sygnał otrzymuje nie ten PID 1.

Wyjście z trybu „magia" jest tu takie samo jak w programowaniu: przekładamy zachowanie na obserwowalne fakty. Kto jest PID 1? Kto otrzymuje SIGTERM? Czy w logach są oznaki poprawnego zakończenia? Jeśli odpowiedzi są jasne — rozwiązanie staje się techniczne, a nie szamańskie.

9. Typowe błędy przy wyborze formy ENTRYPOINT

Ten temat jest mały, ale błędów wokół niego dużo, bo w internecie pełno „działających" przykładów, które działają dokładnie do pierwszego realnego scenariusza stop/restart. Poniżej — najczęstsze pułapki, które warto rozpoznać od razu, żeby potem nie leczyć objawów godzinami.

Błąd nr 1: używanie shell-form „bo tak prościej".
Zapis ENTRYPOINT java -jar app.jar rzeczywiście wygląda prościej niż tablica w nawiasach. Ale uruchamia aplikację przez powłokę, i Docker zaczyna komunikować się sygnałami z powłoką, a nie z JVM. W efekcie zatrzymanie kontenera staje się mniej przewidywalne, a poprawne zakończenie może nie działać tak, jak się spodziewasz.

Błąd nr 2: myślenie, że „tablica JSON = zawsze exec-form", nawet jeśli wewnątrz sh -c.
Konstrukcja ENTRYPOINT ["sh", "-c", "java -jar app.jar"] wygląda wizualnie jak prawidłowa. Ale w istocie — to ten sam pośrednik shellowy. Jeśli chcesz „zrobić jedną linię" lub „podstawić zmienną", nie wracaj do sh -c. Lepiej znajdź sposób na przekazanie parametrów bez shella.

Błąd nr 3: zrobienie skryptu entrypoint, ale zapomnienie exec wewnątrz niego.
Skrypt bez exec pozostaje PID 1, a JVM staje się procesem potomnym. Sygnały znowu lecą „nie tam", zatrzymanie staje się dziwne, i szczerze nie rozumiesz, dlaczego „przecież zrobiliśmy entrypoint.sh, wszystko powinno być ładnie". Ładnie — tylko z exec.

Błąd nr 4: próba „leczenia" długiego zatrzymania przez zwiększenie timeoutu, bez naprawienia PID 1.
Niekiedy ludzie widzą, że kontener nie zdąży zatrzymać się w 10 sekund, i zaczynają zwiększać docker stop --time 60. To może zamaskować problem, ale nie naprawia przyczyny. Jeśli SIGTERM nie dociera do JVM, możesz czekać minutę albo dziesięć — Java nie zacznie poprawnego zakończenia, bo po prostu nie dostała sygnału.

Błąd nr 5: tłumaczenie gwałtownej śmierci kontenera „cechami Spring", bez sprawdzenia formy uruchomienia.
Spring Boot w większości zwykłych scenariuszy całkiem poprawnie reaguje na SIGTERM. Jeśli nie widzisz oznak normalnego zakończenia w logach, najpierw sprawdź, że Java — to naprawdę główny proces, a nie „aktor poboczny" w spektaklu powłoki. Bardzo często problem nie leży w Spring ani w Javie, lecz w tym, że komenda uruchomienia jest napisana w shell-form.

1
Zadanie
Docker for Spring, poziom 4, lekcja 2
Niedostępne
Poprawne zakończenie procesu Java ze znacznikiem shutdown-marker
Poprawne zakończenie procesu Java ze znacznikiem shutdown-marker
1
Zadanie
Docker for Spring, poziom 4, lekcja 2
Niedostępne
Porównanie wariantu shell i exec na liście procesów
Porównanie wariantu shell i exec na liście procesów
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION