OOM у Docker-контейнерах: обмеження пам’яті, що запобігають безшумним збоям

Було корисно?

Ваш сервіс «випадково перезапускається». Немає стеку викликів, немає гарного виключення, немає прощального повідомлення. Хвилина працює;
наступної — він реінкарнується з новим PID і тими самими невирішеними проблемами.

У дев’яти випадках з десяти винна пам’ять: контейнер досяг ліміту, ядро втрутилася, і процес був убитий з ентузіазмом швейцара, який виводить того, хто «просто хоче поговорити».

Що насправді означає «OOM» у Docker (і чому це виглядає як збій)

«OOM» — це не фіча Docker. Це рішення ядра Linux: Out Of Memory. Коли система (або memory cgroup) не може задовольнити запит на пам’ять,
ядро намагається звільнити пам’ять. Якщо це не вдається — воно вбиває щось, щоб вижити.

Docker просто дає зручну арену для цього, бо контейнери зазвичай розміщують у memory cgroups з явними лімітами. Як тільки ви встановили ліміт,
ви створили маленький всесвіт, де «закінчення пам’яті» може статись, навіть якщо на хості ще багато оперативної пам’яті.
Ось у чому суть: predictable failure boundaries замість одного розбійного сервісу, що з’їдає вузол.

Ключова тонкість: існує два широкі сценарії OOM, які зовні виглядають схоже.

  • cgroup OOM (перевищення ліміту контейнера): контейнер досяг свого обмеження пам’яті, ядро вбиває один або кілька процесів у цьому
    cgroup. Хост може бути цілком здоровим.
  • system OOM (OOM на вузлі): весь хост вичерпав пам’ять. Тепер ядро вбиває процеси по всій системі, включно з dockerd, containerd і
    невинними спостерігачами. Звідси і враження, що «все пішло шкереберть одночасно».

В обох випадках процес зазвичай завершується з SIGKILL. SIGKILL означає ні зачистки, ні «записати логи», ні коректного завершення. Ваш додаток
не «впав» стільки, скільки був стертий із існування.

Цитата, яку варто пам’ятати у каналі інцидентів, парафраз ідеї Вернера Вогелса: Все ламається; наше завдання — проектувати системи, що ламаються добре і швидко відновлюються.

Чому іноді виникають «тихі» відмови

Тиша — не злочинна змова. Це механіка:

  • Ядро різко вбиває процес. Ваш логер може бути буферизованим. Останні рядки можливо ніколи не потраплять у stdout/stderr.
  • Docker повідомляє, що контейнер завершився. Поки ви не викличете docker inspect або не перевірите логи ядра, ви можете ніколи не побачити «OOMKilled».
  • Деякі рантайми (або entrypoint) поглинають коди виходу, швидко перезапускаючи контейнер, лишаючи вас з флапінгом і мінімальним контекстом.

Жарт №1: Якщо ви думаєте, що «обробили всі виключення», вітаю — Linux щойно знайшов те, що ви не зможете зловити.

Факти та історія, що впливають на відладку

Це не дрібниці задля дрібниць. Кожен факт підштовхує рішення з усунення несправностей у правильний бік.

  1. cgroups з’явилися за роки до того, як «контейнери» стали продуктом. Інженери Google запропонували cgroups у середині 2000-х; Docker просто зробив їх доступними. Наслідок: авторитет — за ядром, а не за Docker.
  2. Ранні реалізації обліку пам’яті в cgroups були обережними й іноді дивували. Історично облік пам’яті й поведінка OOM у cgroups дозрівали через кілька версій ядра. Наслідок: «працює на моєму ноуті» може бути несумісністю версій ядра.
  3. cgroups v2 змінили регулятори. На багатьох сучасних дистрибутивах файли лімітів пам’яті перемістилися з memory.limit_in_bytes (v1) у memory.max (v2). Наслідок: ваші скрипти мають визначати режим.
  4. Swap — це не «додаткова ОЗП», це відкладений біль. Swap відсуває OOM ціною стрибків латентності й блокування. Наслідок: можна «вирішити» OOM увімкнувши swap і все одно отримати інцидент — просто повільніший.
  5. Код виходу 137 — підказка, а не діагноз. 137 зазвичай означає SIGKILL (128+9). OOM — часта причина, але не єдина. Наслідок: підтверджуйте через cgroup/логи ядра.
  6. Кеш сторінок теж є пам’яттю. Ядро використовує вільну пам’ять для кешу; у cgroups сторінковий кеш може бути зарахований у cgroup залежно від налаштувань і ядра. Наслідок: контейнери з інтенсивним I/O можуть OOM без «витоків».
  7. Оверкоміт — це політика, а не обіцянка. Linux може дозволяти виділення, що перевищує фізичну пам’ять (overcommit) і відмовлятися від неї пізніше при спробі використовувати. Наслідок: велика операція може успішно виділити пам’ять і все одно спричинити OOM пізніше.
  8. OOM killer вибирає жертв на основі балів «поганості». Ядро обчислює score; великі споживачі пам’яті з низькою «важливістю» вибираються в першу чергу. Наслідок: убитий процес може бути не тим, кого ви очікували.
  9. Контейнери не ізолюють ядро. OOM на рівні ядра все ще може вбити кілька контейнерів або сам рантайм. Наслідок: моніторинг хоста важливий навіть у «контейнеризованому» середовищі.

