1. OOMKilled: що це таке
Коли йдеться про ліміти CPU, картина зазвичай мʼякша: контейнер живий, але все виконує повільніше. А от під тиском на памʼять фінал може бути значно жорсткішим. Тут сервіс уже не просто гальмує, а може або сам завершитися з OutOfMemoryError, або взагалі не встигнути нічого сказати, бо його вбʼє контейнерний runtime.
Коли ви вперше бачите, що контейнер «раптом помер», дуже хочеться пояснити це однією фразою: «Ну, мабуть, не вистачило памʼяті». Це як діагностувати будь-яку хворобу словами «десь застудився». Інколи вгадаєте, але частіше — ні. У контейнерному світі є принципова різниця між тим, що застосунок сам упав, і тим, що середовище запуску примусово вимкнуло процес.
OOMKilled — якраз про другий випадок. Це не «Java трохи образилася й закрилася», а ситуація, коли контейнер вийшов за ліміт памʼяті, і оточення (насправді ядро ОС через механізм обмеження памʼяті) просто каже процесу: «Друже, ти забираєш забагато памʼяті — до побачення». І каже це максимально прямолінійно: SIGKILL (тобто «вбити без розмов»). У Java в цей момент зазвичай немає ні часу, ні права на «красиву» помилку, лог, shutdown hooks та іншу культуру спілкування.
2. OutOfMemoryError у JVM і OOMKilled контейнера
Важливо заздалегідь домовитися про терміни, бо вони схожі, а наслідки — різні. Коли Java пише java.lang.OutOfMemoryError, це внутрішня подія JVM: сама JVM розуміє, що не може виділити памʼять (наприклад, heap досяг -Xmx, або metaspace вперся у свій ліміт), і викидає виняток. В ідеальному світі ви отримуєте стек-трейс, логи, іноді навіть обробку на рівні застосунку, хоча зазвичай це все одно погано закінчується.
OOMKilled — це зовнішня подія контейнера. Вона не обовʼязково супроводжується охайним Java-логом. Процес можуть «прибити» посеред життя, і максимум, що ви побачите, — обірваний лог. Якщо вам пощастить, ви побачите в Docker-стані, що контейнер було вбито через OOM.
Щоб це стало максимально конкретним, давайте порівняємо ці два сюжети на рівні «що ви реально побачите».
Як виглядає OutOfMemoryError у логах
Якщо застосунок упав сам, за логами зазвичай видно, що він устиг сказати бодай щось осмислене. Наприклад, може бути такий фрагмент:
java.lang.OutOfMemoryError: Java heap space
at com.example.catalog...
Контейнер при цьому завершиться, але в Docker це не буде «OOMKilled». Це схоже на ситуацію «двигун заглох, і машина зупинилася»: неприємно, але бодай зрозуміло по звуку, що сталося.
Як виглядає OOMKilled за симптомами
За OOMKilled лог може обірватися різко. Наприклад, ви бачите звичайні робочі повідомлення, а потім… нічого. Ні «Exception», ні «Shutting down». Ніби хтось висмикнув вилку з розетки, що в якомусь сенсі недалеко від правди.
І ось тут головний трюк діагностики: ви перестаєте довіряти лише логам і починаєте читати стан контейнера.
3. Де дивитися state контейнера в Docker
Зараз буде важлива думка: контейнер — це процес. А в процесу є дві ключові форми «документа»: що він встиг сказати (логи) і що про нього знає середовище запуску (state). Якщо ви читаєте лише одне джерело, ви часто бачите лише половину картини й домислюєте другу фантазією. А фантазія — відомий конкурент логів за звання «головне джерело правди», і зазвичай вона перемагає в найневдаліший момент.
Docker дає нам змогу дивитися на контейнер «ззовні» через docker ps, docker ps -a і, особливо, docker inspect. Там є блок .State, який можна вважати майже «паспортом смерті» контейнера: чим він завершився, коли завершився, чи був убитий через OOM, який exit code тощо.
Нам потрібні три прості команди, які разом дають достатньо інформації, щоб перестати гадати.
docker logs: що встиг сказати застосунок
# Дивимося, що застосунок встиг вивести в stdout/stderr до смерті
docker logs catalog-service
Ця команда відповідає на запитання: «Чи мав застосунок сили пояснити, що він помирає?» Якщо ви бачите явну причину — чудово. Якщо логи обірвалися, це теж сигнал, просто іншого типу.
docker ps -a: контейнер живий, зупинений чи перезапускався
# Дивимося контейнер, навіть якщо він уже зупинений (без -a він може не показатися)
docker ps -a --filter "name=catalog-service"
Це вже інший рівень: «контейнер узагалі ще існує?» і «в якому він статусі?». Для новачка важливий момент: docker ps без -a показує лише запущені, а питання «чому воно не працює» часто починається з того, що контейнер уже давно не запущений.
docker inspect: стан контейнера
Найкорисніший «швидкий перегляд» стану:
# Швидко витягуємо JSON зі стану контейнера: ExitCode, OOMKilled, StartedAt/FinishedAt тощо
docker inspect --format '{{json .State}}' catalog-service
Ця команда виводить JSON зі станом контейнера. Там зазвичай є поля на кшталт Status, Running, ExitCode, OOMKilled, Error, StartedAt, FinishedAt. Нам не потрібно ставати археологами Docker JSON — досить навчитися дивитися на 3–4 поля.
Якщо хочеться зовсім «точково», можна витягнути окремі поля:
# Точково виводимо лише найкорисніші поля для діагностики
docker inspect --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Status={{.State.Status}}' catalog-service
4. Exit codes і сигнали: 137 і SIGKILL
Якщо ви раніше не стикалися з exit codes, їх легко сприйняти як якесь випадкове число. Насправді це цілком системна річ, просто вона часто виглядає як шифр, поки ви не знаєте легенду. А легенда така: Linux-процеси можуть завершуватися самі й повертати exit code, а можуть бути завершені сигналом. Тоді exit code часто виглядає як 128 + номер_сигналу.
Найчастіший «контейнерний детектив» виглядає так: ви бачите Exited (137) і думаєте: «Ага, OOMKilled». І ось тут можна помилитися, бо 137 означає «процес убили SIGKILL (9)», а SIGKILL може прилетіти не лише в OOM-сценарії. Наприклад, Docker може надіслати SIGKILL, якщо ви виконали docker stop, але процес не завершився за таймаут, і Docker «добив» його. Це зовсім інший сюжет, і лікується він інакше.
Щоб тримати це в голові, зручно мати маленьку табличку-нагадування: не як «вчити напамʼять», а як «розпізнавати знайомі обличчя».
| Що ви бачите | Що це часто означає | Що перевірити, щоб не помилитися |
|---|---|---|
| ExitCode=0 | Застосунок завершився нормально | Чому він узагалі завершився? Він не мав працювати довше? |
| ExitCode=1 (або інший невеликий код) | Застосунок сам упав з помилкою | docker logs, там зазвичай є причина |
| ExitCode=137 | Процес убитий SIGKILL | Дивіться .State.OOMKilled у docker inspect |
| ExitCode=143 | Зазвичай SIGTERM (мʼяка зупинка) | Часто це результат docker stop за коректного shutdown |
Головна мораль: exit code — це «улика», але не «вирок». Вирок виносить звʼязка: exit code + OOMKilled + логи.
5. Діагностика за симптомами: логи + state
Зараз зберемо це в єдиний «маршрут читання», який ви можете повторювати щоразу, коли контейнер раптово помер. Я спеціально не буду оформлювати це як чекліст із двадцяти пунктів, їх ніхто не читає в реальності. Краще дам стійку послідовність думок: ви читаєте не команди, а причинно-наслідковий звʼязок.
Спочатку ви дивитеся логи, бо це най«людськіше» джерело: якщо застосунок помер сам, він зазвичай залишає записку. Потім ви дивитеся статус контейнера, бо іноді застосунок нічого не встиг сказати, а контейнер уже мертвий. І нарешті ви дивитеся inspect, бо там Docker чесно скаже, чи було OOMKilled, який був exit code і коли все закінчилося.
Щоб це зафіксувати візуально, ось маленька блок-схема:
flowchart TD
A["Контейнер «не працює»"] --> B["docker ps -a: він живий чи вже Exited?"]
B -->|Running| C["docker ps: є (healthy/unhealthy)?"]
C --> D["docker logs: є помилки, timeout, stack trace?"]
B -->|Exited| E["docker logs: є «останні слова»?"]
E --> F["docker inspect .State: ExitCode + OOMKilled"]
F -->|OOMKilled=true| G["Це memory limit / загальний memory budget"]
F -->|OOMKilled=false| H["Це crash/stop/kill іншого типу"]
Тут ключовий момент у вузлі docker inspect: саме там ви не «вгадуєте», а перевіряєте факт. Якщо OOMKilled=true, це майже залізобетонно каже: процес убили через памʼять. Якщо OOMKilled=false, а exit code 137, значить вас убили SIGKILL, але причина не обовʼязково в памʼяті.
6. Три характерні картини «контейнер помер»
Зараз я покажу три «портрети» проблем, які в житті часто виглядають однаково, тобто просто як «не працює», але читаються по-різному.
Застосунок упав через помилку конфігурації або коду
Якщо застосунок падає сам, він зазвичай робить це «виховано»: пише стек-трейс або хоча б повідомлення. Для ілюстрації візьмемо штучний приклад помилки застосунку:
import java.lang.IllegalStateException;
public class DemoValidation {
// Проста валідація: перевіряємо вхідні параметри ДО того, як вони потраплять у бізнес-логіку
static void validatePort(int port) {
// Важливо: порт має бути додатним (у цьому прикладі спрощено)
if (port < 1) {
// Явно падаємо зі зрозумілою причиною — це буде видно в логах контейнера
throw new IllegalStateException("Некоректний порт: " + port);
}
}
public static void main(String[] args) {
// Демонстрація: передаємо завідомо неправильне значення й отримуємо передбачуване аварійне завершення
validatePort(0); // BOOM
}
}
У контейнерному світі це майже завжди виглядає так: docker logs показує зрозумілий текст помилки, контейнер Exited, OOMKilled=false. Exit code частіше невеликий, зазвичай 1. Лікується це не памʼяттю, а виправленням конфігурації або коду.
JVM упала через OutOfMemoryError
Тут docker logs зазвичай показує OutOfMemoryError. Контейнер завершився, але OOMKilled=false, бо це JVM «здалася», а не контейнер її «прибив». Такий сценарій інколи лікується збільшенням -Xmx, інколи — зменшенням навантаження, інколи — виправленням витоку. Але в межах нашого курсу важливо інше: це не OOMKilled.
І так, тут легко сплутати, бо слово «OutOfMemory» звучить дуже схоже. Різниця в тому, хто промовив це слово: JVM всередині чи Docker зовні.
Контейнер OOMKilled через memory limit
А ось тут цікавіше. Ви можете побачити, що контейнер завершився з ExitCode=137. Логи обірвалися різко. І найважливіше: docker inspect покаже OOMKilled=true.
Мініприклад команд, якими це читається:
# 1) Спочатку дивимося, чи встиг застосунок сказати щось у логах
docker logs catalog-service
# 2) Потім перевіряємо state контейнера: ExitCode (як завершилося) і OOMKilled (чому могло бути вбито)
docker inspect --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' catalog-service
# ExitCode=137 OOMKilled=true (приклад)
Це та ситуація, коли «лікувати логами» марно: процес убили, він не встиг пояснитися. Ваше головне джерело істини — стан контейнера.
7. OOMKilled vs SIGKILL з інших причин
Ця частина здається нудною, доки одного разу не потрапите в пастку: ви бачите Exited (137) і починаєте крутити памʼять, а насправді контейнер було вбито не через OOMKilled, а тому, що ви його зупинили, він не завершився за timeout, і Docker застосував «молоток» (SIGKILL).
Як відрізнити? Рівно так само, як ми вчилися відрізняти все інше: не гадати, а дивитися .State. Якщо OOMKilled=false, то це не OOMKilled, навіть якщо exit code 137. І далі ви вже згадуєте лекції про життєвий цикл контейнера та коректний shutdown: можливо, процес не ловить сигнал, можливо, ENTRYPOINT написаний у неправильній формі (shell-form), можливо, застосунку потрібно більше часу на graceful shutdown. Але найважливіше — ви не лікуєте не ту хворобу.
Схожа історія буває, коли контейнер «помирає» через ручний docker kill (який теж шле SIGKILL) або через дії оточення. Усюди логіка одна: ExitCode каже «як», OOMKilled часто каже «чому», а логи допомагають зрозуміти, що відбувалося до цього.
8. Діагностика на нашому сервісі
Усе це не абстракція, а нормальна операційна практика навколо вашого навчального Spring Boot-сервісу.
Наш docker-java-catalog-service у контейнері — це рівно один Java-процес. Якщо контейнер упав, значить упав або був убитий саме він. Тому діагностика завжди починається однаково: читаємо docker logs, перевіряємо docker ps -a, потім дивимося .State через docker inspect. І лише після цього робимо висновок: «це OOMKilled» або «це crash іншого типу». Таке розділення економить вам час, нерви й майбутні хаотичні коміти в дусі «збільшив усе, що можна, раптом допоможе».
9. Типові помилки під час діагностики OOMKilled
Помилка № 1: робити висновок «це OOMKilled» лише за Exited (137).
137 справді часто трапляється в OOM-сценаріях, але сам по собі він означає лише SIGKILL, а SIGKILL може прилетіти з різних причин. Щоб не лікувати не ту проблему, завжди перевіряйте .State.OOMKilled через docker inspect. Якщо там false, це не OOMKilled, навіть якщо число 137 виглядає дуже переконливо.
Помилка № 2: читати лише логи й ігнорувати стан контейнера.
Логи чудові, коли застосунок помер «культурно» й устиг написати стек-трейс. Але в OOMKilled-сценарії лог часто обривається без пояснень, бо процес убивають жорстко. Якщо ви продовжуєте шукати причину лише в логах, ви буквально намагаєтеся отримати інформацію від процесу, який уже не має права голосу. Docker state тут важливіший.
Помилка № 3: плутати JVM OutOfMemoryError і контейнерний OOMKilled.
Обидві історії про памʼять, але це різні рівні. OutOfMemoryError — внутрішня проблема JVM, і за логами зазвичай видно, що JVM сама так вирішила. OOMKilled — зовнішнє вбивство процесу через memory limit, і ключовий маркер — .State.OOMKilled=true. Якщо змішати ці два сюжети, ви без кінця «чинитимете heap», хоча проблема може бути в загальному memory budget контейнера.
Помилка № 4: намагатися «полагодити OOMKilled» збільшенням -Xmx без розуміння ліміту контейнера.
Це майже гарантований спосіб зробити гірше, бо контейнерний ліміт обмежує всю памʼять процесу, а не лише heap. Якщо ви впритул посунете heap до ліміту, ви просто залишите нуль місця для metaspace і native memory, а потім дивуватиметеся, чому все стало падати ще швидше. Спочатку зʼясовуйте зовнішній ліміт контейнера та факт OOMKilled, а вже потім думайте про heap.
Помилка № 5: не відрізняти «контейнер помер» від «контейнер живий, але unhealthy».
Інколи сервіс не відповідає, і здається, що він «упав». Але контейнер може продовжувати працювати, просто healthcheck показує unhealthy. Це інший клас проблеми: найімовірніше, сервіс живий, але не готовий обслуговувати запити. У такому разі docker ps і health-статус дадуть більше користі, ніж спроби шукати OOMKilled.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