Продуктивність Docker: чому контейнери гальмують під навантаженням (і це не CPU)

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

Все виглядає нормально. CPU на 30%. Load average не лякає. Але запити повзають, p99 вибухає, а ваш канал для чергових наповнюється повідомленнями типу «воно впало?», що скорочують тривалість життя.

Це пастка продуктивності Docker: ви дивитеся на CPU, бо це вимірювано й заспокійливо, тоді як справжня проблема здебільшого ховається в чомусь бруднішому — сховище, мережа, тиск пам’яті, обмеження ядра або непомітна політика cgroup, яка душить вас, мов бюрократ із печаткою.

Якщо це не CPU, то що?

Коли контейнер «гальмує», це рідко означає, що ваш код раптово став дурним. Це означає, що робота контейнера чекає: чекає на скидання на диск, чекає на стек мережі, чекає на DNS, чекає на page fault, чекає на блокування всередині ядра, чекає на контролер cgroup, чекає, поки драйвер логування перестане блокувати записи.

Графіки CPU підступні, бо вони чисті. Вони також неповні. Система може бути повільною при низькому завантаженні CPU, якщо потоки заблоковані в uninterruptible sleep (зазвичай I/O), затримані в runqueue через throttling або виснажені через reclaim пам’яті. У контейнерах ці проблеми простіше створити, бо ви додали:

  • Об’єднані/overlay файлові системи (copy-up, метадані такий собі хаос).
  • Додаткові мережеві шари (veth пари, мости, NAT, conntrack).
  • Додаткові площини контролю (cgroups, namespaces, ліміти, квоти).
  • «Корисні» значення за замовчуванням (драйвер логування, конфігурація DNS, драйвер зберігання).

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

Одна цитата, бо вона досі влучна: «Надія — це не стратегія.» — генерал Gordon R. Sullivan

Ось суть: якщо ви ставитесь до продуктивності як до одного регулятора, ви й надалі відправлятимете латентність у продакшн. Якщо ви ставитеся до неї як до гри в виключення — вимірюйте, ізолюйте, змінюйте одну річ, вимірюйте знову — ви припините гадати й почнете фіксити.

Цікаві факти та контекст (версія «чому ми тут»)

  • Контейнери не почалися з Docker. Linux мав namespaces і cgroups роками раніше; Docker популяризував упаковку та робочий процес, не ядрові примітиви.
  • Cgroups з’явилися, бо «nice» було недостатньо. Традиційний пріоритет процесів Unix не давав надійної ізоляції багатокористувацьких середовищ щодо пам’яті та I/O; cgroups створено для її примусового застосування.
  • Overlay/union файлові системи створені для зручності. Вони віддають трохи простоти файлової системи заради складаних шарів. Ця trade-off проявляється при навантаженнях із великою кількістю метаданих.
  • Ранній Docker широко використовував AUFS. AUFS був швидким в одних випадках і болісним в інших; екосистема поступово перейшла на overlay2 із покращенням підтримки в ядрі.
  • Conntrack — це таблиця станів, а не магія. NAT і conntrack потребують пам’яті й CPU. Під сплесками таблиця заповнюється, і ядро починає скидати або таймаутити потоки.
  • DNS у контейнерах часто відрізняється від DNS на хості. Вбудований DNS Docker та поведінка резолвера можуть посилювати таймаути, якщо upstream DNS повільний.
  • «fsync-шторми» — стара проблема в новому обгортанні. Бази даних і журнальні додатки, що часто викликають fsync, можуть зруйнувати I/O, якщо бекенд сховища не розрахований на стабільну латентність.
  • Логування завжди було вбивцею пропускної здатності. У часи syslog синхронне логування могло зупинити додатки. Драйвери логування контейнерів можуть відтворити цю біль, якщо не бути обережними.
  • Реаллок пам’яті в Linux — це подія продуктивності. Навіть без OOM kills прямий reclaim та свопінг можуть перетворити «нормальну» латентність на пилку.

Швидкий плейбук діагностики (перший/другий/третій)

Перший: доведіть, чи ви чекаєте на I/O, пам’ять чи мережу

  • Перевірте заблоковані задачі та iowait: якщо потоки накопичуються в D-стані або iowait зростає, ваш графік «CPU низький» бреше через упущення.
  • Перевірте тиск пам’яті: якщо йде reclaim, swap або ви потрапляєте в memory.high, ви можете отримувати латентність без аварій.
  • Перевірте мережеві симптоми: retransmits, conntrack drops і DNS-латентність створюють затримки додатків, що видаються «випадковою повільністю».