Облік пам’яті cgroups: що рахується, що ні, і чому це важливо

Перед тим як торкатися ліміту, зрозумійте, що ядро зараховує до нього. Інакше ви «полагодите» не ту проблему і продовжите розбуджувати SRE у вихідні.

Бачки пам’яті, які треба розрізняти

Всередині контейнера використання пам’яті — це не лише heap. Звичні підозрювані:

  • Анонімна пам’ять: heap, стеки, malloc-арени, рантайми мов, кеші в пам’яті.
  • Файло-підтримувана пам’ять: memory-mapped файли, спільні бібліотеки і page cache, пов’язаний з файлами.
  • Page cache: кешовані читання з диска; робить швидким, доки не вбиває систему.
  • Пам’ять ядра: мережеві буфери, slab-виділення. Облік змінюється між версіями і режимами cgroup.
  • Спільна пам’ять: використання /dev/shm; поширено для браузерів, баз даних і IPC-важких додатків.

cgroups v1 vs v2: що змінює поведінку OOM

У cgroups v1 регулятори пам’яті розкидані по контролерам, і контролер «memsw» (memory+swap) необов’язковий. У v2 все більш уніфіковано: memory.current, memory.max, memory.high, memory.swap.max і кращі події.

Операційно велике поліпшення — це memory.high у v2: можна встановити «притиск» (throttle), щоб спричинити тиск на звільнення пам’яті до досягнення жорсткого ліміту. Це не магія, але реальний важіль для «поступового дегрейду замість смерті».

OOM всередині контейнера vs OOM поза контейнером

Якщо запам’ятати одне: OOM на рівні контейнера зазвичай — це проблема бюджетування; OOM на рівні хоста — проблема ємності або перевантаження.

  • Container OOM: ви встановили --memory занадто низько, забули про page cache, ваш додаток витікає пам’ять або трапився одноразовий спайк
    (JIT warm-up, наповнення кешу, compact).
  • Host OOM: занадто багато контейнерів з великими лімітами, відсутність лімітів, пам’ять вузла задута процесами поза контейнерами, або неправильне налаштування swap.

Обмеження пам’яті, які важливі (і ті, що просто дають відчуття безпеки)

Docker дає кілька прапорів, що виглядають прямолінійно. Вони такими і є. Складніше — як вони взаємодіють зі swap, поведінкою звільнення ядра та патернами алокацій вашого додатка.

Жорсткий ліміт: --memory

Це лінія в піску. Якщо контейнер переступає її і не може достатньо звільнити, ядро вбиває процеси в тому cgroup.

Що робити:

  • Встановіть жорсткий ліміт для кожного production-контейнера. Без винятків.
  • Залишайте запас вище за steady-state використання. Якщо сервіс використовує 600MiB, не ставте 650MiB і називайте це «щільним». Називайте це «крихким».

Політика swap: --memory-swap

Поведінка swap у Docker плутає навіть досвідчених операторів, бо значення залежить від режиму cgroup і ядра, а за замовчуванням не завжди те, що ви припускаєте.

Практична позиція:

  • Якщо можете дозволити, віддавайте перевагу мало або взагалі без swap для сервісів чутливих до затримки, і правильно підбирайте жорсткий ліміт.
  • Якщо треба дозволити swap, робіть це свідомо і моніторте major page faults та латентність. Swap ховає тиск на пам’ять, поки він не перетвориться на пожежу.

М’які ліміти: reservation і memory.high у v2

Docker показує --memory-reservation, що не є гарантованим мінімумом. Це рекомендація, переважно для рішень планування в оркестраторах та для поведінки звільнення. На системах cgroups v2 справжній «м’який кап» — це memory.high.

Простими словами: reservation — для планування; жорсткі ліміти — для виживання; memory.high — для формування поведінки.

