Ідея контейнерів у режимі лише для читання звучить як очевидний виграш з погляду безпеки — доки ваш додаток не спробує записати PID-файл, кешувати результат DNS, повернути лог або розпакувати сертифікат у /tmp, ніби зараз 2012 рік. Тоді в проді ви дивитеся на помилки EROFS і дивуєтеся, чому «проста жорстка налаштування» переросла в інцидент сервісу.
Ось практичний підхід: закрийте файлову систему контейнера, збережіть додаток працездатним і не доведіть операторів до божевілля. Ми зосередимося на Docker, але патерни напряму переносяться в Kubernetes і будь-який рантайм, який вміє монтувати tmpfs та томи.
Що насправді означає «контейнер лише для читання» (і чого це не означає)
У термінах Docker «контейнер лише для читання» зазвичай означає запуск контейнера з прапорцем --read-only. Це змінює записуваний шар контейнера на режим лише для читання. Шари образу вже були незмінними; різниця в тому, що тонкий copy-on-write шар, який зазвичай поглинає записи, стає незмінним.
Важлива тонкість: це не означає, що контейнер не може нічого записувати. Це означає, що він не може писати в кореневу файлову систему, якщо ви не надали записувані маунти. Волюми, bind mounts і tmpfs — це окремі маунти і можуть залишатися записуваними. Якщо зробити все правильно, коренева файловa система фактично перетворюється на запечатаний пристрій, а єдині поверхні для запису — явно оголошені.
Модель файлової системи, з якою ви справді працюєте
- Шари образу: незмінні. Завжди лише для читання.
- Записуваний шар контейнера: зазвичай записуваний copy-on-write (OverlayFS/overlay2 у більшості випадків). З
--read-onlyвін стає лише для читання. - Маунти: волюми, bind mounts, tmpfs. Кожен з них може бути записуваним або лише для читання незалежно від rootfs.
- Віртуальні файлові системи, які надає ядро:
/proc,/sys,/dev. Вони спеціальні й керуються прапорцями рантайму та можливостями Linux, а не тільки «read-only rootfs».
Rootfs лише для читання — це не повний сандбокс. Це один з контролів: він зменшує можливість збереження змін для зловмисника і скорочує зону ураження від «випадкових записів» вашим кодом. Він також змушує вас явно моделювати стан: логи, кеші, PID-файли, завантаження і згенеровані під час виконання конфігурації.
Одна цитата, яка добре працює в операціях (парафраз): John Allspaw стверджує, що надійність приходить з проєктування систем, які роблять режим помилок видимим і керованим, а не з припущення, що нічого не зламається.
Rootfs лише для читання саме такий обмежувальний дизайн.
Навіщо це робити: модел загроз і операційна цінність
Безпека: зробіть збереження змін дорогим
Якщо зловмисник отримує виконання коду всередині контейнера з записуваним rootfs, він може покласти інструменти в /usr/local/bin, змінити код додатка або посадити щось на зразок cron-персистенції (так, навіть у контейнерах люди намагаються це робити). Rootfs лише для читання не перешкоджає виконанню в рантаймі, але блокує поширений крок: запис нових бінарників і редагування існуючих.
Чи запобігає це витоку даних? Ні. Чи запобігає це шкідливому ПО, що живе в пам’яті? Теж ні. Але це підвищує складність і зменшує ймовірність «я просто зміню конфіг і зачекаю» підходу для персистенції.
Операції: зупиніть дрейф конфігурацій всередині контейнера
Деякі команди все ще «хотфіксять» редагуванням файлу через docker exec у запущеному контейнері. Це спокуса: проблема зникає до наступного деплою, потім з’являється о 2:00. Rootfs лише для читання робить цей анти-патерн помітним і фейлить голосно. Добре. Виправляйте в образі або в системі конфігурації.
Продуктивність і передбачуваність: менше записів у COW-шар
Overlay-файлові системи можуть поводитись погано, коли додаток пише багато дрібних файлів. Ви отримуєте overhead copy-up, бруд інодів і момент «чому моє дискове використання контейнера вибухнуло». Виведення відомих шляхів для запису на tmpfs або виділений том робить продуктивність передбачуванішою і менш дратує команду зберігання.
Жарт №1: Записуваний rootfs — як спільна офісна дошка: корисна, але рано чи пізно хтось малює там схему бази даних перманентним маркером.
Цікаві факти та коротка історія
- Union-файлові системи передували Docker на роки. AUFS і OverlayFS вже використовувалися для живих систем і вбудованих appliance; контейнери зробили їх масовими.
- Ранні драйвери зберігання Docker були заплутані. AUFS, Device Mapper, btrfs і OverlayFS мали різні семантики і крайові випадки; rootfs лише для читання був способом зменшити «записи в дивних місцях».
- Rootfs лише для читання старше за контейнери. Це класичний прийом жорсткості для chroot-jails і appliance-подібних Linux-систем, де лише
/varбув записуваним. - Kubernetes зробив це мейнстрімом.
securityContext.readOnlyRootFilesystemперетворив це зі «цікавого прапорця Docker» на політику в багатьох організаціях. - Copy-up у OverlayFS — прихована вартість. Запис у файл, що існує в нижньому (image) шарі, змушує копіювати весь файл у верхній шар перед записом.
- Багато базових образів припускають записуваний
/tmp. Менеджери пакетів, рантайми мов і TLS-утиліти часто викидають тимчасові файли туди. - Деякі бібліотеки досі за замовчуванням «корисно» пишуть поруч із кодом. Кеші байткоду Python (
__pycache__) і подібні механізми Java можуть несподівано проявитись. - Логування раніше було орієнтоване на файли. Звички Syslog і logrotate все ще з’являються в образах, які наполягають на записі в
/var/log, навіть коли stdout — правильний вибір. - Distroless образи допомагають, але не вирішують питання записів. Вони зменшують площу атаки, але ваш додаток все одно потребує місця для стану під час виконання.
Патерни проєктування, що не ламають додатки
Патерн 1: Ставтеся до образу як до прошивки
Будуйте образ так, щоб в ньому було все потрібне для запуску: бінарники, конфіги (або шаблони), CA-бандли, дані часових зон і статичні ресурси. Потім вважайте, що він ніколи не змінюється під час виконання. Це змушує чітко розділяти: код/конфіг в образі (або інжектований), стан — зовні.
Якщо ви зараз «виправляєте» контейнери редагуванням файлів всередині, то у вас не проблеми з режимом лише для читання. У вас проблеми з реліз-інженерією у Docker-костюмі.
Патерн 2: Явні шляхі для запису (підхід «/var — це контракт»)
Більшість додатків потребують невеликої кількості записуваних локацій. Типові:
/tmpдля тимчасових файлів і сокетів/var/run(або/run) для PID-файлів і Unix domain сокетів/var/cacheдля кешів/var/logлише якщо ви абсолютно мусите логувати у файли (намагайтеся не робити цього)- спеціальні директорії додатка:
/data,/uploads,/var/lib/app
Робіть ці шляхи записуваними через tmpfs (для епhemeral стану) або томи (для персистентного стану). Все інше лишається лише для читання. Це основний крок.
Патерн 3: Використовуйте tmpfs для «не повинно зберігатися»
tmpfs зберігається в оперативній пам’яті (і в swap, якщо дозволено). Він швидкий, зникає при рестарті і не засмічує ваш контейнерний шар. Ідеально підходить для:
- PID-файлів
- runtime-сокетів
- тимчасової декомпресії
- кешів рантайм-мов, які не потрібно зберігати
Будьте дисципліновані з розміром tmpfs. Підхід «просто дайте ОЗП» — причина того, чому ви потім дебагуєте OOM-кіли, які виглядають як випадкові краші.
Патерн 4: Логи йдуть у stdout/stderr, а не у файли
Контейнери — не «пестуни». Лог-файли всередині контейнера — пастка: вони заповнюють диски, потребують ротації і втрачаються, якщо контейнер помер. Віддавайте перевагу stdout/stderr і нехай платформа збирає логи. Якщо потрібно писати логи на диск (комплаєнс, старі агенти), змонтуйте том у /var/log і прийміть операційні витрати.
Патерн 5: Не забувайте бібліотеки, що «дружньо» пишуть
Типові винуватці:
- Nginx: хоче писати в
/var/cache/nginxі інколи в/var/run - OpenSSL tooling: може писати тимчасові файли під
/tmp - Java: пише в
/tmpі інколи потребує записуваного$HOME - Python: може писати байткод у кеш і очікувати записуваний
$HOMEдля деяких пакетів - Node: інструменти можуть писати кеші під
/home/node/.npm, якщо ви робите збірку під час запуску (не робіть цього)
Патерн 6: Віддавайте перевагу non-root + read-only rootfs, але лишайте можливість дебагу
Rootfs лише для читання добре поєднується з запуском від імені не-root користувача. Ви зменшуєте і «може писати», і «може chmod/chown» можливості. Але не перестарайтеся, видаливши всі інструменти і здивувавшись, чому on-call не може нічого діагностувати.
Якщо ви йдете у distroless, плануйте окремий debug-образ або використовуйте епемерні debug-контейнери. «Немає shell» — нормально. «Немає плану» — ні.
Патерн 7: Зробіть стан явним у додатку
Найкраща жорсткість — коли сам додаток явно каже, куди він пише. Забезпечте прапорці/змінні середовища для директорій кешу, тимчасових директорій і runtime-стану. Якщо ваш додаток пише в випадкові дефолти, ви будете грати в whack-a-mole з маунтами.
Жарт №2: Єдина річ більш постійна, ніж тимчасовий файл — це тимчасовий файл, який додаток створює на кожен запит.
Практичні завдання: команди, виводи, рішення
Ці завдання впорядковані так, як ви насправді проводите rollout: інспектувати, тестувати, обмежувати, потім верифікувати. Кожне включає, що означає вивід і яке рішення приймати.
Завдання 1: Підтвердіть драйвер зберігання (бо семантика має значення)
cr0x@server:~$ docker info --format 'Storage Driver: {{.Driver}}'
Storage Driver: overlay2
Що це означає: Ви майже напевне на OverlayFS (copy-up, upperdir). Поведінка навколо записів файлів і виснаження інодів відповідатиме очікуванням overlay2.
Рішення: Якщо ви не на overlay2 (наприклад devicemapper), перевірте поведінку read-only і продуктивність у стейджингу. Старі драйвери мають несподівані крайові випадки.
Завдання 2: Базовуйте записувані файли контейнера до жорсткості
cr0x@server:~$ docker run --rm -d --name app-baseline myapp:latest
8b3c1d9d51a5a3a33bb3b4a2e7d0a9e5f3a7c1b0d8c9e2f1a6b7c8d9e0f1a2b3
cr0x@server:~$ docker exec app-baseline sh -c 'find / -xdev -type f -mmin -2 2>/dev/null | head'
/var/run/myapp.pid
/tmp/myapp.sock
/var/cache/myapp/index.bin
Що це означає: За останні дві хвилини додаток записав у /var/run, /tmp і /var/cache. Це ваш перший чорновий список «записуваних шляхів».
Рішення: Плануйте tmpfs для /tmp і /var/run; вирішіть, чи /var/cache має бути tmpfs (епhemeral) або томом (персистентним).
Завдання 3: Перевірте зростання використання диска у записуваному шарі
cr0x@server:~$ docker ps --format '{{.Names}} {{.ID}}'
app-baseline 8b3c1d9d51a5
cr0x@server:~$ docker inspect --format '{{.GraphDriver.Data.UpperDir}}' app-baseline
/var/lib/docker/overlay2/7c6f3e.../diff
cr0x@server:~$ sudo du -sh /var/lib/docker/overlay2/7c6f3e.../diff
84M /var/lib/docker/overlay2/7c6f3e.../diff
Що це означає: Контейнер вже записав 84 MB у свій upper шар. Це «невидимий стан», що живе на хості, а не в жодному задекларованому томі.
Рішення: Якщо це число зростає під навантаженням, вам потрібно перемістити ці записи на tmpfs/томи, інакше хтось одного дня скаржитиметься на «диск заповнений на вузлі воркері».
Завдання 4: Запустіть контейнер з read-only rootfs і простежте режим відмови
cr0x@server:~$ docker rm -f app-baseline
app-baseline
cr0x@server:~$ docker run --rm --name app-ro --read-only myapp:latest
myapp: error: open /var/run/myapp.pid: read-only file system
Що це означає: Перший запис додатка — PID-файл. Класика. Тепер у вас є точний шлях, який потрібно зробити записуваним.
Рішення: Додайте tmpfs для /var/run (або змініть додаток, щоб не писав PID-файли, якщо він запускається як PID 1 і вони непотрібні).
Завдання 5: Додайте tmpfs-маунти для поширених runtime-шляхів
cr0x@server:~$ docker run --rm --name app-ro \
--read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
myapp:latest
myapp: error: open /var/cache/myapp/index.bin: read-only file system
Що це означає: Ви виправили першу помилку; проявився наступний запис. Це нормально. Жорсткість лише для читання ітеративно виявляє припущення.
Рішення: Визначте, чи /var/cache/myapp може бути ефермерним. Якщо так — tmpfs. Якщо важко відновлювати — том.
Завдання 6: Змонтуйте виділену записувану директорію кеша
cr0x@server:~$ docker volume create myapp-cache
myapp-cache
cr0x@server:~$ docker run --rm --name app-ro \
--read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
-v myapp-cache:/var/cache/myapp:rw \
myapp:latest
myapp: started on :8080
Що це означає: Контейнер тепер працює з явними записуваними областями.
Рішення: Задокументуйте контракт: /tmp і /var/run — ефермерні; /var/cache/myapp зберігається між рестартами контейнера на тому ж хості (або між вузлами, якщо ви використовуєте мережеве сховище).
Завдання 7: Переконайтеся, що rootfs дійсно змонтований у режимі лише для читання
cr0x@server:~$ docker exec app-ro sh -c 'mount | head -n 6'
overlay on / type overlay (ro,relatime,lowerdir=/var/lib/docker/overlay2/l/...,
upperdir=/var/lib/docker/overlay2/u/...,
workdir=/var/lib/docker/overlay2/w/...)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k)
Що це означає: Overlay-маунт для / має прапорець ro. Ваші tmpfs-маунти — rw. Це бажана конфігурація.
Рішення: Якщо ви не бачите ro на кореневому маунті, ваша конфігурація рантайму не застосовується. Зупиніться і виправте деплой, не «припускайте, що все гаразд».
Завдання 8: Доведіть, що записи там, де потрібно, не проходять
cr0x@server:~$ docker exec app-ro sh -c 'echo test > /etc/should-fail && echo wrote'
sh: can't create /etc/should-fail: Read-only file system
Що це означає: Контейнер не може змінювати системні шляхи. Добре.
Рішення: Якщо це спрацювало, ви випадково залишили root writable і ваша модель загроз щойно розчинилася.
Завдання 9: Підтвердіть, що записувані маунти працюють як очікується
cr0x@server:~$ docker exec app-ro sh -c 'echo ok > /tmp/ok && cat /tmp/ok'
ok
cr0x@server:~$ docker exec app-ro sh -c 'echo ok > /var/cache/myapp/ok && cat /var/cache/myapp/ok'
ok
Що це означає: Тимчасове та кешове сховище доступні. У вас контрольовані поверхні для запису.
Рішення: Додавайте noexec на tmpfs-маунти, якщо немає причини інакше. Це блокує клас атак «завантажити й виконати з /tmp».
Завдання 10: Перевірте приховані записи через змінні оточення (HOME, XDG)
cr0x@server:~$ docker exec app-ro sh -c 'echo $HOME; ls -ld $HOME 2>/dev/null || true'
/home/app
drwxr-xr-x 2 app app 4096 Jan 1 00:00 /home/app
cr0x@server:~$ docker exec app-ro sh -c 'touch $HOME/.probe'
touch: cannot touch '/home/app/.probe': Read-only file system
Що це означає: Домівка користувача існує, але не записувана (бо вона у read-only rootfs). Деякі бібліотеки намагаються писати конфіг чи кеші під $HOME.
Рішення: Або змонтуйте tmpfs у /home/app (якщо це прийнятно), або задайте змінні середовища, щоб перенаправити кеші у записуваний маунт (переважно, коли ви можете його контролювати).
Завдання 11: Перевірте, що логування не пише на диск
cr0x@server:~$ docker exec app-ro sh -c 'ls -l /var/log 2>/dev/null || true'
total 0
cr0x@server:~$ docker logs --tail=5 app-ro
2026-01-03T00:00:01Z INFO listening on :8080
2026-01-03T00:00:02Z INFO warmup complete
Що це означає: Логи йдуть у stdout/stderr (добре). /var/log не накопичує файлів.
Рішення: Якщо ви бачите з’яву лог-файлів, або змонтуйте /var/log як том і керуйте ротацією зовні, або змініть конфіг логера на stdout.
Завдання 12: Виявляйте проблеми з правами заздалегідь, запускаючи non-root
cr0x@server:~$ docker run --rm --name app-ro-nonroot \
--user 10001:10001 \
--read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
-v myapp-cache:/var/cache/myapp:rw \
myapp:latest
myapp: error: open /var/cache/myapp/index.bin: permission denied
Що це означає: Права на директорію тому не дозволяють UID 10001 писати. Це не помилка «лише для читання»; це невідповідність власності UID/GID.
Рішення: Виправте власність на томі (однократна ініціалізація) або використайте рантайм, що підтримує встановлення прав томів. Уникайте запуску від root лише для того, щоб не думати про власність файлів.
Завдання 13: Безпечно виправте власність тому (один підхід)
cr0x@server:~$ docker run --rm -v myapp-cache:/var/cache/myapp alpine:3.20 \
sh -c 'adduser -D -u 10001 app >/dev/null 2>&1; chown -R 10001:10001 /var/cache/myapp; ls -ld /var/cache/myapp'
drwxr-xr-x 2 app app 4096 Jan 3 00:10 /var/cache/myapp
Що це означає: Директорія кешу тепер належить не-root UID/GID.
Рішення: Перезапустіть захищений контейнер як non-root. Якщо ваша платформа підтримує init-контейнери (Kubernetes), це чистіший довготривалий підхід, ніж робити це вручну.
Завдання 14: Повторно протестуйте запуск hardened non-root
cr0x@server:~$ docker run --rm -d --name app-ro-nonroot \
--user 10001:10001 \
--read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
-v myapp-cache:/var/cache/myapp:rw \
myapp:latest
f2aa0e8d1bf2f7ad7f0c2b8b2b2a9a3d9a9e1e3c4b5d6e7f8a9b0c1d2e3f4a5b
cr0x@server:~$ docker exec app-ro-nonroot sh -c 'id'
uid=10001 gid=10001
Що це означає: Ви працюєте у режимі hardened і non-root. Це суттєвий крок у бік ізоляції.
Рішення: Тепер запровадьте це в CI/CD політикою: образи повинні запускатися не від root, а rootfs має бути read-only, якщо немає задокументованого винятку.
Завдання 15: Перевірте, що додаток не мовчки відмовляється зберігати дані
cr0x@server:~$ docker exec app-ro-nonroot sh -c 'test -f /var/cache/myapp/index.bin && echo "cache exists" || echo "cache missing"'
cache exists
Що це означає: Ваш кеш створюється і зберігається там, де ви очікуєте.
Рішення: Якщо його немає, додаток може приглушувати помилки запису і працювати в деградованому режимі. Додайте явні health checks і метрики для прогрівання кешу, завантажень або іншого важливого стану.
Завдання 16: Перевірте відмови ядра/LSM, що виглядають як файлові помилки
cr0x@server:~$ dmesg --ctime | tail -n 5
[Fri Jan 3 00:12:10 2026] audit: type=1400 audit(1735863130.123:120): apparmor="DENIED" operation="open" profile="docker-default" name="/proc/kcore" pid=31245 comm="myapp"
Що це означає: Не всі «permission denied» — це біти режиму файлової системи. AppArmor (або SELinux) також може блокувати доступ.
Рішення: Якщо ви бачите LSM-denials, не вимикайте випадково профілі безпеки. Налаштуйте профіль або виправте поведінку додатка, що тригерить блокування.
План швидкої діагностики
Коли rollout з read-only ламає щось, вам не потрібна філософська дискусія. Потрібна швидка петля триажу, яка скаже, що намагається писати куди, і чи потрібен маунт, зміна конфігу або правка коду.
По-перше: визначте точний шлях, що падає
- Перевірте логи контейнера на
EROFS,Read-only file system,permission denied. - Знайдіть перший шлях, що впав; зазвичай це найраніший запис і часто найпростіший для вирішення.
cr0x@server:~$ docker logs --tail=50 app-ro
myapp: error: open /var/run/myapp.pid: read-only file system
По-друге: визначте, чи це rootfs readonly або права/власність маунту
- Якщо помилка на шляху, який ви планували зробити записуваним — це, ймовірно, права/власність.
- Якщо на шляху, який ви не монтували — це очікувано; треба додати записуваний маунт або змінити додаток, щоб не писав туди.
cr0x@server:~$ docker exec app-ro sh -c 'mount | grep -E " /var/run | /var/cache | /tmp "'
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k)
По-третє: швидко інвентаризуйте записи
- Використовуйте
findпо останнім зміненим файлам, якщо контейнер стартує. - Якщо не стартує — запустіть entrypoint у debug-режимі (або перевизначте команду) щоб отримати shell і відтворити.
cr0x@server:~$ docker exec app-ro sh -c 'find / -xdev -type f -mmin -5 2>/dev/null | head -n 20'
/var/cache/myapp/index.bin
/tmp/myapp.sock
/var/run/myapp.pid
По-четверте: перевірте вузькі місця ресурсів, які вніс tmpfs
- Tmpfs споживає пам’ять. Під навантаженням тиск пам’яті виглядає як «випадкові перезапуски».
- Стежте за використанням пам’яті і споживанням tmpfs.
cr0x@server:~$ docker exec app-ro sh -c 'df -h /tmp /var/run'
Filesystem Size Used Avail Use% Mounted on
tmpfs 64M 2.1M 62M 4% /tmp
tmpfs 16M 44K 16M 1% /var/run
По-п’яте: підтвердіть, що ви не порушили оновлення або потік оновлення сертифікатів
- Якщо ваш образ раніше запускав
apt-getабо завантажував об’єкти під час старту — read-only це заблокує. Добре — переносіть це в час збірки образу. - Якщо ви покладаєтесь на оновлення CA-бандлів у рантаймі всередині контейнера — припиніть. Оновлюйте шляхом перебудови і redeploy образів.
Три корпоративні міні-історії з передової
Міні-історія 1: Інцидент через хибне припущення
У середнього SaaS-компанії команда платформи запровадила read-only rootfs для «всіх безстанних сервісів». Вони зробили все відповідально: канарка, моніторинг, план відкату. Проте канарка впала за кілька хвилин.
Сервіс був Go API. Здавався безстанним: спілкувався з базою і чергою. Команда припустила, що нічого локально не записується. Насправді там був TLS-клієнт, який кешував OCSP-відповіді й проміжні сертифікати в директорії під $HOME, успадкованій від старої бібліотеки за замовчуванням. У нормальних умовах все було «ок», бо директорія існувала і була записуваною у верхньому шарі контейнера.
З read-only rootfs запис кешу провалився. Бібліотека не впала «безпомилково»; вона агресивно перекидала запити по мережі. Латентність зросла, потім залежний сервіс почав застосовувати rate-limit. API залишався доступним, але затримки спричинили каскадні таймаути в кількох сервісах, що його викликали.
Виправлення було нудним: перенаправити кеш в tmpfs і обмежити поведінку повторних спроб. Урок гостріший: «безстанність» — не віра. Це контракт, який ви забезпечуєте проєктуванням і вимірюванням.
Міні-історія 2: Оптимізація, що відкотилася
Інша організація хотіла read-only rootfs і одночасно вирішила оптимізувати продуктивність. Вони перемістили /tmp в tmpfs і задали великий розмір, «щоб ніколи не заповнювався». У стейджингу все виглядало чудово: швидші збірки, краща обробка запитів для сервісу обробки зображень. Усі плескали у долоні.
Але в проді прийшов трафік з реальними користувачами: невеликий відсоток запитів завантажував величезні зображення, і сервіс писав кілька проміжних файлів у /tmp на запит. Використання пам’яті tmpfs швидко зросло. Linux зробив те, що робить під тиском: почав рекламувати пам’ять, а потім викликав OOM killer.
On-call бачив перезапуски контейнерів і припустив регрес коду. Вони відкатили read-only зміну і «проблема зникла», бо сервіс почав знову писати проміжні файли на диск. Наступного дня вони спробували знову і отримали ті самі «таємничі» перезапуски.
Правильне виправлення: тримати tmpfs для дрібних тимчасових файлів, а великі проміжні файли перемістити на виділений том (або переробити на стрімінг). Також: ставити реалістичні ліміти на tmpfs. «Необмежений tmpfs» — це креативний спосіб перетворити пам’ять у диск, не повідомляючи про це нікого.
Міні-історія 3: Нудна практика, що врятувала день
Команда фінансових сервісів мала політику: кожен контейнер оголошує свої записувані шляхи в одному місці, що рев’юється як будь-який інтерфейс. Вони тримали невеликий внутрішній «контракт контейнера» поруч із Dockerfile: які шляхи мають бути записуваними, які tmpfs, які томи, які лише для читання і чому.
Під час хвилі безпеки вони включили read-only rootfs для десятків сервісів. Більшість змін були рутинними, бо записувані шляхи вже були явними. Декілька застарілих додатків впали, але відмови були локалізовані: контракт казав, що має бути записуваним, і їхні тести перевіряли наявність маунтів.
Один сервіс все ж впав у проді через нову версію бібліотеки, що почала писати кеш у /var/lib. Канарка зловила це. Відкат пройшов чисто, а post-incident action був простим: оновити контракт, додати маунт і додати тест, що grep-ить логи на Read-only file system під час старту.
Ніщо героїчне не сталося. Ось в цьому й суть. Нудна практика запобігла масовому відключенню сервісів і перетворила ризиковий проєкт на контрольовану міграцію.
Поширені помилки: симптом → корінь → виправлення
1) Додаток падає миттєво з «Read-only file system» на /var/run
Симптом: Створення PID-файлу або сокета провалюється при старті.
Корінь: Очікується runtime-стан у /var/run або /run, але rootfs лише для читання.
Виправлення: Змонтуйте tmpfs у /var/run (і можливо /run) або налаштуйте додаток писати PID/сокети в /tmp.
2) Додаток працює, але стає повільним; downstream бачить спайки
Симптом: Крахів немає, але після ввімкнення read-only латентність зростає.
Корінь: Запис кешу провалюється, і бібліотека потай переходить на перерахунок/повторне отримання при кожному запиті.
Виправлення: Визначте директорію кешу, змонтуйте записуваний шлях для кешу і додайте оглядність для показників хітрейт кешу або успішності прогріву.
3) «Permission denied» на змонтованому томі
Симптом: Записи не проходять, хоча том змонтовано як rw.
Корінь: Невідповідність UID/GID. Процес non-root не може писати в директорію з власником root.
Виправлення: Встановіть правильну власність через init-крок (init container, однократний chown) або використайте storage class, що підтримує fsGroup у Kubernetes.
4) Випадкові перезапуски після переведення /tmp у tmpfs
Симптом: Контейнер OOM-killed або евікнений під навантаженням.
Корінь: Tmpfs споживає пам’ять; великі тимчасові файли підсилюють тиск пам’яті.
Виправлення: Задавайте консервативний розмір tmpfs, перемістіть великі тимчасові робочі навантаження на том, або переробіть на стрінгову обробку замість тимчасових файлів.
5) Nginx падає з помилками запису в кеш або client body temp
Симптом: Логи Nginx: «open() … failed (30: Read-only file system)» або «client_body_temp».
Корінь: Nginx за замовчуванням пише тимчасові файли і кеш у /var/cache/nginx.
Виправлення: Змонтуйте /var/cache/nginx як записуваний (tmpfs для тимчасових файлів, том для кешу) і налаштуйте client_body_temp_path на записуваний каталог.
6) Java або Python інструменти ламаються, бо хочуть записувану домашню директорію
Симптом: Помилки запису під /root або /home/app чи XDG-шляхи.
Корінь: Бібліотеки за замовчуванням використовують $HOME для кешів/конфігів навіть для серверних процесів.
Виправлення: Встановіть HOME на записуваний tmpfs (обережно) або задайте мови-специфічні шляхи кешу на змонтовані записувані директорії.
7) «Працює на Docker, падає в Kubernetes» (або навпаки)
Симптом: Поведінка відрізняється між середовищами.
Корінь: Різні дефолтні маунти, securityContext або прапорці read-only. Kubernetes також інжектує service account маунти і може встановлювати filesystem groups.
Виправлення: Робіть маунти явними в обох середовищах. Не покладайтеся на «дефолти платформи» для записуваних шляхів.
Чеклисти / покроковий план
Покроковий план rollout (що я б зробив у production-команді)
- Зробіть інвентар записів у базовому контейнері. Запустіть під нормальним навантаженням і перелічіть останні змінені файли в
/з-xdev. Збережіть шляхи. - Класифікуйте кожен шлях: ефермерний (tmpfs), персистентний (том), або «не повинен існувати» (виправте додаток/образ).
- Зупиніть runtime-інсталятори. Якщо entrypoint робить інстали пакетів, завантаження або компіляцію — перенесіть це в час збірки образу. Read-only вас примусить до цього.
- Додайте
--read-onlyі мінімальні tmpfs-маунти. Почніть із/tmpі/var/run. - Ітеративно виправляйте помилки. Додавайте наступні записувані шляхи тільки після підтвердження їх необхідності і правильного обсягу (директорія, а не «монтуйте / як rw»).
- Запустіть як non-root. Виправте проблеми з власністю томів правильно. Саме тут команди намагаються шахраювати — не робіть цього.
- Додаткове закриття: використайте
noexecна tmpfs, відкиньте зайві Linux capabilities і застосуйте обмежувальний seccomp профіль, де можливо. - Додайте тести: інтеграційний тест, що контейнер стартує з read-only rootfs і може писати тільки в задекларовані шляхи.
- Канар у проді. Слідкуйте за латентністю, рівнем помилок, перезапусками і пам’яттю (tmpfs). Швидко йдіть вперед або назад.
- Задокументуйте контракт. Записувані шляхи — частина інтерфейсу сервісу тепер.
Чеклист жорсткості (швидко, але суворо)
- Коренева файловa система змонтована лише для читання (
--read-only/readOnlyRootFilesystem: true). - tmpfs для
/tmpі/var/runзnosuid,nodev,noexec, де можливо. - Виділені томи для справжнього персистентного стану.
- Логи в stdout/stderr; уникайте запису в
/var/log. - Запуск не від root, з явним керуванням власністю томів.
- Ніяких runtime-інсталяцій пакетів, ніяких самонових кодів.
- Health checks фіксують «працює, але деградований» (помилки кешу, помилки завантажень).
- Моніторинг використання tmpfs і перезапусків контейнерів.
Керівництво з вибору маунту (вирішуйте як дорослий)
- tmpfs: секрети, що декриптуються під час виконання, PID-файли, малі тимчасові файли, сокети. Чудово для швидкості; небезпечно, якщо без обмежень.
- іменований том: кеші, які потрібно зберігати на хості, невеликий стан, буферні черги (ідеально — не всередині контейнера додатка, але реальність інша).
- bind mount: конфіг або сертифікати з хоста (часто лише для читання). Обережно: ви зв’язуєтеся з шляхами хоста.
- не монтуйте: все, що можна усунути, змінивши додаток на стрімінг, логування в stdout або ставлення до образу як до immutable.
FAQ
1) Чи робить --read-only мій контейнер «захищеним»?
Ні. Це один контроль, що зменшує файлову персистентність і випадкові модифікації. Вам все ще потрібні non-root, відкидання можливостей, мережеві контролі і патчинг через перебудову образів.
2) Якщо я монтую записуваний том, хіба це не нівелює сенс?
Ні, якщо ви робите це навмисно. Мета — зменшити площу для запису і зробити її явною. Малий записуваний том для /var/cache/myapp куди кращий, ніж «всі можуть писати скрізь».
3) Чому б просто не робити все безстанним і уникнути томів?
Бо багато «безстанних» додатків все ще потребують тимчасового простору, сокетів і кешів. Мета — мінімізувати і контролювати стан, а не заперечувати його існування.
4) Чи можна змонтувати / як readonly і потім ремонтувати частини всередині контейнера?
Всередині контейнера ремонт зазвичай вимагає підвищених привілеїв і можливостей, які ви не повинні давати. Робіть маунти з рантайму (Docker/Kubernetes), щоб процес у контейнері не міг розширити свої права на запис.
5) Які мінімальні записувані маунти потрібні більшості додатків?
Зазвичай /tmp і /var/run. Далі — специфічно для додатка: кеші, завантаження, файли SQLite тощо. Правильна відповідь — «те, що ваш додаток доведе під навантаженням».
6) Як це працює в Kubernetes?
Встановіть securityContext.readOnlyRootFilesystem: true і визначте emptyDir томи (опціонально medium: Memory) для поведінки, схожої на tmpfs, плюс persistent volumes де потрібно. Та сама угода про записувані шляхи застосовується.
7) Чому я отримую «permission denied», коли маунт — rw?
Бо все ще діють права власності та дозволи файлової системи. Записуваний маунт не дає автоматично UID 10001 право писати в директорію, що належить root. Виправте власність або використайте fsGroup/init-кроки.
8) А що з додатками, що генерують конфіги при старті?
Краще генерувати конфіги у записуваний маунт (наприклад у /tmp або /var/run) і вказувати додатку на них. Ще краще: генерувати конфіги на етапі збірки або інжектити їх через змінні середовища чи змонтовані файли.
9) Чи зламає noexec на /tmp щось?
Іноді. Інструменти, що розпаковують і виконують бінарники з /tmp, не працюватимуть. Часто це бажано в проді. Якщо ваш додаток дійсно потребує цього (рідко для правильно побудованих сервісів), задокументуйте виняток і обмежте його.
10) Як протестувати це в CI?
Запускайте контейнер з --read-only та потрібними tmpfs/volumes, виконуйте смок-тест і провалюйте збірку при будь-яких рядках логів з Read-only file system або ненульовому exit-коді.
Висновок: наступні кроки, які дійсно приживаються
Контейнери лише для читання — один із рідкісних прийомів жорсткості, що водночас покращує операційну ясність. Вони змушують вас задекларувати, де живе стан, що полегшує дебаг і ускладнює персистенцію компромісів. Вартість у тому, що ліниві файлові припущення додатка стають вашою проблемою. Це не недолік; це реальність без маски.
Що зробити далі
- Виберіть один сервіс, який ви повністю контролюєте, і запустіть його з
--read-onlyу стейджингу. - Ітеративно додавайте записувані маунти, поки воно не запуститься, потім звузьте їх до мінімуму.
- Перемістіть логи в stdout, припиніть runtime-інсталяції і зробіть кеші явними.
- Запустіть як non-root і правильно виправте власність томів.
- Перетворіть остаточний список маунтів у контракт: закомічений, рев’юваний і тестований.
Якщо ви зробите лише одну річ: припиніть дозволяти контейнерам писати куди заманеться. Ваші майбутні інциденти стануть нуднішими — а це найвища похвала, яку може дати продакшн.