Другий: локалізуйте — на всьому хості, в одному контейнері чи на одному монту

  • В масштабі хоста: насичення пристрою, конфлікт журналу файлової системи, повна таблиця conntrack, тиск пам’яті ноди.
  • Один контейнер: лавина логів, надто маленька CPU квота що спричиняє throttling, занадто малий ulimit, шумний том-сусід.
  • Один монтувальний шлях/шлях: шлях overlay2 copy-up, bind mount на повільне мережеве сховище, параметри тома, невідповідні для робочого навантаження.

Третій: тимчасово приберіть або обійдіть шари

  • Запустіть те саме навантаження з tmpfs для тимчасових даних, щоб перевірити, чи диск — вузьке місце.
  • Перекиньте логування на none коротко в staging-відтворенні, щоб дізнатися, чи блокує логування.
  • Запустіть з host networking для тесту (де безпечно), щоб ізолювати bridge/NAT/conntrack.
  • Протестуйте навантаження на single writable layer (volume) замість overlay2 для «гарячого» шляху.

Швидкість має значення під час інциденту. Вам не потрібна ідеальна істина; вам потрібне наступне обмеження.

Вузькі місця сховища та файлової системи (overlay2, fsync і податок, який ви забули)

Диск — зазвичай підозрюваний, бо це найпростіший спосіб створити черги. Сучасні CPU можуть обганяти сховище на порядок. Контейнери не змінюють фізику; вони просто додають кілька креативних способів наступити на ті самі граблі.

Overlay2: достатньо швидкий, поки не перестає бути

Overlay2 поєднує стек тільки для читання з Writable upper layer. Читання часто потрапляє в кеш і відчувається чудово. Записи можуть спричинити copy-up: перший раз, коли ви модифікуєте файл з нижнього шару, overlay має скопіювати його у writable layer. Це не лише копіювання даних; це операції над метаданими, перевірки дозволів і іноді несподівано великий обсяг роботи для того, що ви думали як «малий запис».

Проблеми overlay2 проявляються як:

  • Повільна інсталяція пакетів або кроки збірки всередині контейнерів.
  • Стрибки латентності, коли додаток записує у шляхи, вбудовані в образ.
  • Високі IOPS на метаданих (stat, open, rename, unlink), а не великий послідовний throughput.

Журнали, бар’єри та чому fsync — це контракт продуктивності

Додатки, що викликають fsync (бази даних, черги повідомлень, усе, що притворюється durable), просять стек зберігання зафіксувати роботу на стабільному носії. На хорошому NVMe це нормально. На мережевому сховищі, споживчих SSD з проблемною прошивкою або перевантажених томах це перетворюється на чергу. Контейнер не повільний; сховище чесно повідомляє про затримку.

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

Bind mounts і «я не знав, що це NFS»

Bind mounts прекрасні. Вони також — рушниця під ноги, коли змонтований шлях знаходиться на повільному або непередбачуваному бекенді: NFS, SMB, FUSE-шари, шифрування або хмарний блоковий диск, що тихо обмежує швидкість. Контейнер може бути «локальним» і все одно записувати в «десь далеко».

Томи: краще для гарячих шляхів запису

Якщо ваш додаток часто записує, розміщуйте write-heavy директорії на Docker volume або bind mount на присвячену файлову систему. Тримайте writable layer контейнера якнайхолоднішою. Overlay2 — пристойний дефолт, але не файлова система високопродуктивної бази даних.

Жарт №1 (коротко, по темі): Контейнери — як переїзд у малу квартиру: можна жити, але не намагайтеся запустити пилораму на кухні.

Тиск пам’яті і проблема «це не OOM, але воно вмирає»

Проблеми з пам’яттю не завжди заявляють про себе OOM kill. Насправді найнеприємніші баги латентності відбуваються до OOM. Це коли ядро дуже старається вас утримати в живих, роблячи reclaim сторінок, compacting пам’яті і іноді своплячи щось важливе.

Як виглядає тиск пам’яті в контейнерах

  • Стрибки латентності під час сплесків трафіку з подальшим відновленням після спаду навантаження.
  • Затримки GC погіршуються (бо алокації тригерять reclaim і page faults).
  • Зростання дискового I/O без очевидної причини (своп або writeback).
  • CPU залишається помірним, але додаток відчувається «застряглим».

Обмеження пам’яті cgroup змінюють поведінку ядра

Коли ви встановлюєте memory limits, ви не лише забороняєте контейнеру використовувати забагато RAM. Ви змінюєте, коли і як відбувається reclaim. Якщо контейнер близький до ліміту, він може частіше потрапляти в direct reclaim, що блокує потоки додатку. Якщо увімкнений swap, може бути своп всередині cgroup, що часто виглядає як випадкова латентність.