Чого слід уникати

  • Нелімітовані контейнери на спільному вузлі. Це не «гнучкість». Це рулетка з вашим рантаймом.
  • Жорсткі ліміти без видимості. Якщо ви не вимірюєте пам’ять, ви просто обираєте час відмови.
  • Встановлення лімітів рівних JVM max heap (або очікуваному використанню Python) без накладних витрат. Рантайми, native-бібліотеки, треди й page cache посміються над вашою таблицею в spreadsheet.

Швидкий план діагностики

Це послідовність «я на чергуванні, 02:13». Не вигадуйте велосипед. Почніть тут, швидко отримайте сигнал, а потім копайте глибше.

Перш за все: підтвердьте, що це був OOM

  • Перевірте стан контейнера: чи було OOMKilled, вийшов він з кодом 137, чи з іншим SIGKILL?
  • Перевірте логи ядра на події OOM, пов’язані з тим cgroup або PID.

Друге: визначте, де стався OOM

  • Це був cgroup OOM (ліміт контейнера) чи system OOM (вузол вичерпався)?
  • Подивіться на тиск пам’яті на вузлі та використання інших контейнерів у той самий час.

Третє: вирішіть, це проблема розміру чи витік/спайк

  • Порівняйте steady-state використання з лімітом. Якщо використання повільно росте до смерті — підозрюйте витік.
  • Якщо вмирає під час відомої події (деплой, наповнення кешу, пакетна задача) — підозрюйте спайк і відсутній запас.
  • Якщо вмирає під I/O-навантаженням — підозрюйте page cache, mmap або tmpfs (/dev/shm) використання.

Четверте: виправте безпечно

  • Короткочасно: підніміть ліміт або зменшіть конкурентність, щоб зупинити кровотечу.
  • Середньостроково: додайте інструментування, захопіть heap/profile дампи і відтворіть проблему.
  • Довгостроково: впровадьте ліміти всюди; встановіть бюджети; створіть тривоги на «наближення до ліміту», а не лише на перезапуски.

Практичні завдання: команди, виводи та рішення (12+)

Це розраховано запускати на Docker-хості. Декотрі завдання потребують root. Всі вони дають інформацію, на яку можна діяти.

Завдання 1: Перевірити, чи Docker вважає контейнер OOMKilled

cr0x@server:~$ docker inspect -f '{{.Name}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Error={{.State.Error}}' api-1
/api-1 OOMKilled=true ExitCode=137 Error=

Що це означає: OOMKilled=true — це ваш курящий пістолет; код виходу 137 вказує на SIGKILL.
Рішення: Розглядайте це як перевищення ліміту пам’яті, якщо логи ядра не показують OOM на рівні системи.

Завдання 2: Перевірити лічильник перезапусків і час останнього завершення (корелюйте з навантаженням/деплоєм)

cr0x@server:~$ docker inspect -f 'RestartCount={{.RestartCount}} FinishedAt={{.State.FinishedAt}} StartedAt={{.State.StartedAt}}' api-1
RestartCount=6 FinishedAt=2026-01-02T01:58:11.432198765Z StartedAt=2026-01-02T01:58:13.019003214Z

Що це означає: Часті перезапуски поруч зазвичай вказують на жорстку петлю помилок, а не на одноразовий випадок.
Рішення: Заморозьте pipeline деплоїв і зберіть судову інформацію до наступного перезапуску, щоб її не перезаписали.

Завдання 3: Прочитати оповідь ядра про OOM

cr0x@server:~$ sudo dmesg -T | tail -n 20
[Thu Jan  2 01:58:11 2026] Memory cgroup out of memory: Killed process 24819 (python) total-vm:1328452kB, anon-rss:812340kB, file-rss:12044kB, shmem-rss:0kB, UID:1000 pgtables:2820kB oom_score_adj:0
[Thu Jan  2 01:58:11 2026] oom_reaper: reaped process 24819 (python), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

Що це означає: «Memory cgroup out of memory» вказує на OOM на рівні контейнера, а не на весь хост.
Рішення: Сфокусуйтеся на лімітах контейнера і поведінці пам’яті по контейнеру — не на ємності вузла, принаймні поки що.

Завдання 4: Підтвердити, чи хост пережив system OOM

cr0x@server:~$ sudo dmesg -T | egrep -i 'out of memory|oom-killer|Killed process' | tail -n 10
[Thu Jan  2 01:58:11 2026] Memory cgroup out of memory: Killed process 24819 (python) total-vm:1328452kB, anon-rss:812340kB, file-rss:12044kB, shmem-rss:0kB, UID:1000 pgtables:2820kB oom_score_adj:0

