7.4. tmpfs и read-only filesystem
Цели
После этого материала вы сможете:
- смонтировать
tmpfsс ограничением размера и объяснить, почему без него это опасно; - объяснить, почему
tmpfsучитывается в лимите памяти container'а; - запустить приложение с
--read-onlyи измерить, каким каталогам нужна запись; - назвать пути, которые обычно нужны Python-приложению на запись;
- собрать конфигурацию из read-only корня,
tmpfsи volume для данных.
Предварительные знания
- 7.1. Файловая система container;
- 6.13. Resource limits и память Python — лимит памяти;
- 6.4. Переменные окружения —
PYTHONDONTWRITEBYTECODE.
Ключевые термины
| Термин | Объяснение |
|---|---|
tmpfs | Файловая система, целиком расположенная в памяти |
--read-only | Запуск с корневой файловой системой только на чтение |
tmpfs-size | Предельный размер файловой системы в памяти |
tmpfs-mode | Права на точку монтирования (восьмеричные) |
noexec | Опция монтирования: запрет исполнения файлов |
Теория
Что такое tmpfs
Файловая система, живущая в оперативной памяти. Данные не попадают на диск ни при каких условиях (кроме выгрузки в swap, если он разрешён) и исчезают при остановке container'а.
| Свойство | Значение |
|---|---|
| Скорость | Скорость памяти |
| Переживает перезапуск | Нет |
| Следы на диске | Нет |
| Разделяется между container'ами | Нет |
| Работает в Windows-container'ах | Нет |
| Учитывается в лимите памяти | Да |
Применения:
| Задача | Почему tmpfs |
|---|---|
/tmp при read-only корне | Нужен на запись, содержимое не ценно |
| Секреты в процессе работы | Не должны оседать на диске |
| Кэш, который дешевле пересоздать, чем хранить | Скорость и отсутствие уборки |
| Сессии, блокировки, PID-файлы | Не должны переживать перезапуск |
Два синтаксиса и их различие
docker run --tmpfs /tmp ...
docker run --mount type=tmpfs,target=/tmp,tmpfs-size=64m,tmpfs-mode=1777 ...
--tmpfs | --mount type=tmpfs | |
|---|---|---|
| Ограничение размера | Нет по умолчанию | tmpfs-size |
| Права | 1777 | tmpfs-mode |
Опции noexec, nosuid | Через запятую | Через запятую |
| Работает в Compose | Ключ tmpfs: | Длинный синтаксис volumes: |
Первая строка — важная. --tmpfs без указания размера даёт файловую систему в половину оперативной памяти host. Приложение, случайно записавшее туда гигабайты, съест память, которая нужна другим.
Почему tmpfs учитывается в лимите памяти
Страницы tmpfs принадлежат той же cgroup, что и процессы container'а. Значит, запись файла в tmpfs увеличивает memory.current ровно так же, как выделение памяти процессом (урок 6.13).
Следствие, которое застаёт врасплох:
docker run --memory 256m --tmpfs /tmp ...
# приложение записало 300 MB в /tmp → OOM kill, код 137
Ни одна строка кода не выделяла память — но container убит по памяти.
Отсюда правило: tmpfs-size задают всегда, и сумма всех tmpfs плюс потребность приложения должна умещаться в --memory.
Read-only корневая файловая система
docker run --read-only ...
Writable layer монтируется только на чтение. Любая попытка записи вне явно разрешённых точек даёт Read-only file system.
Что это даёт:
| Выгода | Пояснение |
|---|---|
| Неизменяемость | Container гарантированно соответствует образу |
| Защита от закрепления | Атакующий не запишет исполняемый файл или веб-шелл |
| Явная модель данных | Всё изменяемое перечислено в конфигурации |
| Ранняя диагностика | Незапланированная запись падает сразу, а не теряется при rm |
Цена — необходимость перечислить каталоги, которым запись нужна. Причём угадывать их не нужно: их можно измерить.
Как измерить, что нужно на запись
Приём в две команды:
- Запустить container без
--read-onlyпод типичной нагрузкой. - Выполнить
docker diff— он покажет ровно то, что было записано в writable layer (урок 7.1).
Каждый путь из списка нужно затем классифицировать:
| Что записывалось | Куда вынести |
|---|---|
| Временные файлы, сокеты, PID-файлы | tmpfs |
| Кэш, который дешевле пересоздать | tmpfs |
| Кэш, который дорого прогревать | Volume |
| Данные приложения | Volume |
| Логи | stdout/stderr, не файл (урок 6.6) |
| Ничего из перечисленного | Ошибка в приложении — разобраться |
Последняя строка — самая ценная. Read-only режим регулярно обнаруживает запись, о которой автор приложения не знал.
Что обычно нужно Python-приложению
| Путь | Кто пишет | Как обойтись без записи |
|---|---|---|
/tmp | tempfile, многие библиотеки | tmpfs |
__pycache__ рядом с кодом | Интерпретатор | PYTHONDONTWRITEBYTECODE=1 |
~/.cache | pip, uv, matplotlib, fontconfig | tmpfs или HOME в tmpfs |
/var/run, /run | PID-файлы, сокеты | tmpfs |
/var/log | Логи в файл | Логировать в stdout |
.pytest_cache | pytest | -p no:cacheprovider |
| Каталог с данными | Само приложение | Volume |
Первые две строки покрывают большинство случаев. Переменная PYTHONDONTWRITEBYTECODE=1 заодно избавляет от копий .pyc в writable layer.
Отдельно: библиотеки, определяющие каталог кэша по HOME, ломаются, если HOME указывает на несуществующий или нечитаемый путь. Для non-root пользователя HOME нужно задать явно (урок 6.7).
Компоновка в Compose
services:
app:
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /run:size=8m
volumes:
- app-data:/data
volumes:
app-data:
Три уровня: корень неизменяем, временное — в памяти, данные — в volume. Это стандартная целевая конфигурация для production.
Внутренний механизм
Как --read-only реализован
Docker монтирует корень container'а с флагом ro. Это флаг монтирования, а не права файлов: chmod внутри не поможет, а root внутри container'а обойти его не сможет.
Точки монтирования, добавленные явно (tmpfs, volume, bind mount), имеют собственные флаги и остаются доступными на запись — то же свойство, что у вложенных монтирований (урок 7.3).
Что происходит при переполнении tmpfs
Достигнув tmpfs-size, файловая система возвращает ENOSPC — «No space left on device». Приложение получает обычную ошибку записи.
Это принципиально лучше поведения без ограничения: там вместо ENOSPC наступает OOM kill, который убивает процесс без возможности обработать ситуацию.
Команды и примеры
tmpfs без ограничения размера
echo "═══ --tmpfs без указания размера ═══"
docker run --rm --tmpfs /scratch alpine:3.21 df -h /scratch
echo "═══ --mount с tmpfs-size=32m ═══"
docker run --rm --mount type=tmpfs,target=/scratch,tmpfs-size=32m \
alpine:3.21 df -h /scratch
Ожидаемый вывод на машине с 16 GB:
═══ --tmpfs без указания размера ═══
Filesystem Size Used Available Use% Mounted on
tmpfs 7.8G 0 7.8G 0% /scratch
═══ --mount с tmpfs-size=32m ═══
Filesystem Size Used Available Use% Mounted on
tmpfs 32.0M 0 32.0M 0% /scratch
7.8 GB — половина памяти host. Это значение по умолчанию, и оно почти никогда не является тем, чего вы хотели.
tmpfs съедает лимит памяти
echo "═══ запись 200 MB в tmpfs при --memory 256m ═══"
docker run --rm --memory 256m --memory-swap 256m --tmpfs /scratch \
alpine:3.21 sh -c '
dd if=/dev/zero of=/scratch/f bs=1M count=200 2>/dev/null
echo "записано, память cgroup:"
awk "{printf \" %.0f MiB\n\", \$1/1048576}" /sys/fs/cgroup/memory.current
'
echo
echo "═══ запись 300 MB при том же лимите ═══"
docker run --name tmpfs-oom --memory 256m --memory-swap 256m --tmpfs /scratch \
alpine:3.21 sh -c 'dd if=/dev/zero of=/scratch/f bs=1M count=300' > /dev/null 2>&1
printf ' ExitCode: %s\n' "$(docker inspect tmpfs-oom --format '{{.State.ExitCode}}')"
printf ' OOMKilled: %s\n' "$(docker inspect tmpfs-oom --format '{{.State.OOMKilled}}')"
docker rm tmpfs-oom > /dev/null
echo
echo "═══ то же с tmpfs-size=128m ═══"
docker run --rm --memory 256m --memory-swap 256m \
--mount type=tmpfs,target=/scratch,tmpfs-size=128m \
alpine:3.21 sh -c 'dd if=/dev/zero of=/scratch/f bs=1M count=300' 2>&1 | tail -1
Ожидаемый вывод:
═══ запись 200 MB в tmpfs при --memory 256m ═══
записано, память cgroup:
205 MiB
═══ запись 300 MB при том же лимите ═══
ExitCode: 137
OOMKilled: true
═══ то же с tmpfs-size=128m ═══
dd: error writing '/scratch/f': No space left on device
Три блока показывают всю суть.
Первый: запись в tmpfs увеличила memory.current на 205 MiB, хотя ни один процесс столько памяти не выделял.
Второй: превышение лимита дало OOM kill с кодом 137. Диагностировать такое сложно — в логах приложения ничего нет, память «утекла» в файловую систему.
Третий: с ограничением размера приложение получило понятную ошибку ENOSPC и могло бы её обработать. Это и есть довод в пользу tmpfs-size.
Измеряем, что нужно на запись
mkdir -p /tmp/ro-probe && cd /tmp/ro-probe
cat > app.py <<'PY'
"""Приложение, которое пишет в разные места — как это обычно и бывает."""
import os
import tempfile
from pathlib import Path
# 1. Временный файл — стандартная библиотека
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".tmp") as f:
f.write("временные данные\n")
print(f"tempfile: {f.name}")
# 2. Кэш в HOME — так делают многие библиотеки
cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "myapp"
cache.mkdir(parents=True, exist_ok=True)
(cache / "state.json").write_text("{}\n")
print(f"кэш: {cache}")
# 3. PID-файл
Path("/run/myapp.pid").write_text(f"{os.getpid()}\n")
print("pid-файл: /run/myapp.pid")
# 4. Данные приложения
data = Path("/data")
data.mkdir(parents=True, exist_ok=True)
(data / "records.txt").write_text("важные данные\n")
print(f"данные: {data}/records.txt")
PY
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
EOF
docker build -q -t ro-probe . > /dev/null
echo "═══ шаг 1: обычный запуск ═══"
docker run --name probe ro-probe
echo
echo "═══ шаг 2: что было записано ═══"
docker diff probe | grep -v '^C /$' | sort
docker rm probe > /dev/null
Ожидаемый вывод:
═══ шаг 1: обычный запуск ═══
tempfile: /tmp/tmp8x2k1p9v.tmp
кэш: /root/.cache/myapp
pid-файл: /run/myapp.pid
данные: /data/records.txt
═══ шаг 2: что было записано ═══
A /app/__pycache__
A /data
A /data/records.txt
A /root/.cache
A /root/.cache/myapp
A /root/.cache/myapp/state.json
A /run/myapp.pid
A /tmp/tmp8x2k1p9v.tmp
C /root
C /run
C /tmp
docker diff дал готовый список — гадать не пришлось. Обратите внимание на /app/__pycache__: приложение о нём не знает, его создал интерпретатор.
Классифицируем и запускаем с --read-only:
echo "═══ шаг 3: --read-only без подготовки ═══"
docker run --rm --read-only ro-probe 2>&1 | tail -3
echo
echo "═══ шаг 4: --read-only с нужными монтированиями ═══"
docker volume create probe-data > /dev/null
docker run --rm --read-only \
-e PYTHONDONTWRITEBYTECODE=1 \
--mount type=tmpfs,target=/tmp,tmpfs-size=32m \
--mount type=tmpfs,target=/run,tmpfs-size=8m \
--mount type=tmpfs,target=/root/.cache,tmpfs-size=32m \
--mount type=volume,source=probe-data,target=/data \
ro-probe
echo
echo "═══ данные в volume уцелели ═══"
docker run --rm --mount type=volume,source=probe-data,target=/data \
alpine:3.21 cat /data/records.txt
docker volume rm probe-data > /dev/null
Ожидаемый вывод:
═══ шаг 3: --read-only без подготовки ═══
File "/app/app.py", line 7, in <module>
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".tmp") as f:
OSError: [Errno 30] Read-only file system: '/tmp/tmpXXXXXX'
═══ шаг 4: --read-only с нужными монтированиями ═══
tempfile: /tmp/tmpq7w3e9r1.tmp
кэш: /root/.cache/myapp
pid-файл: /run/myapp.pid
данные: /data/records.txt
═══ данные в volume уцелели ═══
важные данные
Шаг 3 показывает типичную первую реакцию на --read-only: приложение падает на первой же записи. Шаг 4 — результат применения списка из шага 2.
Разница между tmpfs и volume здесь принципиальна: временное исчезло вместе с container'ом, данные остались.
Проверим, что --read-only действительно работает:
docker run --rm --read-only ro-probe sh -c 'echo x > /malicious.sh' 2>&1 | tail -1
docker run --rm --read-only \
--mount type=tmpfs,target=/tmp,tmpfs-size=8m \
ro-probe sh -c 'echo x > /tmp/ok.txt && echo "в tmpfs запись разрешена"'
Ожидаемый вывод:
sh: 1: cannot create /malicious.sh: Read-only file system
в tmpfs запись разрешена
Корень неизменяем, а явно разрешённая точка — доступна. Это ровно то, что нужно от конфигурации.
noexec: запрет исполнения из tmpfs
echo "═══ обычный tmpfs ═══"
docker run --rm --mount type=tmpfs,target=/tmp,tmpfs-size=8m alpine:3.21 sh -c '
printf "#!/bin/sh\necho скрипт выполнен\n" > /tmp/s.sh
chmod +x /tmp/s.sh
/tmp/s.sh
'
echo "═══ tmpfs с noexec ═══"
docker run --rm --mount type=tmpfs,target=/tmp,tmpfs-size=8m,tmpfs-mode=1777 \
--tmpfs /tmp:rw,noexec,nosuid,size=8m alpine:3.21 sh -c '
printf "#!/bin/sh\necho скрипт выполнен\n" > /tmp/s.sh
chmod +x /tmp/s.sh
/tmp/s.sh 2>&1 || echo " исполнение запрещено"
'
Ожидаемый вывод:
═══ обычный tmpfs ═══
скрипт выполнен
═══ tmpfs с noexec ═══
sh: /tmp/s.sh: Permission denied
исполнение запрещено
noexec дополняет read-only корень: даже записав файл в разрешённую точку, атакующий не сможет его выполнить. Для /tmp веб-приложения это разумное умолчание — исполняемые файлы там появляться не должны.
Учтите, что noexec ломает всё, что распаковывает и запускает код из /tmp, — некоторые установщики и профилировщики так делают.
Секреты в tmpfs
echo "═══ секрет через tmpfs: нет следов на диске ═══"
docker run --rm --mount type=tmpfs,target=/secrets,tmpfs-size=1m,tmpfs-mode=0700 \
alpine:3.21 sh -c '
echo "api-token-12345" > /secrets/token
chmod 600 /secrets/token
echo " прочитан: $(cat /secrets/token)"
df -h /secrets | tail -1 | awk "{print \" файловая система: \" \$1 \", размер \" \$2}"
'
echo " container удалён — секрет существовал только в памяти"
Ожидаемый вывод:
═══ секрет через tmpfs: нет следов на диске ═══
прочитан: api-token-12345
файловая система: tmpfs, размер 1.0M
container удалён — секрет существовал только в памяти
Секрет не попал ни в writable layer, ни в образ, ни на диск. Это не полноценное управление секретами (раздел 12), но существенно лучше файла в образе или переменной окружения, видимой в docker inspect.
Оговорка: tmpfs может быть выгружен в swap. Полный запрет требует отключения swap для container'а (--memory-swap равен --memory) или mlock на уровне приложения.
docker rmi -f ro-probe > /dev/null; cd /tmp && rm -rf /tmp/ro-probe
Практическое упражнение
Задание. Переведите Flask-приложение в режим --read-only, определив нужные каталоги измерением, а не подбором.
- Запустите приложение обычным образом под нагрузкой, соберите список записанных путей.
- Классифицируйте каждый путь:
tmpfs, volume или устранить. - Соберите конфигурацию Compose с
read_only: true. - Приложение должно работать: отвечать на запросы, писать данные в volume, переживать перезапуск.
- Докажите, что запись вне разрешённых точек невозможна.
- Убедитесь, что сумма
tmpfsукладывается в лимит памяти и OOM не происходит под нагрузкой.
Подсказки
Подсказка 1
docker diff после нагрузки даёт готовый список. Нагрузка обязательна: некоторые каталоги создаются только при первом запросе.
Подсказка 2
__pycache__ устраняется переменной окружения, а не монтированием.
Подсказка 3
Для пункта 6 сложите все tmpfs-size и сравните с --memory, затем проверьте memory.events.
Решение
Показать решение
mkdir -p /tmp/ro-flask && cd /tmp/ro-flask
cat > app.py <<'PY'
"""Flask-приложение с типичными потребностями в записи."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from flask import Flask, jsonify
app = Flask(__name__)
DATA = Path(os.environ.get("DATA_DIR", "/data"))
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.post("/records")
def add_record():
"""Данные приложения — им нужен volume."""
DATA.mkdir(parents=True, exist_ok=True)
target = DATA / "records.txt"
with target.open("a", encoding="utf-8") as f:
f.write("запись\n")
return jsonify(records=sum(1 for _ in target.open(encoding="utf-8")))
@app.get("/report")
def report():
"""Временный файл — ему достаточно tmpfs."""
with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f:
f.write("колонка\nзначение\n")
path = f.name
size = os.path.getsize(path)
os.unlink(path)
return jsonify(generated=True, bytes=size)
@app.get("/")
def index():
return jsonify(service="ro-flask", pid=os.getpid())
PY
cat > gunicorn.conf.py <<'PY'
import os
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
accesslog = "-"
errorlog = "-"
graceful_timeout = 8
# По умолчанию Gunicorn кладёт heartbeat-файлы worker'ов в /tmp.
# При read-only корне путь должен указывать на смонтированный tmpfs.
worker_tmp_dir = os.environ.get("WORKER_TMP_DIR", "/dev/shm")
PY
cat > requirements.txt <<'EOF'
flask==3.1.3
gunicorn==26.0.0
EOF
cat > .dockerignore <<'EOF'
Dockerfile
.dockerignore
__pycache__
*.py[cod]
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HOME=/home/appuser \
PATH="/opt/venv/bin:$PATH"
RUN useradd --create-home --uid 10001 appuser
RUN python -m venv /opt/venv
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY app.py gunicorn.conf.py ./
USER 10001:10001
EXPOSE 8000
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app"]
EOF
# ── Шаг 1: измерение ──
echo "═══ Шаг 1: обычный запуск под нагрузкой ═══"
docker build -q -t ro-flask . > /dev/null
docker run -d --name measure -p 8000:8000 ro-flask > /dev/null
sleep 5
for _ in $(seq 20); do
curl -s -X POST localhost:8000/records > /dev/null
curl -s localhost:8000/report > /dev/null
done
sleep 1
echo " записанные пути:"
docker diff measure | grep '^A' | sed 's/^A / /' | head -20
docker rm -f measure > /dev/null
# ── Шаг 2: классификация → шаг 3: конфигурация ──
cat > compose.yaml <<'EOF'
services:
app:
build: .
ports:
- "8000:8000"
environment:
PYTHONDONTWRITEBYTECODE: "1" # устраняет __pycache__
WORKER_TMP_DIR: "/dev/shm" # heartbeat Gunicorn
DATA_DIR: "/data"
WEB_CONCURRENCY: "2"
# Пункт 3: корень неизменяем
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777,noexec,nosuid # tempfile
- /dev/shm:size=16m # heartbeat worker'ов
- /home/appuser/.cache:size=16m,uid=10001,gid=10001
volumes:
- app-data:/data # данные приложения
deploy:
resources:
limits:
# Пункт 6: 32 + 16 + 16 = 64 MiB tmpfs + приложение
memory: 256M
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=2).status==200 else 1)"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
volumes:
app-data:
EOF
echo
echo "═══ Шаг 4: работа с read_only ═══"
docker compose up -d --build > /dev/null 2>&1
sleep 12
printf ' статус: %s\n' "$(docker compose ps --format '{{.Status}}' app)"
printf ' /healthz: HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' localhost:8000/healthz)"
printf ' /report: %s\n' "$(curl -s localhost:8000/report)"
for _ in $(seq 5); do curl -s -X POST localhost:8000/records > /dev/null; done
printf ' /records: %s\n' "$(curl -s -X POST localhost:8000/records)"
echo
echo "═══ Данные переживают перезапуск ═══"
docker compose restart app > /dev/null
sleep 10
printf ' после restart: %s\n' "$(curl -s -X POST localhost:8000/records)"
echo
echo "═══ Шаг 5: запись вне разрешённых точек ═══"
docker compose exec -T app sh -c 'echo x > /app/injected.py' 2>&1 | tail -1 | sed 's/^/ /'
docker compose exec -T app sh -c 'echo x > /usr/local/bin/evil' 2>&1 | tail -1 | sed 's/^/ /'
docker compose exec -T app sh -c 'echo x > /tmp/allowed && echo " /tmp: запись разрешена"'
docker compose exec -T app sh -c 'echo x > /data/allowed && echo " /data: запись разрешена"'
echo
echo "═══ Шаг 5: docker diff должен быть почти пуст ═══"
cid="$(docker compose ps -q app)"
docker diff "$cid" | sed 's/^/ /' | head -5
printf ' строк всего: %s\n' "$(docker diff "$cid" | wc -l)"
echo
echo "═══ Шаг 6: память ═══"
printf ' лимит: %s MiB\n' "$(docker compose exec -T app awk '{printf "%.0f", $1/1048576}' /sys/fs/cgroup/memory.max)"
printf ' текущее: %s MiB\n' "$(docker compose exec -T app awk '{printf "%.0f", $1/1048576}' /sys/fs/cgroup/memory.current)"
printf ' oom_kill: %s\n' "$(docker compose exec -T app awk '/oom_kill /{print $2}' /sys/fs/cgroup/memory.events)"
docker compose down -v > /dev/null 2>&1
docker rmi -f ro-flask > /dev/null 2>&1
cd /tmp && rm -rf /tmp/ro-flask
Ожидаемый вывод:
═══ Шаг 1: обычный запуск под нагрузкой ═══
записанные пути:
/app/__pycache__
/app/__pycache__/app.cpython-313.pyc
/data
/data/records.txt
/home/appuser/.cache
/tmp/wgunicorn-8x2k1p9v
═══ Шаг 4: работа с read_only ═══
статус: Up 12 seconds (healthy)
/healthz: HTTP 200
/report: {"bytes":16,"generated":true}
/records: {"records":6}
═══ Данные переживают перезапуск ═══
после restart: {"records":7}
═══ Шаг 5: запись вне разрешённых точек ═══
sh: 1: cannot create /app/injected.py: Read-only file system
sh: 1: cannot create /usr/local/bin/evil: Read-only file system
/tmp: запись разрешена
/data: запись разрешена
═══ Шаг 5: docker diff должен быть почти пуст ═══
C /data
C /home
C /home/appuser
строк всего: 5
═══ Шаг 6: память ═══
лимит: 256 MiB
текущее: 87 MiB
oom_kill: 0
Все шесть пунктов выполнены.
Три решения, определяющие качество.
worker_tmp_dir в конфигурации Gunicorn — та строка, без которой ничего не работает. Gunicorn создаёт для каждого worker'а файл-heartbeat, по умолчанию в /tmp. При read-only корне без смонтированного /tmp мастер решает, что worker'ы зависли, и убивает их по кругу — container выглядит запущенным, но не отвечает. Симптом при этом никак не указывает на файловую систему, и найти причину без списка из шага 1 трудно.
__pycache__ устранён переменной, а не монтированием. Смонтировать tmpfs на /app/__pycache__ было бы возможно, но неправильно: байткод в container'е не нужен вовсе, а лишнее монтирование — это лишняя память и лишняя строка в конфигурации. Список из docker diff полезно читать не только как «что смонтировать», но и как «что можно не писать».
uid=10001 в опциях tmpfs для .cache. Без него точка монтирования принадлежит root, а приложение работает от 10001 и записать туда не может. Ошибка проявляется не сразу — только когда библиотека впервые обратится к кэшу, — и выглядит как случайный сбой. Для /tmp этого не требуется: режим 1777 разрешает запись всем.
Чего решение не делает. Оно не защищает от записи в /data: volume смонтирован на запись, потому что это данные приложения. Read-only корень не мешает атакующему, получившему выполнение кода, испортить данные — он лишь не даёт закрепиться в файловой системе container'а и изменить сам код. Это ограничение по существу: неизменяемость кода и изменяемость данных — разные задачи.
Проверка результата
docker run --rm --read-only \
--mount type=tmpfs,target=/tmp,tmpfs-size=16m \
python:3.13-slim python -c "
import tempfile, pathlib
with tempfile.NamedTemporaryFile('w', delete=False) as f:
f.write('ok')
print('запись в /tmp:', f.name)
try:
pathlib.Path('/probe').write_text('x')
print('ОШИБКА: корень доступен на запись')
except OSError as e:
print('корень защищён:', e.strerror)
"
Ожидается успешная запись в /tmp и Read-only file system для корня.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
--tmpfs без size | Не знали про умолчание | Половина памяти host; задавать tmpfs-size |
tmpfs не учли в лимите памяти | Считают файлы «не памятью» | Страницы принадлежат cgroup; OOM без утечки |
Каталоги для --read-only подбирают наугад | Не знают про docker diff | Измерить: запустить обычно, посмотреть diff |
Забыли worker_tmp_dir для Gunicorn | Не очевидно | Worker'ы убиваются по таймауту heartbeat |
tmpfs для non-root без uid | Точка монтирования принадлежит root | uid=/gid= в опциях |
__pycache__ монтируют как tmpfs | Появился в списке путей | Устраняется PYTHONDONTWRITEBYTECODE=1 |
| Логи в файл при read-only | Привычка с виртуальных машин | Логировать в stdout |
noexec для /tmp без проверки | Кажется безопасным | Ломает установщики, распаковывающие код в /tmp |
Ожидают, что tmpfs переживёт перезапуск | Путают с volume | Данные в памяти исчезают |
Секрет в tmpfs считают недостижимым | Забыли про swap | Запретить swap или использовать средства раздела 12 |
Контрольные вопросы
На понимание:
- Какой размер получает
--tmpfsбез указанияsize? - Почему запись в
tmpfsможет привести к OOM kill? - Как измерить, каким каталогам нужна запись, вместо подбора?
- Почему
--read-onlyне мешает записи в volume? - Что даёт
noexecповерх read-only корня?
На применение:
- Как избавиться от
__pycache__без монтирования? - Как дать non-root пользователю запись в
tmpfs? - Как проверить, что container ничего не пишет в writable layer?
На диагностику:
- Gunicorn с
--read-onlyзапускается, но не отвечает. Причина? - Container с
--memory 512mубит по OOM, приложение памяти не выделяло. Где искать?
Краткое резюме
tmpfs— файловая система в памяти; данные исчезают вместе с container'ом.- Без
tmpfs-sizeразмер равен половине памяти host. - Страницы
tmpfsучитываются в лимите памяти cgroup — возможен OOM без утечки. - С ограничением размера приложение получает
ENOSPCвместо OOM kill. --read-onlyмонтирует корень на чтение; явные точки монтирования остаются на запись.- Список нужных на запись каталогов измеряют через
docker diff, а не подбирают. - Python-приложению обычно нужны
/tmp,~/.cacheи/run. PYTHONDONTWRITEBYTECODE=1убирает__pycache__без монтирования.- Gunicorn требует
worker_tmp_dirна записываемом пути, иначе worker'ы убиваются. - Для non-root в опциях
tmpfsзадаютuidиgid. noexecне даёт выполнить записанный файл — полезно, но ломает часть установщиков.- Целевая конфигурация production: read-only корень,
tmpfsдля временного, volume для данных.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: tmpfs mounts | https://docs.docker.com/engine/storage/tmpfs/ | Синтаксис, опции, ограничения |
Docker: docker run reference | https://docs.docker.com/reference/cli/docker/container/run/#read-only | --read-only, --tmpfs |
| Compose: service specification | https://docs.docker.com/reference/compose-file/services/#read_only | read_only, tmpfs |
| Docker: resource constraints | https://docs.docker.com/engine/containers/resource_constraints/ | Учёт памяти cgroup |
| cgroup v2 | https://docs.kernel.org/admin-guide/cgroup-v2.html | Страницы tmpfs в memory.current |
| Linux: tmpfs | https://docs.kernel.org/filesystems/tmpfs.html | Умолчания размера, опции монтирования |
| Gunicorn: settings | https://docs.gunicorn.org/en/stable/settings.html#worker-tmp-dir | worker_tmp_dir и heartbeat |
Python: tempfile | https://docs.python.org/3/library/tempfile.html | Выбор каталога для временных файлов |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Права доступа, UID и GID
Главное оглавление