Також: file cache має значення. Позбавлення контейнера пам’яті може зменшити ефективність кешування і перенести навантаження на диск. CPU сидить просто, а ваш стек зберігання виконує інтерпретаційний танець.

Мережа: латентність, conntrack і DNS, що виглядає невинно

Мережева продуктивність у контейнерах часто «достатня», поки одного дня не стає ні. Режим відмови під навантаженням зазвичай не про пропускну здатність. Це про латентність і втрати: retransmits, черги та таймаути.

Накладні витрати bridge/NAT/conntrack

Стандартна Docker bridge конфігурація зазвичай включає NAT і connection tracking. Кожне з’єднання стає станом у conntrack. Якщо у вас високий churn з’єднань (короткоживучі HTTP виклики, service mesh, агресивні клієнти), таблиця conntrack може заповнитися. Коли вона повна, ядро починає скидати нові з’єднання або таймаутити їх. Ваш додаток повідомляє «upstream timeout», а ви дивитеся на CPU бо воно низьке.

DNS: смерть від тисячі 5-секундних таймаутів

Проблеми з DNS — найменш діагностований джерело затримок у контейнерах. Контейнер, який іноді не може розв’язати імена, поводиться так, ніби він «повільний», а не «зламаний». Резолвер чекає. Ваші потоки чекають. CPU ввічливо сидить.

Неправильні налаштування включають:

  • Upstream DNS перевантажений або обмежений по швидкості.
  • Занадто багато search domains, що викликають повторні запити.
  • Поведінка glibc resolver, що створює послідовні запити і повільне fallback.
  • Вбудований DNS Docker під навантаженням (особливо з великою кількістю контейнерів та частими рестартами).

Cgroups: throttling, квоти і чому «ліміти» не безкоштовні

Cgroups — це неспівавторизована причина, чому контейнери можуть ділити хост без миттєвого конфлікту. Вони також надійний спосіб створити «загадкову повільність».

Throttling CPU

Якщо ви задали CPU limits, ядро примусово застосовує їх через quotas per period. Під навантаженням контейнер може швидко витратити свою квоту й тоді бути throttled до наступного періоду. Наслідок: процес не використовує CPU, бо йому не дозволено. Ваш графік CPU виглядає спокійно. Графік латентності — ні.

I/O контролі (blkio / io controller)

Залежно від версії cgroup і конфігурації, контейнери можуть бути зважені або обмежені для I/O. Навіть без явних I/O лімітів шумні сусіди можуть насичувати пристрій, уповільнюючи всіх. З лімітами ви випадково можете обрізати базу даних і потім звинувачувати мережу.

Обмеження PID і ліміти дескрипторів файлів

Не всі ліміти в cgroups. PIDs і ulimits тихо можуть обмежувати конкурентність. Коли ви вичерпали дескриптори файлів, додатки можуть зависати, не приймати нові з’єднання або зациклитися на ретраєлах. Це не CPU. Це ядро, яке повторно каже вам «ні».

Логування: обрив продуктивності під виглядом спостережуваності

Логування — це I/O. Логування також часто є достатньо синхронним, щоб завдати шкоди. За замовчуванням Docker json-file логування може стати вузьким місцем, коли контейнер генерує великий обсяг логів. Демон записує логи на диск. Диск перевантажується. Усе, що хоче диск, тепер чекає. Іноді контейнер блокується на stdout/stderr писаннях, якщо буфери заповнилися.

Це одна з тих проблем, що здаються чаклунством: «ми додали більше debug логів, і сервіс став повільнішим». Так. Ви перетворили продакшн-вузол на друкарську машинку.

Жарт №2 (коротко, по темі): Дебаг-логування в продакшні — як додати друге кермо у машину: технічно більше контролю, практично більше аварій.

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

Це не академічно. Це команди, які ви виконуєте, коли хтось каже «Docker повільний», і ви хочете докази перед тим, як щось міняти.

Завдання 1: Перевірте, чи ядро кричить про заблоковані задачі

cr0x@server:~$ dmesg -T | tail -n 20
[Mon Feb  3 10:14:52 2026] INFO: task myservice:23144 blocked for more than 120 seconds.
[Mon Feb  3 10:14:52 2026]       Tainted: G        W  OE     5.15.0-97-generic #107-Ubuntu
[Mon Feb  3 10:14:52 2026] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.

Що це означає: Процес завис, зазвичай в uninterruptible sleep (I/O). Це сигнал від ядра.

Рішення: Не сперечайтеся про CPU. Негайно переходьте до перевірки I/O та файлової системи (iostat, pidstat, латентність бекенду зберігання).