Що це означає: Ви бачите подію cgroup OOM, але немає загальносистемного прологу «Out of memory: Kill process …».
Рішення: Ймовірно, не потрібно евакуювати вузол; потрібно зупинити цей контейнер від досягнення стіни.

Завдання 5: Перевірити налаштовані ліміти пам’яті контейнера

cr0x@server:~$ docker inspect -f 'Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}} MemoryReservation={{.HostConfig.MemoryReservation}}' api-1
Memory=1073741824 MemorySwap=1073741824 MemoryReservation=0

Що це означає: Жорсткий ліміт — 1GiB. Ліміт swap рівний ліміту пам’яті (фактично немає swap понад RAM для цього cgroup).
Рішення: Якщо процес потребує короткочасних стрибків понад 1GiB, або піднімайте ліміт, або зменшуйте ці стрибки.

Завдання 6: Визначити версію cgroup (v1 vs v2), щоб читати правильні файли

cr0x@server:~$ stat -fc %T /sys/fs/cgroup
cgroup2fs

Що це означає: cgroup2fs вказує на cgroups v2.
Рішення: Використовуйте memory.current, memory.max і memory.events для точних сигналів.

Завдання 7: Зіставити контейнер з шляхом cgroup і прочитати поточне використання (v2)

cr0x@server:~$ CID=$(docker inspect -f '{{.Id}}' api-1); echo $CID
b1d6e0b6e4c7c14e2c8c3ad3b0b6e9b7d3c1a2f7d9f5d2e1c0b9a8f7e6d5c4b3
cr0x@server:~$ CG=$(systemctl show -p ControlGroup docker.service | cut -d= -f2); echo $CG
/system.slice/docker.service
cr0x@server:~$ sudo find /sys/fs/cgroup$CG -name "*$CID*" | head -n 1
/sys/fs/cgroup/system.slice/docker.service/docker/b1d6e0b6e4c7c14e2c8c3ad3b0b6e9b7d3c1a2f7d9f5d2e1c0b9a8f7e6d5c4b3
cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.current
965312512

Що це означає: Приблизно 920MiB вичерпано прямо зараз (байти). Якщо ліміт — 1GiB, ви близько.
Рішення: Якщо це steady-state, підніміть ліміт. Якщо росте — почніть розслідування витоку/спайку.

Завдання 8: Перевірити ліміт пам’яті та лічильник подій OOM (v2)

cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.max
1073741824
cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.events
low 0
high 12
max 3
oom 3
oom_kill 3

Що це означає: cgroup тричі досягав memory.max; сталося три OOM-вбивства. high ненульове,
що вказує на повторний тиск на звільнення пам’яті.
Рішення: Це не випадковість. Підніміть бюджет або зменшіть пікові вимоги пам’яті (або те й інше).

Завдання 9: Подивитися, які процеси в контейнері використовують пам’ять

cr0x@server:~$ docker top api-1 -o pid,ppid,cmd,rss
PID    PPID   CMD                          RSS
25102  25071  python /app/server.py         612m
25134  25102  python /app/worker.py         248m
25160  25102  /usr/bin/ffmpeg -i pipe:0     121m

Що це означає: Головний процес плюс воркер і нативний бінар (ffmpeg) ділять бюджет пам’яті контейнера.
Рішення: Якщо «сайдкароподібний» підпроцес без обмежень, обмежте конкурентність або винесіть його в окремий контейнер з власними лімітами.

Завдання 10: Швидко подивитися тренд використання пам’яті контейнера (Docker stats)

cr0x@server:~$ docker stats --no-stream api-1
CONTAINER ID   NAME    CPU %     MEM USAGE / LIMIT     MEM %     NET I/O     BLOCK I/O   PIDS
b1d6e0b6e4c7   api-1   184.21%   972.4MiB / 1GiB       94.96%    1.3GB/1.1GB  2.8GB/1.9GB  28

Що це означає: Контейнер працює близько ~95% від ліміту. Це не «ефективно», це «один запит від катастрофи».
Рішення: Негайно підвищіть ліміт або зменшіть навантаження. Потім розберіться, чому він так близько.

Завдання 11: Розрізнити анонімну пам’ять vs файловий кеш (cgroup v2 memory.stat)

cr0x@server:~$ sudo egrep 'anon|file|shmem|slab' /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.stat | head -n 20
anon 843018240
file 76292096
shmem 0
slab 31260672
file_mapped 21434368

Що це означає: Більшість використання — анонімна пам’ять (heap/stack/native), а не page cache.
Рішення: Зосередьтеся на алокаціях додатка: пошук витоку, налаштування рантайму, обмеження розміру payload, конкурентності.

Завдання 12: Перевірити, чи /dev/shm тихо не надто малий (або надто великий)

