Главная/Storage/Урок

7.4. tmpfs и read-only filesystem

Цели

После этого материала вы сможете:

  • смонтировать tmpfs с ограничением размера и объяснить, почему без него это опасно;
  • объяснить, почему tmpfs учитывается в лимите памяти container'а;
  • запустить приложение с --read-only и измерить, каким каталогам нужна запись;
  • назвать пути, которые обычно нужны Python-приложению на запись;
  • собрать конфигурацию из read-only корня, tmpfs и volume для данных.

Предварительные знания

Ключевые термины

ТерминОбъяснение
tmpfsФайловая система, целиком расположенная в памяти
--read-onlyЗапуск с корневой файловой системой только на чтение
tmpfs-sizeПредельный размер файловой системы в памяти
tmpfs-modeПрава на точку монтирования (восьмеричные)
noexecОпция монтирования: запрет исполнения файлов

Теория

Что такое tmpfs

Файловая система, живущая в оперативной памяти. Данные не попадают на диск ни при каких условиях (кроме выгрузки в swap, если он разрешён) и исчезают при остановке container'а.

СвойствоЗначение
СкоростьСкорость памяти
Переживает перезапускНет
Следы на дискеНет
Разделяется между container'амиНет
Работает в Windows-container'ахНет
Учитывается в лимите памятиДа

Применения:

ЗадачаПочему tmpfs
/tmp при read-only корнеНужен на запись, содержимое не ценно
Секреты в процессе работыНе должны оседать на диске
Кэш, который дешевле пересоздать, чем хранитьСкорость и отсутствие уборки
Сессии, блокировки, PID-файлыНе должны переживать перезапуск

Два синтаксиса и их различие

bash
docker run --tmpfs /tmp ...
docker run --mount type=tmpfs,target=/tmp,tmpfs-size=64m,tmpfs-mode=1777 ...
--tmpfs--mount type=tmpfs
Ограничение размераНет по умолчаниюtmpfs-size
Права1777tmpfs-mode
Опции noexec, nosuidЧерез запятуюЧерез запятую
Работает в ComposeКлюч tmpfs:Длинный синтаксис volumes:

Первая строка — важная. --tmpfs без указания размера даёт файловую систему в половину оперативной памяти host. Приложение, случайно записавшее туда гигабайты, съест память, которая нужна другим.

Почему tmpfs учитывается в лимите памяти

Страницы tmpfs принадлежат той же cgroup, что и процессы container'а. Значит, запись файла в tmpfs увеличивает memory.current ровно так же, как выделение памяти процессом (урок 6.13).

Следствие, которое застаёт врасплох:

bash
docker run --memory 256m --tmpfs /tmp ...
# приложение записало 300 MB в /tmp → OOM kill, код 137

Ни одна строка кода не выделяла память — но container убит по памяти.

Отсюда правило: tmpfs-size задают всегда, и сумма всех tmpfs плюс потребность приложения должна умещаться в --memory.

Read-only корневая файловая система

bash
docker run --read-only ...

Writable layer монтируется только на чтение. Любая попытка записи вне явно разрешённых точек даёт Read-only file system.

Что это даёт:

ВыгодаПояснение
НеизменяемостьContainer гарантированно соответствует образу
Защита от закрепленияАтакующий не запишет исполняемый файл или веб-шелл
Явная модель данныхВсё изменяемое перечислено в конфигурации
Ранняя диагностикаНезапланированная запись падает сразу, а не теряется при rm

Цена — необходимость перечислить каталоги, которым запись нужна. Причём угадывать их не нужно: их можно измерить.

Как измерить, что нужно на запись

Приём в две команды:

  1. Запустить container без --read-only под типичной нагрузкой.
  2. Выполнить docker diff — он покажет ровно то, что было записано в writable layer (урок 7.1).

Каждый путь из списка нужно затем классифицировать:

Что записывалосьКуда вынести
Временные файлы, сокеты, PID-файлыtmpfs
Кэш, который дешевле пересоздатьtmpfs
Кэш, который дорого прогреватьVolume
Данные приложенияVolume
Логиstdout/stderr, не файл (урок 6.6)
Ничего из перечисленногоОшибка в приложении — разобраться

Последняя строка — самая ценная. Read-only режим регулярно обнаруживает запись, о которой автор приложения не знал.

Что обычно нужно Python-приложению

ПутьКто пишетКак обойтись без записи
/tmptempfile, многие библиотекиtmpfs
__pycache__ рядом с кодомИнтерпретаторPYTHONDONTWRITEBYTECODE=1
~/.cachepip, uv, matplotlib, fontconfigtmpfs или HOME в tmpfs
/var/run, /runPID-файлы, сокетыtmpfs
/var/logЛоги в файлЛогировать в stdout
.pytest_cachepytest-p no:cacheprovider
Каталог с даннымиСамо приложениеVolume

Первые две строки покрывают большинство случаев. Переменная PYTHONDONTWRITEBYTECODE=1 заодно избавляет от копий .pyc в writable layer.

Отдельно: библиотеки, определяющие каталог кэша по HOME, ломаются, если HOME указывает на несуществующий или нечитаемый путь. Для non-root пользователя HOME нужно задать явно (урок 6.7).

Компоновка в Compose

yaml
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 без ограничения размера

bash
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:

text
═══ --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 съедает лимит памяти

bash
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

Ожидаемый вывод:

text
═══ запись 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.

Измеряем, что нужно на запись

bash
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

Ожидаемый вывод:

text
═══ шаг 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:

bash
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

Ожидаемый вывод:

text
═══ шаг 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 действительно работает:

bash
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 запись разрешена"'

Ожидаемый вывод:

text
sh: 1: cannot create /malicious.sh: Read-only file system
в tmpfs запись разрешена

Корень неизменяем, а явно разрешённая точка — доступна. Это ровно то, что нужно от конфигурации.

noexec: запрет исполнения из tmpfs

bash
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 "  исполнение запрещено"
'

Ожидаемый вывод:

text
═══ обычный tmpfs ═══
скрипт выполнен
═══ tmpfs с noexec ═══
sh: /tmp/s.sh: Permission denied
  исполнение запрещено

noexec дополняет read-only корень: даже записав файл в разрешённую точку, атакующий не сможет его выполнить. Для /tmp веб-приложения это разумное умолчание — исполняемые файлы там появляться не должны.

Учтите, что noexec ломает всё, что распаковывает и запускает код из /tmp, — некоторые установщики и профилировщики так делают.

Секреты в tmpfs

bash
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 удалён — секрет существовал только в памяти"

Ожидаемый вывод:

text
═══ секрет через tmpfs: нет следов на диске ═══
  прочитан: api-token-12345
  файловая система: tmpfs, размер 1.0M
  container удалён — секрет существовал только в памяти

Секрет не попал ни в writable layer, ни в образ, ни на диск. Это не полноценное управление секретами (раздел 12), но существенно лучше файла в образе или переменной окружения, видимой в docker inspect.

Оговорка: tmpfs может быть выгружен в swap. Полный запрет требует отключения swap для container'а (--memory-swap равен --memory) или mlock на уровне приложения.

bash
docker rmi -f ro-probe > /dev/null; cd /tmp && rm -rf /tmp/ro-probe

Практическое упражнение

Задание. Переведите Flask-приложение в режим --read-only, определив нужные каталоги измерением, а не подбором.

  1. Запустите приложение обычным образом под нагрузкой, соберите список записанных путей.
  2. Классифицируйте каждый путь: tmpfs, volume или устранить.
  3. Соберите конфигурацию Compose с read_only: true.
  4. Приложение должно работать: отвечать на запросы, писать данные в volume, переживать перезапуск.
  5. Докажите, что запись вне разрешённых точек невозможна.
  6. Убедитесь, что сумма tmpfs укладывается в лимит памяти и OOM не происходит под нагрузкой.