Завдання 2: Визначте контейнери з високим числом рестартів або дивним статусом

cr0x@server:~$ docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}\t{{.Image}}'
NAMES        STATUS                     RUNNING FOR     IMAGE
api          Up 3 hours (healthy)       3 hours         myorg/api:4.2.1
worker       Up 3 hours                 3 hours         myorg/worker:4.2.1
sidecar      Restarting (1) 5s ago      2 minutes       myorg/sidecar:1.9.0

Що це означає: Рестарт-цикли можуть створювати навантаження (DNS-запити, churn образів, лог-спам) і маскувати справжнє вузьке місце.

Рішення: Спершу стабілізуйте падаючі контейнери. Налаштування продуктивності для «флапаючого» процесу — це театр продуктивності.

Завдання 3: Перевірте per-container CPU throttling

cr0x@server:~$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
NAME     CPU %     MEM USAGE / LIMIT     NET I/O        BLOCK I/O
api      35.12%    1.2GiB / 2GiB         1.1GB / 980MB  18GB / 9GB
worker   12.44%    800MiB / 1GiB         200MB / 210MB  3GB / 1GB

Що це означає: Це підказка, а не доказ. Високий CPU% тут не показує throttling; він показує використання.

Рішення: Якщо підозрюєте ліміти, перевірте лічильники throttling у cgroup (наступне завдання). Не робіть висновку, що «35%» означає запас.

Завдання 4: Прочитайте лічильники CPU throttling (cgroup v2)

cr0x@server:~$ CID=$(docker inspect -f '{{.Id}}' api)
cr0x@server:~$ CGP=$(find /sys/fs/cgroup -name "*$CID*" -type d 2>/dev/null | head -n 1)
cr0x@server:~$ cat "$CGP/cpu.stat"
usage_usec 987654321
user_usec 700000000
system_usec 287654321
nr_periods 123456
nr_throttled 45678
throttled_usec 912345678

Що це означає: nr_throttled і throttled_usec, що швидко зростають, вказують на quota throttling. Ваш додаток паузиться політикою.

Рішення: Підвищте CPU limit, приберіть квоту для латентних сервісів або налаштуйте concurrency. Якщо потрібна справедливість, використовуйте reservations/weights обережно, а не жорстку квоту.

Завдання 5: Швидко виявити насичення I/O на хості

cr0x@server:~$ iostat -xz 1 3
Linux 5.15.0-97-generic (server)  02/04/2026  _x86_64_  (16 CPU)

avg-cpu:  %user %nice %system %iowait  %steal %idle
          12.10  0.00    4.20   28.50   0.00  55.20

Device            r/s     w/s   rkB/s   wkB/s  avgqu-sz await  svctm  %util
nvme0n1         120.0   900.0  8200.0 64000.0     35.2  28.4   0.9  98.0

Що це означає: %util близько 100% і високий await означають, що пристрій насичений і запити ставляться в чергу. iowait також підвищений.

Рішення: Перенесіть write-heavy шляхи на швидше сховище, зменшіть частоту fsync (тільки якщо ви готові на компроміс по надійності), розподіліть навантаження по пристроях або виправте проблему «лавини логів».

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

cr0x@server:~$ pidstat -d 1 5
Linux 5.15.0-97-generic (server)  02/04/2026  _x86_64_  (16 CPU)

12:10:01      UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  iodelay  Command
12:10:02        0     23144      10.00  52000.00      0.00     3200  myservice
12:10:02        0     14521       0.00   9000.00      0.00      600  dockerd

Що це означає: Сервіс і навіть dockerd багато пишуть. iodelay показує час, витрачений у очікуванні I/O.

Рішення: Якщо dockerd важкий — підозрюйте драйвер логування або churn образів. Якщо сервіс важкий — інспектуйте його шляхи запису та поведінку fsync.

Завдання 7: Доведіть, що overlay2 використовується і де він живе

cr0x@server:~$ docker info --format '{{.Driver}} {{.DockerRootDir}}'
overlay2 /var/lib/docker

Що це означає: Ви використовуєте overlay2 під /var/lib/docker. Якщо та файлова система повільна, все, що робить Docker, буде повільним.

Рішення: Помістіть /var/lib/docker на швидкий локальний SSD/NVMe для серйозних навантажень. Якщо він на мережевому сховищі — готуйтеся до проблем.

Завдання 8: Перевірте тип файлової системи і опції монтування для Docker root

cr0x@server:~$ findmnt -no SOURCE,FSTYPE,OPTIONS /var/lib/docker
/dev/nvme0n1p2 ext4 rw,relatime,errors=remount-ro