cr0x@server:~$ docker exec api-1 df -h /dev/shm
Filesystem      Size  Used Avail Use% Mounted on
shm              64M   12M   52M  19% /dev/shm

Що це означає: Розмір shm за замовчуванням — 64MiB, якщо не налаштовано. Деякі робочі навантаження (Chromium, розширення Postgres, ML inference) потребують більше.
Рішення: Якщо додаток активно використовує shared memory, встановіть --shm-size і врахуйте це в бюджеті пам’яті.

Завдання 13: Перевірити тиск пам’яті на хості (щоб виключити ризик OOM вузла)

cr0x@server:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:            62Gi        49Gi       1.2Gi       1.1Gi        12Gi        8.4Gi
Swap:            0B          0B          0B

Що це означає: На хості мало «free», але пристойно «available» завдяки кешу; swap вимкнено.
Рішення: Вузол наразі не OOM, але ви працюєте з тонкими запасами. Не запускайте на ньому нелімітовані контейнери.

Завдання 14: Побачити ліміти по контейнерам швидко, щоб виявити «нелімітовані» міни

cr0x@server:~$ docker ps -q | xargs -n1 docker inspect -f '{{.Name}} mem={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}}'
/api-1 mem=1073741824 swap=1073741824
/worker-1 mem=0 swap=0
/cache-1 mem=536870912 swap=536870912

Що це означає: mem=0 означає відсутність ліміту пам’яті. Такий контейнер може з’їсти вузол.
Рішення: Спочатку виправте нелімітований контейнер. Один такий процес може перетворити container OOM у node OOM.

Завдання 15: Виявити, чи контейнер убитий, але одразу перезапущений політикою

cr0x@server:~$ docker inspect -f 'RestartPolicy={{.HostConfig.RestartPolicy.Name}} MaxRetry={{.HostConfig.RestartPolicy.MaximumRetryCount}}' api-1
RestartPolicy=always MaxRetry=0

Що це означає: «always» рестарт робить відмови помітними лише якщо ви дивитесь на перезапуски; інакше це перетворює OOM у «саме виправилось».
Рішення: Залишайте політики рестарту, але додайте алерти на частоту перезапусків і стан OOMKilled.

Завдання 16: Підтвердити уявлення контейнера про пам’ять (важливо для налаштування JVM/Go)

cr0x@server:~$ docker exec api-1 sh -lc 'cat /proc/meminfo | head -n 3'
MemTotal:       65843056 kB
MemFree:         824512 kB
MemAvailable:   9123456 kB

Що це означає: Деякі процеси все ще «бачать» пам’ять хоста через /proc/meminfo, залежно від ядра/рантайму.
Рішення: Переконайтеся, що ваш рантайм орієнтується на cgroup (сучасні JVM і Go зазвичай так роблять). Якщо ні — встановіть явні heap-флаги.

Три корпоративні міні-історії з фронту OOM

Міні-історія 1: Інцидент через неправильне припущення

Середня SaaS-компанія мігрувала кілька endpoint-ів моноліту в блискучий новий «API» контейнер. Команда встановила ліміт пам’яті 512MiB, бо
сервіс «лише обробляв JSON» і «пам’ять в основному для баз даних». Це припущення протрималось приблизно один робочий день.

Сервіс використовував популярний Python-web стек з кількома нативними залежностями. За нормального трафіку він зависав у межах 250–300MiB. Під час
маркетингового спайку почала приходити незвично велика payload (ще валідний JSON; просто гігантський). Тіла запитів парсились, копіювались і
валідувались кілька разів. Пікова пам’ять росла кроками із зростанням конкурентності.

Відмови виглядали як випадкові перезапуски. Логи обривались на піврядка. APM показував неповні трейси і дивні прогалини. Хтось звинувачував балансировщик.
Хтось — останній деплой. Інцидент-координатор зробив нудну справу: docker inspect, потім dmesg.
І ось воно: cgroup OOM kills.

Виправлення вимагало двох кроків. По-перше, підняли ліміт, щоб зупинити кровотечу, і зменшили конкурентність воркерів. По-друге, додали обмеження розміру payload і стрімінговий парсинг для великих тіл. Урок — не «давайте всім більше RAM». Урок — ліміти пам’яті мають встановлюватись з урахуванням гіршого випадку вводу, а не середньої поведінки.

Міні-історія 2: Оптимізація, що зіграла злий жарт

Платформа, що поруч з фінансами, одержима p99-латентністю. Інженер помітив часті читання з диска і вирішив «прогріти кеш» агресивно на стартапі: прочитати велику кількість довідкових даних, передобчислити результати, тримати все в пам’яті для швидкості. У staging це працювало прекрасно.