Подсказки

Подсказка 1

docker diff после нагрузки даёт готовый список. Нагрузка обязательна: некоторые каталоги создаются только при первом запросе.

Подсказка 2

__pycache__ устраняется переменной окружения, а не монтированием.

Подсказка 3

Для пункта 6 сложите все tmpfs-size и сравните с --memory, затем проверьте memory.events.

Решение

Показать решение
bash
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

Ожидаемый вывод:

text
═══ Шаг 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'а и изменить сам код. Это ограничение по существу: неизменяемость кода и изменяемость данных — разные задачи.

Проверка результата

bash
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Точка монтирования принадлежит rootuid=/gid= в опциях
__pycache__ монтируют как tmpfsПоявился в списке путейУстраняется PYTHONDONTWRITEBYTECODE=1
Логи в файл при read-onlyПривычка с виртуальных машинЛогировать в stdout
noexec для /tmp без проверкиКажется безопаснымЛомает установщики, распаковывающие код в /tmp
Ожидают, что tmpfs переживёт перезапускПутают с volumeДанные в памяти исчезают
Секрет в tmpfs считают недостижимымЗабыли про swapЗапретить swap или использовать средства раздела 12

Контрольные вопросы

На понимание:

  1. Какой размер получает --tmpfs без указания size?
  2. Почему запись в tmpfs может привести к OOM kill?
  3. Как измерить, каким каталогам нужна запись, вместо подбора?
  4. Почему --read-only не мешает записи в volume?
  5. Что даёт noexec поверх read-only корня?

На применение:

  1. Как избавиться от __pycache__ без монтирования?
  2. Как дать non-root пользователю запись в tmpfs?
  3. Как проверить, что container ничего не пишет в writable layer?

На диагностику:

  1. Gunicorn с --read-only запускается, но не отвечает. Причина?
  2. Container с --memory 512m убит по OOM, приложение памяти не выделяло. Где искать?

Краткое резюме

  1. tmpfs — файловая система в памяти; данные исчезают вместе с container'ом.
  2. Без tmpfs-size размер равен половине памяти host.
  3. Страницы tmpfs учитываются в лимите памяти cgroup — возможен OOM без утечки.
  4. С ограничением размера приложение получает ENOSPC вместо OOM kill.
  5. --read-only монтирует корень на чтение; явные точки монтирования остаются на запись.
  6. Список нужных на запись каталогов измеряют через docker diff, а не подбирают.
  7. Python-приложению обычно нужны /tmp, ~/.cache и /run.
  8. PYTHONDONTWRITEBYTECODE=1 убирает __pycache__ без монтирования.
  9. Gunicorn требует worker_tmp_dir на записываемом пути, иначе worker'ы убиваются.
  10. Для non-root в опциях tmpfs задают uid и gid.
  11. noexec не даёт выполнить записанный файл — полезно, но ломает часть установщиков.
  12. Целевая конфигурация production: read-only корень, tmpfs для временного, volume для данных.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: tmpfs mountshttps://docs.docker.com/engine/storage/tmpfs/Синтаксис, опции, ограничения
Docker: docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/#read-only--read-only, --tmpfs
Compose: service specificationhttps://docs.docker.com/reference/compose-file/services/#read_onlyread_only, tmpfs
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/Учёт памяти cgroup
cgroup v2https://docs.kernel.org/admin-guide/cgroup-v2.htmlСтраницы tmpfs в memory.current
Linux: tmpfshttps://docs.kernel.org/filesystems/tmpfs.htmlУмолчания размера, опции монтирования
Gunicorn: settingshttps://docs.gunicorn.org/en/stable/settings.html#worker-tmp-dirworker_tmp_dir и heartbeat
Python: tempfilehttps://docs.python.org/3/library/tempfile.htmlВыбор каталога для временных файлов

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Права доступа, UID и GID
Главное оглавление

Markdown на GitHub ↗