Що це означає: ext4 з типовими опціями. Якщо ви бачите NFS/FUSE або дивні sync опції — це червоний прапорець.

Рішення: Якщо опції монтування включають sync або бекенд віддалений, зупиніться і перепроєктуйте. Контейнери не виправлять повільне сховище.

Завдання 9: Перевірте ріст Docker лог-файлів (json-file driver)

cr0x@server:~$ ls -lh /var/lib/docker/containers/*/*-json.log | sort -k5 -h | tail -n 5
-rw-r----- 1 root root  6.2G Feb  4 12:09 /var/lib/docker/containers/7c.../7c...-json.log
-rw-r----- 1 root root  7.8G Feb  4 12:09 /var/lib/docker/containers/aa.../aa...-json.log

Що це означає: Гігантські логи означають величезні записи, плюс біль з ротацією, якщо неправильно налаштовано.

Рішення: Увімкніть ротацію логів, зменшіть verbosity і розгляньте драйвер логування, який не прив’язує все до локального гарячого диска вузла.

Завдання 10: Виміряйте DNS-латентність зсередини контейнера

cr0x@server:~$ docker exec -it api bash -lc 'time getent hosts db.internal >/dev/null'
real	0m2.013s
user	0m0.000s
sys	0m0.004s

Що це означає: Два секунди на резолв імені — інцидент продуктивності, що чекає на реалізацію.

Рішення: Перевірте конфіг резолвера, продуктивність upstream DNS і search domains. Виправте DNS перед тим, як тонувати потоки додатку.

Завдання 11: Інспектуйте конфіг резолвера всередині контейнера

cr0x@server:~$ docker exec -it api cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:5
search corp.example internal.example svc.cluster.local

Що це означає: Вбудований DNS Docker (127.0.0.11) і ndots:5 з кількома search domains можуть множити lookups.

Рішення: Зменшіть search domains, налаштуйте ndots за потреби і переконайтеся, що upstream DNS здоровий. В деяких середовищах обходьте вбудований DNS, вказуючи явні резолвери.

Завдання 12: Перевірте використання таблиці conntrack

cr0x@server:~$ sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_count = 248932
net.netfilter.nf_conntrack_max = 262144

Що це означає: Ви близькі до стелі. Нові з’єднання почнуть падати при сплесках.

Рішення: Збільшіть conntrack max (з урахуванням пам’яті), зменшіть churn з’єднань (keepalive, pooling) або зменшіть залежність від NAT (host networking, прямий routing).

Завдання 13: Шукайте retransmits і загальні TCP-проблеми

cr0x@server:~$ ss -s
Total: 2138
TCP:   1821 (estab 932, closed 756, orphaned 3, timewait 650)

Transport Total     IP        IPv6
RAW	  0         0         0
UDP	  45        40        5
TCP	  1065      980       85
INET	  1110      1020      90
FRAG	  0         0         0

Що це означає: Багато TIMEWAIT може вказувати на високий churn з’єднань. Це не автоматично погано, але під навантаженням підозрілість підвищується.

Рішення: Якщо бачите churn — додайте keepalives, повторне використання з’єднань і перевірте поведінку клієнтів. Потім провалідуйте capacity conntrack.

Завдання 14: Перевірте тиск на відкриті дескриптори файлів для процесу в контейнері

cr0x@server:~$ PID=$(pgrep -f myservice | head -n 1)
cr0x@server:~$ ls /proc/$PID/fd | wc -l
9823

Що це означає: Майже 10k FDs. Якщо ваші ліміти низькі, ви близькі до відмови; якщо ви вже падаєте, побачите «too many open files».

Рішення: Підвищте ulimit для сервісу, аудитуйте витоки з’єднань і перевірте host-level fs.file-max та per-process ліміти.

Завдання 15: Перевірте ulimit контейнера

cr0x@server:~$ docker exec -it api bash -lc 'ulimit -n'
1024

Що це означає: 1024 — це мало для сучасних мережевих сервісів під навантаженням.

Рішення: Встановіть більше --ulimit nofile=... або налаштуйте в оркестраторові. Потім переконайтеся, що додаток дійсно використовує connection pooling.

Завдання 16: Виявлення тиску пам’яті на хості (своп / підказки reclaim)

cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 3  2  524288  81234  10240 901234   5    8   120  1800  900 1400 12  4 55 29  0

Що це означає: Ненульові si/so вказують на свопінг. b — заблоковані процеси плюс високий wa сигналізують очікування.

Рішення: Зменшіть тиск пам’яті: підвищте ліміти там, де безпечно, виправте витоки, додайте RAM або перерозподіліть навантаження. Свопінг латентного сервісу — це свідомий вибір; оберіть інакше.

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

Міні-історія 1: Інцидент через хибне припущення

У них був високонавантажений API і простий план міграції: перемістити його в контейнери на новий пул нод. Ті самі бінарники, та сама конфігурація, «просто Docker». Команда зробила раціональну річ і дивилася на CPU. Воно залишалося низьким. Вони оголосили перемогу.

Потім прийшла понеділок. p99 латентність потроїлася, але середня латентність виглядала тільки трохи гірше. Автоскейлер додав більше контейнерів, що зробило проблему гіршою в особистісний спосіб. Усі сперечалися про thread pools і garbage collection, бо це аргументи інженерів, які не вимагають виходити зі стільців.

Хибне припущення було простим: вони думали, що файлова система контейнера поводиться як файлова система хоста. Насправді додаток писав тимчасові файли у шлях, що жив у image layer, а не у volume. Під навантаженням overlay2 copy-up спричинив churn і вибух метаданих. Диск не був повільним по throughput; він був перевантажений малими випадковими записами і комітами журналу.

Вони довели це, перемістивши лише тимчасову директорію на volume на швидкому сховищі. Нічого більше не змінилося. Латентність повернулася до норми, CPU залишався низьким, і чат інциденту замовк. Урок не в тому, що «overlay2 поганий». Урок — «шляхи запису — це архітектура».

Міні-історія 2: Оптимізація, що відкатала назад

Інша компанія мала рахунок за логування, який їй не подобався. Хтось запропонував просту оптимізацію: ввімкнути debug логи лише у пікові години, щоб ловити краєві випадки. Вони випустили перемикач. Це працювало в staging. Навіть працювало в проді… перший день.

На другий день прийшов пік трафіку з піковим debug логуванням. Використання диска досягло стелі. Dockerd витрачав реальний час на запис json логів. Додаток почав блокуватися на stdout, бо буфери наповнилися під час сплесків. Латентність зросла. Повтори збільшилися. Більше логів. Більше записів на диск. Утворився петлевий фідбек — наче туторіал з того, як побудувати власний відмова.

«Оптимізація» ґрунтувалася на міфі: що логування безкоштовне, якщо CPU доступний. Логування — це I/O, а I/O під конкуренцією — латентність. Вони відкатили debug логування, увімкнули ротацію логів (що мали б мати й так) і перенесли високошвидкісні логи в асинхронний пайплайн з backpressure.

Після розбору команда перестала називати логування «observability» і почала трактувати його як «продакшн-навантаження, що конкурує із сервісом». Зміна формулювання запобігла більше інцидентів, ніж будь-яка одна конфігурація.

Міні-історія 3: Нудна, але правильна практика, що врятувала день

Ця історія менш гламурна. Команда запускала stateful сервіси в контейнерах зі строгим контролем змін. Вони мали звичку — немодну, майже соромну — базувати продуктивність кожного пулу нод: латентність диска, RTT мережі, запас conntrack, поведінка reclaim пам’яті. Вони зберігали результати як простий звіт і перепускали його після апгрейдів ядра.

Одного дня з’явилася нова партія нод. Все «працювало», але p99 трохи погіршився. Жодних алертів. Просто тихе регресування. Оскільки у них були baseline’и, вони одразу помітили, що процентилі латентності сховища змістилися і що опція монтування Docker root відрізняється.

Виявилося, що пайплайн провізіонування застосував іншу конфігурацію файлової системи на нових нодах. Не катастрофа, але достатньо, щоб підвищити латентність запису під sync-важкими робочими навантаженнями. Вони опорожнили ноди, виправили конфіг і продовжили. Без аварії. Без героїки. Просто нудна компетентність.

Якщо ви хочете надійності, ви маєте погодитися бути нудними наперед. Це не слоган; це стаття бюджету.

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

1) Симптом: CPU низький, латентність висока, потоки «застрягли»

Корінь: Насичення I/O або заблоковані задачі (D-state), часто через write amplification overlay2 або повільне бекенд-сховище.

Фікс: Покладіть гарячі шляхи запису на томи; перемістіть Docker root на швидке локальне сховище; зменшіть синхронні записи; підтвердіть через iostat/pidstat.

2) Симптом: Випадкові затримки 1–5 с, особливо для нових з’єднань

Корінь: DNS таймаути, посилені налаштуванням резолвера (ndots/search domains) або перевантаженим upstream DNS.

Фікс: Виміряйте час резолву всередині контейнера; спростіть resolv.conf; виправте upstream DNS; розгляньте кешуючий резолвер поруч з робочими навантаженнями.

3) Симптом: p99 стрибає під час сплесків, без OOM kills

Корінь: Тиск пам’яті, що викликає reclaim/compaction; занадто жорсткі memory limits; активність swap.

Фікс: Збільшіть запас пам’яті; встановіть адекватні memory limits; зменшіть алокації за запит; уникайте swap для латентних сервісів.

4) Симптом: Сервіс масштабується, але працює гірше

Корінь: Спільне вузьке місце: один диск насичений, спільний вихідний трафік мережі, тиск conntrack або централізований логінг як вузьке місце.

Фікс: Виявте спільний ресурс. Масштабування обчислень не масштабує диск. Розділіть пристрої, додайте ноди з незалежним I/O, зменшіть запис логів.

5) Симптом: Періодичні «завмирання» кожні 100ms або 1s під навантаженням

Корінь: Throttling CPU (cgroup periods). Контейнер досягає квоти, паузиться, потім відновлюється.

Фікс: Розслабте CPU ліміти або використовуйте CPU shares/requests; тримайте квоти для batch-джобів, а не для сервісів з чутливим хвостовим латентністю.

6) Симптом: Помилки з’єднання, таймаути, SYN retries під час піку

Корінь: Повна таблиця conntrack або накладні витрати NAT; високий churn з’єднань створює TIMEWAIT-шторм.

Фікс: Збільшіть conntrack ліміти, зменшіть churn через keepalive, і розгляньте мережеві режими, що зменшують залежність від NAT/conntrack.

7) Симптом: «Too many open files» або дивні часткові відмови

Корінь: Малий ulimit в контейнері або хості; FD leak у додатку.

Фікс: Підвищте nofile; перевірте використання FD; використовуйте connection pooling; налаштуйте алерт по кількості FD.

8) Симптом: Диск вузла раптово заповнюється, після чого все деградує

Корінь: Неблоковані контейнерні лог-файли або неконтрольовані тимчасові дані на Docker root.

Фікс: Налаштуйте ротацію логів; обмежте обсяг логів; зберігайте тимчасові дані на томах з фіксованим розміром; ставте алерти на використання /var/lib/docker.

Чеклісти / покроковий план (нудно, повторювано, ефективно)

Чекліст інциденту: «контейнери повільні зараз»

  1. Підтвердіть обсяг: один сервіс, одна нода чи усе одночасно?
  2. Перевірте насичення I/O: запустіть iostat -xz; якщо %util закріплений і await високий, вважайте сховище пріоритетним.
  3. Перевірте заблоковані задачі: перегляньте dmesg на предмет hung tasks; підтвердіть стан процесів.
  4. Перевірте тиск пам’яті: vmstat, активність swap, cgroup memory events якщо доступні.
  5. Перевірте throttling: прочитайте cpu.stat на наявність лічильників throttling; зіставте з патернами латентності.
  6. Перевірте DNS: замірте резолв всередині контейнера; подивіться на /etc/resolv.conf.
  7. Перевірте conntrack: порівняйте count і max; шукайте дропи в логах ядра, якщо є.
  8. Перевірте логи: розмір json-логів; використання диска в /var/lib/docker.
  9. Ізолюйте, обходячи шари (в staging або обережно): tmpfs для тимчасових даних, volume для гарячих записів, тест host networking.
  10. Зробіть одну зміну: оберіть найвищу ймовірність вузького місця і змініть одну річ.
  11. Виміряйте знову: чи покращився p99? чи зменшився iostat/conntrack/DNS latency?

Профілактичний чекліст: проектуйте контейнери, що не гальмують

  1. Розміщуйте write-heavy шляхи на томах: бази даних, черги, кеші з персистентністю та тимчасові директорії.
  2. Тримайте image layer холодним: не записуйте в шляхи, вбудовані в образ під час виконання.
  3. Встановіть розумні ulimits: особливо nofile і ліміти процесів для висококонкурентних сервісів.
  4. Уникайте жорстких CPU квот для latency-sensitive сервісів: надавайте перевагу weights і reservations; квоти для batch-джобів.
  5. Налаштуйте ротацію логів: не довіряйте дефолтам; обмежуйте розмір і кількість.
  6. Базуйте ноду: латентність диска, RTT мережі, час резолву DNS, запас conntrack.
  7. Алертуйте на реальні вузькі місця: disk await, активність свопу, conntrack count, ріст логів, лічильники throttling.
  8. Тестуйте під навантаженням, схожим на прод: особливо поведінку fsync і навантаження з великою кількістю метаданих.

План змін: покращення продуктивності без культів

  1. Оберіть один сервіс зі стабільним трафіком і вимірюваною проблемою латентності.
  2. Зберіть базу: p50/p95/p99 латентності, disk await, DNS timings, лічильники throttling та рівень помилок.
  3. Визначте найсильніше обмеження: насичення диска, reclaim пам’яті, DNS, conntrack або throttling.
  4. Застосуйте найменший життєздатний фікс: перемістіть одну директорію на volume, змініть один ліміт, ротуйте логи або налаштуйте DNS.
  5. Прогони те саме навантаження і порівняйте з базою; залишайте зміну тільки якщо вона реально допомагає.
  6. Автоматизуйте перевірку: перетворіть ручні команди на дашборди/алерти і runbook.

FAQ

Чому мій контейнер повільнішає, коли CPU низький?

Бо робота чекає, а не виконується. Звичайні підозрювані — латентність диска, reclaim пам’яті, DNS таймаути, мережеві втрати/retransmits або CPU quota throttling.

Чи завжди overlay2 повільніший за роботу на файловій системі хоста?

Ні. Часто він добре підходить для читально-орієнтованих навантажень і помірних записів. Проблеми з’являються при великій кількості дрібних записів, churn метаданих або модифікації файлів із нижніх шарів образу (вартість copy-up).

Чи слід розміщувати бази даних у Docker?

Можна, але треба ставитися до сховища як до першокласного архітектурного рішення: виділені томи, передбачувана латентність сховища і чесне тестування fsync. Якщо ви запускаєте базу на насиченому спільному диску і звинувачуєте Docker, диск тихо посміється.

Чи роблять CPU ліміти продуктивність більш передбачуваною?

Вони роблять використання CPU більш передбачуваним. Латентність часто стає менш передбачуваною, бо throttling вводить періодичні паузи. Для сервісів, чутливих до tail-latency, жорсткі квоти зазвичай не підходять.

Як зрозуміти, чи логування шкодить продуктивності?

Шукайте великі json лог-файли, високу активність записів dockerd і насичення диска. Тимчасове зменшення обсягу логів у контрольованому тесті — простий спосіб підтвердити причинність.

Чому проблеми з DNS виглядають як повільний додаток?

Бо помилки DNS часто — це повільні помилки (таймаути), а не швидкі відмови. Виклики блокуються під час резолву. Під навантаженням заблоковані потоки створюють черги і таймаути в інших місцях.

Який найшвидший спосіб підтвердити, що диск — вузьке місце?

iostat -xz 1 на хості для насичення і pidstat -d, щоб знайти, хто пише. Високий await разом з високою utilization — сигнальна ракета.

Чому масштабування іноді робить гірше?

Бо ви масштабували неправильний ресурс. Більше контейнерів може підвищити конкуренцію за спільний диск, спільний NAT/conntrack, спільний DNS або централізований пайплайн логів.

Ці проблеми «специфічні для Docker» чи «специфічні для Linux»?

Переважно специфічні для Linux. Docker робить їх легше спричинити, бо додає шари і дефолти. Ядро все ще робить реальну роботу, і вузькі місця живуть у ядрі.

Чи варто переходити на host networking заради продуктивності?

Іноді це допомагає, зменшуючи накладні витрати NAT/conntrack, але це змінює ізоляцію і управління портами. Використовуйте як діагностичний інструмент спочатку, потім вирішуйте на основі ризику і вимірюваних вигод.

Висновок: наступні кроки, які ви реально зробите цього тижня

Якщо ваші контейнери гальмують під навантаженням, а CPU низький — перестаньте дивитися на CPU, ніби воно винне. Ставтеся до продуктивності як до розслідування: знаходьте те, що чекає, а не те, що зайняте.

  1. Інструментуйте вузькі місця, які ви постійно пропускаєте: disk await/utilization, активність свопу, лічильники throttling cgroup, час резолву DNS, використання conntrack і ріст Docker логів.
  2. Перемістіть гарячі шляхи запису з writable layer контейнера: використовуйте томи для temp, кешів і всього, що нагадує базу даних.
  3. Виправте «тихі вбивці»: search domains/ndots у DNS, conntrack стелі, ulimits і ротацію логів.
  4. Переоцініть ліміти: особливо CPU quotas. Якщо ви хочете справедливості, не купуйте латентність випадково.
  5. Базуйте ноди й зберігайте звіти: це нудно, але запобігає інцидентам, за які вам не дадуть кредиту.

Контейнери не повільні. Повільні — неопиті обмеження. Docker просто полегшує удавання, що цих обмежень немає — аж поки ваш графік p99 не почне кричати.

← Попередня
Видалення ZFS-снапшота: швидке звільнення місця (і як уникнути головної помилки «Я щойно знищив дані»)
Наступна →
Dev Drive у Windows 11: прискорення збірок (чесно)

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