У продакшні було інакше: кілька реплік перезапускались під час деплоїв, усі одночасно грілися. У вузлі було багато RAM, але кожен контейнер мав строгий ліміт 1GiB, бо команда намагалася щільно пакувати інстанси. Фаза прогріву коротко підштовхнула пам’ять до ~1.1GiB на інстанс через тимчасові алокації під час парсингу й побудови індексів. Тимчасові алокації — це теж алокації; ядро байдуже, що ви мали на увазі добре.

Схема OOM була жорстока: контейнери помирали під час прогріву, перезапускались, знову грілись, знову вмирали. Класична crash loop, тільки без стеку.
Оптимізація стала самонанесеною атакою на доступність під час деплоїв.

Виправлення: зробити прогрів інкрементальним і обмеженим. Також ввели бюджет пам’яті на запуск: виміряти пікове використання під час прогріву і встановити ліміт контейнера з запасом. Робота над продуктивністю, що ігнорує бюджети пам’яті — не про продуктивність; це просто відмова з кращими графіками.

Міні-історія 3: Скучно, але правильно — і це врятувало

Логістична компанія запускала Docker-флот на кількох потужних вузлах. Нічого вигадливого. Їх найбільш «інноваційна» звичка — щотижневий аудит: кожен контейнер мав явні CPU і memory ліміти, і кожна служба мала погоджений бюджет пам’яті з невеликим буфером.

Якось оновлення бібліотеки постачальника внесло витік у рідко вживаний код. Пам’ять повільно росла — десятки мегабайт на годину — поки сервіс не досяг ліміту 768MiB і не був OOM-killed. Але радіус ураження залишився малим: лише цей контейнер помер, а не вузол.

Їхня сигналізація була не «контейнер упав», що запізно. Вона була «пам’ять контейнера на 85% ліміту протягом 10 хвилин». На чергуванні помітили тренд рано, відкотили бібліотеку і відкрили задачу на глибше розслідування. Користувачі помітили короткий спалах, а не багатосервісний інцидент.

Скучна практика — ліміти скрізь, перегляд бюджетів та тривоги на наближення до ліміту — не запобігла багу. Вона не дозволила багу стати платформним інцидентом. Оце і є стандарт.

Типові помилки: симптом → корінь → виправлення

1) «Контейнер перезапускається з кодом 137, але прапора OOMKilled немає»

Симптом: Вихід 137, перезапуски, немає явного OOM-індикатора у стані Docker.
Корінь: Його вбили інші причини: ручний kill, таймаут оркестратора, kill на хості або супервіжер, що відправив SIGKILL після закінчення grace SIGTERM.
Виправлення: Перевірте dmesg на рядки OOM і події оркестратора. Якщо доказів OOM немає — розглядайте як примусове вбивство і перевіряйте health checks та timeouts на зупинку.

2) «Ми встановили ліміт пам’яті, але вузол все одно OOM»

Симптом: Хост переживає system OOM, помирає кілька контейнерів, іноді вбивається dockerd/containerd.
Корінь: Деякі контейнери без лімітів, або сумарні ліміти перевищують ємність вузла, або на хості є великі споживачі поза контейнерами. Також: page cache і пам’ять ядра не узгоджуються з вашою таблицею.
Виправлення: Наведіть ліміти для всіх контейнерів, зарезервуйте пам’ять для ОС і припиніть перезапаковувати. Якщо дозволяєте swap — робіть це свідомо і моніторьте. Якщо працюєте без swap — будьте суворішими з запасом.

3) «Ми збільшили ліміт і все одно OOM»

Симптом: Та сама схема, більший номер, та сама смерть.
Корінь: Витік пам’яті або необмежене навантаження (глибина черги, конкурентність, розмір payload, кешування). Підняття ліміту лише змінює час до відмови.
Виправлення: Обмежте конкурентність, розмір черги, розмір payload і інструментуйте пам’ять. Потім профілюйте: heap dumps, alloc профайлинг, відстеження нативної пам’яті для змішаних навантажень.

4) «OOM трапляється лише під час деплоїв / перезапусків»

Симптом: Стабільно дні, потім помирає під час rollout.
Корінь: Стартап-спайк: прогрів кешу, JIT-компіляція, міграції, побудова індексів або thundering herd, коли багато реплік одночасно прогріваються.
Виправлення: Рознесіть рестарти, обмежте роботу під час запуску і встановіть ліміти на пікове стартове використання, не лише на steady state. Розгляньте readiness-гейти, які не пропускають трафік до завершення прогріву.

5) «OOM під час I/O навантаження, хоча heap виглядає нормальним»

