Раздел 7. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
mkdir -p ~/docker-course/07-storage && cd ~/docker-course/07-storage
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in alpine:3.21 python:3.13-slim postgres:17-alpine; do docker pull -q "$img"; done
docker system df -v | head -3 # зафиксируйте исходное состояние
Задание 1. Данные и удаление container [обяз.]
Постановка. Запишите файл в container. Покажите три вещи:
- Файл переживает
docker stopиdocker start. - Файл исчезает после
docker rmи создания нового container'а из того же образа. - Тот же сценарий с named volume: данные доступны новому container'у.
Ожидаемый результат. Три пары команд с выводом, не допускающим двоякого толкования.
Проверка:
docker run --rm -v <volume>:/d alpine:3.21 cat /d/file.txt
Разбор — в уроке 7.1.
Задание 2. Физическое расположение volume [обяз.]
Постановка. Создайте named volume, запишите в него файл из container'а, найдите этот файл на host и прочитайте его напрямую.
Дополнительно: объясните, почему путь оканчивается на _data и почему для чтения нужен sudo.
Ожидаемый результат. Содержимое файла, прочитанное с host, и путь, полученный командой, а не угаданный.
Проверка:
sudo cat "$(docker volume inspect <volume> --format '{{.Mountpoint}}')/file.txt"
Разбор — в уроке 7.2.
Задание 3. Монтирование поверх файлов образа [обяз.]
Постановка. Соберите образ, в котором каталог /app содержит два файла. Затем:
- Смонтируйте туда каталог host с одним файлом — покажите, что видно.
- Смонтируйте туда пустой каталог host — покажите, что видно.
- Смонтируйте туда пустой named volume — покажите, что видно.
- Объясните разницу между пунктами 2 и 3.
- Докажите, что файлы образа не удалены.
Ожидаемый результат. Четыре разных вывода и объяснение механизма.
Проверка:
docker run --rm <образ> ls /app # без монтирования файлы на месте
Разбор — в уроке 7.3 и уроке 7.2.
Задание 4. Файл, который не удаляется [обяз.]
Постановка. Воспроизведите ситуацию: container создаёт файл в примонтированном каталоге проекта, и обычный пользователь host не может его удалить.
Покажите числовых владельцев (ls -ln), а не имена. Объясните, почему ls -l вводит в заблуждение.
Ожидаемый результат. Сообщение Permission denied при rm и объяснение через UID.
Проверка:
ls -ln ./output
rm ./output/file.txt # должно отказать
Разбор — в уроке 7.5.
Задание 5. Три способа исправления [обяз.]
Постановка. Исправьте задание 4 тремя разными способами:
--userпри запуске;- UID образа, заданный при сборке;
- общая группа с битом
setgid.
Для каждого способа покажите: файл создаётся, принадлежит нужному владельцу, удаляется без sudo. Составьте таблицу сравнения с указанием минусов каждого способа.
Ожидаемый результат. Три рабочих варианта и обоснованный выбор для разработки и для сервера.
Проверка:
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/out" <образ> touch /out/t
rm ./out/t && echo "удалено без sudo"
Разбор — в уроке 7.5.
Задание 6. Минимальный набор tmpfs [доп.]
Постановка. Возьмите Python-приложение, которое пишет во временные файлы и создаёт кэш. Определите измерением, каким каталогам нужна запись, и запустите приложение с --read-only.
Требования: список каталогов получен из docker diff, а не подбором; каждому tmpfs задан размер; сумма размеров укладывается в лимит памяти.
Ожидаемый результат. Работающее приложение с --read-only и обоснование каждой точки монтирования.
Проверка:
docker diff <container> | wc -l # должно быть близко к нулю
docker exec <container> sh -c 'echo x > /probe' 2>&1 | tail -1
Разбор — в уроке 7.4.
Задание 7. Backup и restore PostgreSQL [доп.]
Постановка. Поднимите PostgreSQL в Compose, наполните таблицу, сделайте резервную копию, удалите volume, восстановите и подтвердите целостность данных.
Требования: копия делается на работающей базе; проверка сравнивает контрольную сумму содержимого, а не только количество строк.
Ожидаемый результат. Совпадение контрольных сумм до и после.
Проверка:
docker compose exec -T db psql -U postgres -d appdb -tAc \
"SELECT md5(string_agg(payload, '' ORDER BY id)) FROM records"
Разбор — в уроке 7.6.
Задание 8. Безопасная уборка volumes [доп.]
Постановка. Создайте несколько anonymous volumes (запуск образа с VOLUME без --rm), затем найдите их, определите размер, загляните внутрь и удалите поимённо.
Отдельно объясните, почему docker volume prune --all в этой ситуации опасен.
Ожидаемый результат. Скрипт, который показывает содержимое перед удалением и ничего не удаляет сам.
Проверка:
docker volume ls -qf dangling=true | wc -l
docker system df -v | sed -n '/VOLUME NAME/,$p'
Разбор — в уроке 7.6.
Задание 9. Диагностика: данные исчезли после compose down [диаг.]
Постановка. Дан Compose-файл:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- ./db-data:/var/lib/postgresql/data/pgdata
Разработчик наполняет базу, выполняет docker compose down, затем docker compose up -d — и база пуста. Каталог ./db-data на host существует и не пустой.
Найдите все причины (их больше одной), объясните каждую и предложите правильную конфигурацию.
Ожидаемый результат. Разбор каждой ошибки с доказательством и рабочий Compose-файл.
Разбор
В этом файле три независимые ошибки. Каждая по отдельности приводит к потере данных.
Ошибка 1. Путь монтирования не тот, который использует PostgreSQL.
Образ postgres хранит данные в каталоге из переменной PGDATA, по умолчанию /var/lib/postgresql/data. Здесь смонтирован вложенный /var/lib/postgresql/data/pgdata, который PostgreSQL не использует.
docker compose up -d
sleep 12
echo "═══ где на самом деле данные ═══"
docker compose exec -T db sh -c 'echo "PGDATA=$PGDATA"; ls -d /var/lib/postgresql/data/*/ 2>/dev/null | head -3'
echo "═══ что в смонтированном каталоге ═══"
docker compose exec -T db ls -A /var/lib/postgresql/data/pgdata | head -3
Ожидаемый вывод:
═══ где на самом деле данные ═══
PGDATA=/var/lib/postgresql/data
/var/lib/postgresql/data/base/
/var/lib/postgresql/data/global/
/var/lib/postgresql/data/pg_wal/
═══ что в смонтированном каталоге ═══
Смонтированный каталог пуст: данные лежат уровнем выше, в writable layer, и погибают вместе с container'ом (урок 7.1).
Ошибка 2. Bind mount вместо volume для данных базы.
Даже с правильным путём bind mount здесь плохой выбор:
ls -ldn ./db-data 2>/dev/null
docker compose exec -T db id
Ожидаемый вывод:
drwxr-xr-x 2 1000 1000 4096 Jul 30 13:30 ./db-data
uid=70(postgres) gid=70(postgres) groups=70(postgres)
Каталог принадлежит UID 1000, процесс работает от UID 70 — при правильном пути PostgreSQL просто не смог бы запуститься (урок 7.5). Named volume наследует владельца из образа и этой проблемы не создаёт (урок 7.2).
Ошибка 3. Не объявлен named volume — значит, нечему пережить пересоздание.
Проверим, что docker compose down действительно уносит данные:
docker compose exec -T db psql -U postgres -d appdb -q \
-c 'CREATE TABLE t (id int); INSERT INTO t VALUES (1),(2),(3);'
printf 'записей до down: %s\n' \
"$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM t' | tr -d ' \r')"
docker compose down > /dev/null
docker compose up -d > /dev/null
sleep 12
docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM t' 2>&1 | tail -1
Ожидаемый вывод:
записей до down: 3
ERROR: relation "t" does not exist
Данные ушли вместе с writable layer.
Правильная конфигурация:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
# Named volume на тот путь, который PostgreSQL реально использует
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
retries: 10
volumes:
db-data:
Проверка исправления:
docker compose up -d > /dev/null
sleep 12
docker compose exec -T db psql -U postgres -d appdb -q \
-c 'CREATE TABLE t (id int); INSERT INTO t VALUES (1),(2),(3);'
docker compose down > /dev/null
docker compose up -d > /dev/null
sleep 12
printf 'записей после down/up: %s\n' \
"$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM t' | tr -d ' \r')"
docker compose down -v > /dev/null
Ожидаемый вывод:
записей после down/up: 3
Отдельно: когда bind mount для базы всё же используют. Если каталог данных должен лежать на конкретном диске, применяют volume с драйвером local и опцией bind (урок 7.2):
volumes:
db-data:
driver: local
driver_opts:
type: none
device: /srv/postgres-data
o: bind
Права при этом всё равно придётся согласовать вручную: sudo chown 70:70 /srv/postgres-data.
Чего разбор не покрывает. Если разработчик запускал docker compose down -v, данные исчезли бы и при правильной конфигурации — флаг -v удаляет объявленные volumes. Это четвёртая возможная причина, и проверяется она историей команд, а не файлом. Ключевое различие: docker compose down named volume не трогает, down -v — удаляет.
Задание 10. Разработка без чужих файлов [★]
Постановка. Организуйте окружение разработки Python-приложения, удовлетворяющее восьми требованиям.
- Код монтируется с host; правка видна без пересборки.
- Установленные в образе пакеты не скрываются монтированием.
- Приложение работает не от root.
- Все файлы, созданные приложением в смонтированном каталоге, принадлежат вашему UID.
- Эти файлы удаляются с host без
sudo. - Конфигурация монтируется read-only; правка редактором видна в container'е.
- Кэш приложения не попадает в репозиторий на host.
- Тот же репозиторий работает у коллеги с другим UID без правки файлов.
Каждое требование подтвердите проверкой; скрипт проверки должен возвращать ненулевой код при провале.
Подсказки
Подсказка 1
Требования 2 и 7 решаются расположением и дополнительными монтированиями, а не флагами доступа.
Подсказка 2
Требование 6 не выполнится при монтировании отдельного файла: редактор меняет inode.
Подсказка 3
Требование 8 означает, что UID не должен быть записан в файлы репозитория.
Подсказка 4
Требование 4 проверяйте на файле, созданном приложением, а не командой touch из вашей оболочки.
Решение
Показать решение
mkdir -p ~/docker-course/07-storage/devenv/{src/app,config,output}
cd ~/docker-course/07-storage/devenv
cat > src/app/__init__.py <<'PY'
"""Приложение окружения разработки."""
PY
cat > src/app/main.py <<'PY'
"""Пишет отчёт, кэш и читает конфигурацию — как обычное приложение."""
from __future__ import annotations
import configparser
import os
import sys
from pathlib import Path
VERSION = "1" # правится на host для проверки требования 1
OUTPUT = Path(os.environ.get("OUTPUT_DIR", "/output"))
CONFIG = Path(os.environ.get("CONFIG_FILE", "/app/config/app.ini"))
CACHE = Path(os.environ.get("APP_CACHE", "/app/.cache"))
def main() -> int:
# Требование 2: пакет из образа должен быть доступен
try:
import click
pkg = f"click {click.__version__}"
except ModuleNotFoundError as exc:
print(f" пакет: ОШИБКА — {exc}")
return 1
# Требование 6: конфигурация читается с host
parser = configparser.ConfigParser()
if not CONFIG.exists():
print(f" конфиг: ОТСУТСТВУЕТ {CONFIG}")
return 1
parser.read(CONFIG)
setting = parser.get("app", "setting", fallback="?")
# Требование 7: кэш пишется, но не в репозиторий host
CACHE.mkdir(parents=True, exist_ok=True)
(CACHE / "state.txt").write_text("кэш\n", encoding="utf-8")
# Требование 4: файлы приложения в смонтированном каталоге
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / "report.txt").write_text(f"версия {VERSION}\n", encoding="utf-8")
nested = OUTPUT / "nested"
nested.mkdir(exist_ok=True)
(nested / "deep.txt").write_text("вложенный файл\n", encoding="utf-8")
print(f" версия: {VERSION}")
print(f" UID: {os.getuid()}:{os.getgid()}")
print(f" HOME: {os.environ.get('HOME', '(не задан)')}")
print(f" пакет: {pkg}")
print(f" конфиг: setting={setting}")
print(f" вывод: {OUTPUT}/report.txt, {nested}/deep.txt")
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > config/app.ini <<'EOF'
[app]
setting = исходное
EOF
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
output
.cache
.env
EOF
cat > .gitignore <<'EOF'
.env
output/
.cache/
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app/src \
PATH="/opt/venv/bin:$PATH"
# Требование 2: окружение ВНЕ /app, монтирование его не скроет
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 src/ ./src/
# ── production: фиксированный UID ──
FROM base AS runtime
RUN groupadd -g 10001 app && useradd -u 10001 -g 10001 -m -d /home/app app && \
mkdir -p /output /app/.cache && chown -R 10001:10001 /output /app/.cache
ENV HOME=/home/app
USER 10001:10001
CMD ["python", "-m", "app.main"]
# ── разработка: UID приходит снаружи (требование 8) ──
FROM base AS dev
ARG UID=1000
ARG GID=1000
# Пользователь или группа с таким номером могут уже быть в базовом образе
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
mkdir -p /home/dev /app/.cache && \
chown -R ${UID}:${GID} /home/dev /app/.cache
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["python", "-m", "app.main"]
EOF
# Требование 8: UID вычисляется локально, в репозиторий не попадает
printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env
cat > compose.yaml <<'EOF'
services:
dev:
build:
context: .
target: dev
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
environment:
OUTPUT_DIR: /output
CONFIG_FILE: /app/config/app.ini
APP_CACHE: /app/.cache
volumes:
# Требование 1: код с host
- ./src:/app/src
# Требование 4: вывод приложения виден на host
- ./output:/output
# Требование 6: КАТАЛОГ, не файл — правка редактором видна
- ./config:/app/config:ro
# Требование 7: кэш в volume, на host не попадает
- dev-cache:/app/.cache
prod:
build:
context: .
target: runtime
environment:
OUTPUT_DIR: /output
volumes:
- prod-output:/output
volumes:
dev-cache:
prod-output:
EOF
cat > check.sh <<'SH'
#!/usr/bin/env bash
# Проверка всех восьми требований. Ненулевой код при любом провале.
set -uo pipefail
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
run() { docker compose run --rm -T dev "$@" 2>/dev/null; }
printf '\n═══ Сборка ═══\n'
docker compose build dev > /dev/null 2>&1 && ok "образ собран" || { bad "сборка"; exit 1; }
printf '\n═══ Требования 2, 3, 6: базовый запуск ═══\n'
out="$(docker compose run --rm -T dev 2>/dev/null)"
echo "$out" | sed 's/^/ /'
echo "$out" | grep -q "click" && ok "пакет доступен (требование 2)" || bad "пакет скрыт монтированием"
uid="$(run id -u | tr -d '\r')"
[ "$uid" != "0" ] && ok "работает не от root: UID=$uid (требование 3)" || bad "работает от root"
printf '\n═══ Требование 1: правка кода без пересборки ═══\n'
sed -i 's/^VERSION = "1"/VERSION = "2-ПРАВКА"/' src/app/main.py
docker compose run --rm -T dev 2>/dev/null | grep версия | sed 's/^/ /'
docker compose run --rm -T dev 2>/dev/null | grep -q "2-ПРАВКА" \
&& ok "правка видна без пересборки" || bad "правка не видна"
printf '\n═══ Требование 4: владельцы файлов приложения ═══\n'
mine="$(id -u):$(id -g)"
bad_owner=0
while read -r p; do
owner="$(stat -c '%u:%g' "$p")"
printf ' %-26s %s\n' "$p" "$owner"
[ "$owner" = "$mine" ] || bad_owner=1
done < <(find output -mindepth 1 | sort)
[ "$bad_owner" -eq 0 ] && ok "все файлы принадлежат $mine" || bad "есть файлы с чужим владельцем"
printf '\n═══ Требование 5: удаление без sudo ═══\n'
rm -rf output/report.txt output/nested 2>/dev/null \
&& ok "удалено без sudo" || bad "потребовался sudo"
printf '\n═══ Требование 6: правка конфигурации редактором ═══\n'
before="$(stat -c '%i' config/app.ini)"
sed -i 's/setting = .*/setting = ИЗМЕНЕНО/' config/app.ini
after="$(stat -c '%i' config/app.ini)"
printf ' inode: %s → %s\n' "$before" "$after"
docker compose run --rm -T dev 2>/dev/null | grep -q "setting=ИЗМЕНЕНО" \
&& ok "новое значение видно (inode менялся: $([ "$before" != "$after" ] && echo да || echo нет))" \
|| bad "конфигурация не обновилась"
docker compose run --rm -T dev sh -c 'echo x > /app/config/app.ini' 2>&1 | grep -qi "read-only" \
&& ok "конфигурация недоступна на запись" || bad "конфигурацию можно перезаписать"
printf '\n═══ Требование 7: кэш не в репозитории ═══\n'
run sh -c 'ls /app/.cache' | grep -q state.txt && ok "кэш пишется в container" || bad "кэш не пишется"
[ ! -d .cache ] && ok "каталога .cache на host нет" || bad "кэш просочился на host"
printf '\n═══ Требование 8: другой UID ═══\n'
UID=4242 GID=4242 docker compose build dev > /dev/null 2>&1
other="$(UID=4242 GID=4242 docker compose run --rm -T dev id -u 2>/dev/null | tr -d '\r')"
[ "$other" = "4242" ] && ok "собралось под UID 4242 без правки репозитория" || bad "UID не подставился (получено '$other')"
git_tracked_uid="$(grep -rl "UID=$(id -u)" --include='*.yaml' --include='*.yml' --include='Dockerfile' . 2>/dev/null | wc -l)"
[ "$git_tracked_uid" -eq 0 ] && ok "UID не записан в файлы репозитория" || bad "UID зашит в конфигурацию"
# Возвращаем исходное состояние
sed -i 's/^VERSION = "2-ПРАВКА"/VERSION = "1"/' src/app/main.py
sed -i 's/setting = ИЗМЕНЕНО/setting = исходное/' config/app.ini
UID="$(id -u)" GID="$(id -g)" docker compose build dev > /dev/null 2>&1
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все восемь требований выполнены" || echo " ЕСТЬ ПРОВАЛЕННЫЕ ПРОВЕРКИ"
exit "$fail"
SH
chmod +x check.sh
./check.sh
echo "КОД: $?"
docker compose down -v > /dev/null 2>&1
Ожидаемый вывод:
═══ Сборка ═══
✓ образ собран
═══ Требования 2, 3, 6: базовый запуск ═══
версия: 1
UID: 1000:1000
HOME: /home/dev
пакет: click 8.3.0
конфиг: setting=исходное
вывод: /output/report.txt, /output/nested/deep.txt
✓ пакет доступен (требование 2)
✓ работает не от root: UID=1000 (требование 3)
═══ Требование 1: правка кода без пересборки ═══
версия: 2-ПРАВКА
✓ правка видна без пересборки
═══ Требование 4: владельцы файлов приложения ═══
output/nested 1000:1000
output/nested/deep.txt 1000:1000
output/report.txt 1000:1000
✓ все файлы принадлежат 1000:1000
═══ Требование 5: удаление без sudo ═══
✓ удалено без sudo
═══ Требование 6: правка конфигурации редактором ═══
inode: 1443521 → 1443907
✓ новое значение видно (inode менялся: да)
✓ конфигурация недоступна на запись
═══ Требование 7: кэш не в репозитории ═══
✓ кэш пишется в container
✓ каталога .cache на host нет
═══ Требование 8: другой UID ═══
✓ собралось под UID 4242 без правки репозитория
✓ UID не записан в файлы репозитория
═══ ИТОГ ═══
все восемь требований выполнены
КОД: 0
Три решения, определяющие качество.
Проверка требования 4 обходит все созданные файлы, включая вложенные. Проверить только report.txt было бы недостаточно: каталог nested/ создаётся приложением отдельно, и при неверной настройке владелец у него может отличаться от владельца файла в корне вывода. Цикл по find ловит и такие случаи.
Требование 6 проверяется по факту изменения inode. Скрипт печатает inode до и после sed -i и подтверждает, что он действительно менялся. Без этого проверка была бы бессмысленной: если бы sed записал файл на месте, тест прошёл бы и при монтировании отдельного файла — то есть не проверил бы ровно то, ради чего написан.
Требование 8 проверяется дважды: запуском под чужим UID и поиском своего UID в файлах репозитория. Первая проверка показывает, что механизм работает; вторая — что он работает по правильной причине, а не потому, что у проверяющего тот же UID. Вторая проверка — единственная, которая поймала бы UID: 1000, случайно закоммиченный в compose.yaml.
Чего решение не делает. Если каталог ./output уже существует и принадлежит другому пользователю — например, остался от запусков от root до внедрения этой схемы, — bind mount владельца не изменит, и приложение упрётся в отказ записи. Промышленная обёртка проверяет владельца перед запуском и подсказывает sudo chown. Не покрыт и случай, когда UID разработчика совпадает с системным UID внутри базового образа: useradd тогда не создаст пользователя, а || true это скроет — сработает chown домашнего каталога, но whoami покажет чужое имя.
Очистка после раздела
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in alpine:3.21 python:3.13-slim postgres:17-alpine; do
docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker volume ls -qf dangling=true | xargs -r docker volume rm
docker image prune -f
docker system df -v | head -3
Сравните с состоянием, зафиксированным в начале раздела.
Обратите внимание: вторая строка удаляет все неиспользуемые volumes, включая named. Перед выполнением на рабочей машине посмотрите их содержимое (урок 7.6).
Критерии завершения
Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.
Дальше: Quiz 07.
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий раздел → Docker Networking
Главное оглавление