Симптом: Метрики heap стабільні; OOM з’являється при інтенсивних читаннях/записах.
Корінь: Page cache і memory-mapped файли, або tmpfs (включаючи /dev/shm), зараховані в cgroup. Іноді також буфери ядра для мережі або сховища.
Виправлення: Порівняйте файл memory.stat з anon-використанням. Зменшіть mmap-футпринт, налаштуйте стратегію кешування або підніміть ліміт, щоб включити бюджет кешу. Переконайтесь, що tmpfs/shm налаштовано свідомо.

6) «Ми відключили swap і тепер бачимо більше OOM»

Симптом: Після відключення swap кількість OOM-kill зросла.
Корінь: Swap раніше маскував перепідписування пам’яті. Без swap система досягає жорстких меж швидше.
Виправлення: Не вмикайте swap лише як рефлекс. Спочатку виправте бюджети контейнерів і зменшіть перевантаження. Якщо swap потрібен, встановіть SLO-орієнтовані правила: які навантаження можуть swap-итись і як ви виявите, коли swap стає помітним для користувача.

7) «OOM вбиває не той процес у контейнері»

Симптом: Помирає допоміжний процес, або головний процес помирає першим несподівано.
Корінь: OOM badness scoring плюс пер-процесне використання пам’яті в момент вбивства. Контейнери з кількома процесами ускладнюють вибір жертви.
Виправлення: Віддавайте перевагу одному головному процесу на контейнер. Якщо потрібно кілька, ізолюйте пам’ятко-важкі помічники в окремі контейнери або змініть архітектуру так, щоб одноразове вбивство не спричиняло каскадну відмову.

Жарт №2: OOM killer — єдиний колега, який ніколи не пропускає дедлайн; на жаль, у нього також м’які навички гільйотини.

Контрольні списки / покроковий план

Контрольний список: встановити ліміти пам’яті, що запобігають тихим збоям

  1. Виміряйте steady-state пам’ять принаймні за один повний цикл трафіку (день/тиждень залежно від навантаження).
  2. Виміряйте пікову пам’ять під час деплоїв/стартапу, піків трафіку і фонових задач.
  3. Оберіть жорсткий ліміт з запасом вище піка (не вище середнього). Якщо не можете дозволити запас — ваша щільність вузлів нереалістична.
  4. Визначте політику swap: ніякого для сервісів чутливих до латентності; обмежений swap для пакетних робіт, якщо припустимо.
  5. Урахуйте накладні витрати: треди, native-бібліотеки, memory-maps, page cache, tmpfs і метадані рантайму.
  6. Встановіть алерти на 80–90% ліміту контейнера, а не лише на перезапуски.
  7. Відстежуйте OOM-події через логи ядра і лічильники cgroup.

Покроковий план: коли контейнер OOM-иться в продакшні

  1. Підтвердьте OOM: docker inspect на OOMKilled і dmesg на рядки cgroup OOM.
  2. Стабілізуйте: тимчасово підніміть ліміт або зменшіть конкурентність/навантаження. Якщо це crash loop, розгляньте призупинення деплоїв і зниження трафіку до ураженої інстанції.
  3. Класифікуйте: витік vs спайк vs кеш. Використовуйте memory.stat щоб розділити anon vs file.
  4. Збирайте докази: захопіть heap dumps/профілі, розміри запитів, глибини черг і кореляцію з деплоєм.
  5. Виправте тригер: додайте обмеження (payload, concurrency, cache size), виправте витік або зменшіть стартовий спайк.
  6. Зробіть так, щоб повторення було складнішим: застосуйте ліміти для всіх сервісів і закріпіть бюджети у CI/CD або політиках.

Контрольний список: уникнути перетворення контейнерного OOM у node OOM

  1. Ніяких нелімітованих контейнерів на спільних вузлах (якщо це не спеціальна ізольована група вузлів).
  2. Резервуйте пам’ять для хоста: системні демони, файловий кеш, моніторинг і накладні витрати рантайму.
  3. Не сумуйте жорсткі ліміти до 100% RAM; залишайте реальний запас.
  4. Моніторьте тиск пам’яті на вузлі і активність reclaim, а не лише «вільну пам’ять».
  5. Визначте політику swap на рівні вузла і переконайтесь, що вона відповідає SLO навантажень.

Питання й відповіді (те, що питають одразу після інциденту)

1) У чому різниця між OOMKilled і кодом виходу 137?

OOMKilled — це Docker, який каже, що ядро вбило контейнер через memory cgroup OOM. Код виходу 137 означає, що процес отримав SIGKILL,
що узгоджується з OOM, але також може бути наслідком інших примусових вбивств. Завжди підтверджуйте через dmesg і лічильники cgroup.

2) Якщо на хості є вільна пам’ять, чому мій контейнер OOM?

Бо ви надали контейнеру власний бюджет через cgroups. Контейнер може досягти --memory і бути вбитим, навіть якщо на вузлі є RAM.
Це межа ізоляції, яка працює — за умови, що ви правильно встановили бюджет.

3) Чи ставити ліміти пам’яті на кожен контейнер?

Так. Production-вузли без лімітів по контейнерах рано чи пізно стануть «одним простим деплоєм до OOM вузла». Якщо вам потрібен один особливий контейнер без ліміту, виділіть для нього окремий вузол або принаймні обговоріть ризики.

4) Скільки запасу залишати?

Достатньо, щоб пережити відомі спалахи плюс запас на невідомі. Практично: виміряйте пік і додайте буфер. Якщо не можете додати буфер — зменшіть піки через caps або зменшіть щільність.
Запас дешевший за інциденти.

5) Чи хороший swap для Docker-контейнерів?

Swap — це компроміс. Для сервісів, чутливих до латентності, swap часто перетворює «жорстку відмову» на «повільне руйнування», що може бути гіршим. Для пакетних задач обмежений swap може підвищити пропускну здатність, уникаючи kill-штормів. Вирішуйте для кожного навантаження і моніторьте major page faults і латентність.

6) Чому мої логи обриваються прямо перед крашем?

OOM-kill зазвичай — SIGKILL: процес не може скинути буфери. Якщо потрібна краща «останНЯ» спостережуваність, частіше скидайте логи, записуйте критичні події синхронно (обережно) або використовуйте зовнішнього колектора логів, який не залежить від благодійності вмираючого процесу.

7) Чи можна примусити ядро вбити інший процес?

Усередині single-container cgroup вибір жертви обмежений процесами в цій cgroup. Іноді можна вплинути на поведінку через oom_score_adj, але це ненадійний спосіб «вибрати те, що треба». Справжнє виправлення — архітектурне: уникати мульти-процесних контейнерів для пам’ятко-важких помічників або ізолювати їх.

8) Працюють чи ні обмеження пам’яті в Docker Compose?

Вони працюють, коли розгортання відбувається у режимі, який їх підтримує. Compose у classic mode мапить на runtime-обмеження Docker; у swarm mode обмеження виражаються інакше. Єдина безпечна відповідь — перевірити через docker inspect і файли cgroup на хості.

9) Як зрозуміти, чи це витік пам’яті?

Шукайте монотонне зростання з плином часу при приблизно однаковому навантаженні, що завершується OOM. Підтверджуйте через метрики рантайму (heap, RSS) і лічильники cgroup. Якщо зростання лише під час певних подій — швидше спайк або необмежений кеш.

10) Чому у контейнера /proc/meminfo виглядає як у хоста?

Залежно від налаштувань ядра та рантайму,報ортинг /proc може відображати хостові значення, тоді як enforcement все одно відбувається через cgroups. Сучасні рантайми часто читають ліміти cgroup безпосередньо, але не припускайте цього: встановлюйте явні флаги пам’яті для рантаймів, що поводяться некоректно.

Висновок: наступні кроки, які дійсно зменшують відмови

Контейнерні OOM — один з небагатьох режимів відмов, які як передбачувані, так і запобіжні. Передбачувані, бо ліміт явний.
Запобіжні, бо ви можете його підібрати, спостерігати і формувати поведінку до того, як ядро відтягне вилку.

Робіть це в такому порядку:

  1. Застосуйте ліміти пам’яті для кожного контейнера і знайдіть offenders з mem=0.
  2. Додайте алерти на наближення до ліміту (80–90%) і на події OOMKilled, а не лише на перезапуски.
  3. Виміряйте пікову пам’ять під час деплою і прогріву; встановлюйте ліміти на основі реального піка, а не спокійних моментів.
  4. Обмежте необмежену поведінку: розмір payload, глибину черги, конкурентність, ріст кешу.
  5. Визначте політику swap усвідомлено, а не успадковуйте те, що є на вузлі.

Обмеження пам’яті не роблять ваш сервіс «безпечним». Вони роблять вашу відмову контейнеризованою. Ось у чому суть контейнерів: не запобігати відмовам, а не давати їм розповзатися, як плітки в офісі.

← Попередня
Debian 13: помилка в fstab перешкоджає завантаженню — найшвидше виправлення в режимі rescue
Наступна →
Proxmox «pmxcfs не змонтовано»: чому /etc/pve порожній і як відновити

Залишити коментар