Раздел 4. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
mkdir -p ~/docker-course/04-lifecycle
cd ~/docker-course/04-lifecycle
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim alpine:3.21; do docker pull -q "$img"; done
Задание 1. Все состояния container [обяз.]
Постановка. Проведите один container через все доступные состояния и зафиксируйте на каждом шаге: вывод docker ps -a, значение .State.Status, PID процесса.
Последовательность: create → start → pause → unpause → stop → start → rm.
Ожидаемый результат. Таблица из семи строк. PID при первом и втором start должен различаться.
Проверка:
docker create --name st alpine sleep 300 > /dev/null
docker inspect st --format '{{.State.Status}} PID={{.State.Pid}}'
docker start st > /dev/null
docker inspect st --format '{{.State.Status}} PID={{.State.Pid}}'
docker rm -f st > /dev/null
Полное решение — в уроке 4.1.
Задание 2. Exec и его свойства [обяз.]
Постановка. Запустите долгоживущий container и через docker exec:
- Выполните команду и покажите, что процесс не имеет PID 1.
- Завершите
exec-процесс с кодом 1 и убедитесь, что container продолжает работать. - Покажите, что переменная, созданная entrypoint-скриптом, в
execне видна, а переменная изENV— видна. - Прочитайте фактическое окружение PID 1 обходным путём.
Ожидаемый результат. Вывод ps с PID больше единицы и демонстрация обоих случаев с переменными.
Разбор
cat > entrypoint.sh <<'SH'
#!/bin/sh
export RUNTIME_VAR="создан-в-entrypoint"
exec "$@"
SH
chmod +x entrypoint.sh
cat > Dockerfile.env <<'EOF'
FROM alpine:3.21
ENV DOCKERFILE_VAR="объявлен через ENV"
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["sleep", "300"]
EOF
docker build -q -f Dockerfile.env -t ex2:1 . > /dev/null
docker run -d --name ex2 ex2:1 > /dev/null
sleep 1
echo "=== 1. PID процесса exec ==="
docker exec ex2 ps -o pid,ppid,args
echo
echo "=== 2. Завершение exec с кодом 1 ==="
docker exec ex2 sh -c 'exit 1'
echo "код exec: $?"
docker ps --filter name=ex2 --format 'container: {{.Status}}'
echo
echo "=== 3. Переменные ==="
docker exec ex2 sh -c '
echo "DOCKERFILE_VAR: ${DOCKERFILE_VAR:-НЕ ВИДНА}"
echo "RUNTIME_VAR: ${RUNTIME_VAR:-НЕ ВИДНА}"
'
echo
echo "=== 4. Реальное окружение PID 1 ==="
docker exec ex2 sh -c "tr '\0' '\n' < /proc/1/environ | grep -E 'RUNTIME_VAR|DOCKERFILE_VAR'"
docker rm -f ex2 > /dev/null
docker rmi ex2:1 > /dev/null
=== 1. PID процесса exec ===
PID PPID COMMAND
1 0 sleep 300
8 0 ps -o pid,ppid,args
=== 2. Завершение exec с кодом 1 ===
код exec: 1
container: Up 2 seconds
=== 3. Переменные ===
DOCKERFILE_VAR: объявлен через ENV
RUNTIME_VAR: НЕ ВИДНА
=== 4. Реальное окружение PID 1 ===
DOCKERFILE_VAR=объявлен через ENV
RUNTIME_VAR=создан-в-entrypoint
Разбор наблюдений.
Процесс ps получил PID 8 и PPID 0. Нулевой родитель означает, что процесс порождён извне PID namespace — через setns(), а не как потомок PID 1.
Завершение exec с кодом 1 не затронуло container: он остался в состоянии Up. Это принципиальное отличие от attach, где сигнал может остановить главный процесс.
Переменная DOCKERFILE_VAR видна, потому что она записана в конфигурацию образа и Docker добавляет её в окружение любого процесса container, включая exec. Переменная RUNTIME_VAR создана командой export внутри процесса entrypoint — это часть окружения конкретного процесса, и новый процесс её не наследует.
Чтение /proc/1/environ показывает фактическое окружение работающего приложения. Это основной приём диагностики, когда нужно понять, с какими переменными реально работает сервис, — например, при подозрении, что переменная не дошла до приложения.
Задание 3. Graceful shutdown [обяз.]
Постановка. Напишите Python-приложение, которое обрабатывает SIGTERM, и докажите измерением, что docker stop завершает его корректно.
Требования к доказательству:
- Время
docker stopменьше секунды. - Exit code равен
0. - В логах присутствуют строки, которые печатает только обработчик.
Затем удалите обработчик и покажите, как изменятся все три показателя.
Ожидаемый результат. Две серии измерений с объяснением разницы.
Полное решение — в уроке 4.4.
Задание 4. Влияние формы CMD [обяз.]
Постановка. Соберите два образа с одинаковым Python-кодом (обработчик SIGTERM установлен), различающихся только формой записи CMD: shell form и exec form.
Покажите:
- Дерево процессов в каждом случае.
- Время
docker stopи exit code. - Дошёл ли сигнал до приложения.
Затем почините shell-form вариант, не переходя на exec form.
Ожидаемый результат. Таблица сравнения трёх конфигураций и рабочее исправление.
Проверка:
docker exec <container> ps -o pid,args | awk '$1==1'
Разбор
Полная реализация — в уроке 4.4. Ключевые выводы:
Форма CMD | PID 1 | Время stop | Код | Сигнал дошёл |
|---|---|---|---|---|
CMD python -u /app.py | /bin/sh -c ... | 10.3 c | 137 | нет |
CMD ["python", "-u", "/app.py"] | python | 0.4 c | 0 | да |
CMD exec python -u /app.py | python | 0.4 c | 0 | да |
Как чинится shell form. Встроенная команда exec заменяет процесс оболочки процессом приложения — вызывается execve() без порождения нового процесса. sh не остаётся в памяти, приложение становится PID 1.
Это нужно, когда возможности оболочки действительно требуются:
FROM python:3.13-slim
COPY app.py /app/app.py
ENV APP_MODULE=/app/app.py
CMD exec python -u "$APP_MODULE"
Без exec подстановка $APP_MODULE тоже сработает, но sh останется PID 1 и сигнал не дойдёт.
Тот же приём обязателен в entrypoint-скриптах: последняя строка должна быть exec "$@", а не "$@".
Практический критерий проверки чужого образа:
docker exec <container> ps -o pid,args | awk '$1==1'
Если PID 1 — это /bin/sh -c ..., graceful shutdown работать не будет независимо от качества кода приложения.
Задание 5. Zombie и --init [доп.]
Постановка. Воспроизведите накопление zombie-процессов и устраните проблему флагом --init.
- Напишите приложение, порождающее процессы, которые оставляют сирот.
- Покажите zombie в
psбез--init. - Покажите их отсутствие с
--init. - Объясните, почему с
--initменяется exit code приdocker stopдля приложения без обработчика.
Проверка:
docker exec <container> sh -c "ps -o stat= | grep -c '^Z'"
Полное решение — в уроке 4.5.
Задание 6. Exit codes 125, 126, 127 [доп.]
Постановка. Воспроизведите каждый из трёх кодов и объясните, чем причины принципиально различаются.
Для каждого укажите: какая часть системы виновата и как исправлять.
Разбор
echo "=== 125 ==="
docker run --rm --invalid-flag alpine echo x 2>&1 | head -1
echo "код: $?"
echo
echo "=== 126 ==="
printf '#!/bin/sh\necho hi\n' > script.sh
chmod 644 script.sh # намеренно без бита исполнения
docker run --rm -v "$PWD/script.sh:/s.sh:ro" alpine /s.sh 2>&1 | tail -1
echo "код: $?"
echo
echo "=== 127 ==="
docker run --rm alpine bash -c 'echo x' 2>&1 | tail -1
echo "код: $?"
=== 125 ===
unknown flag: --invalid-flag
код: 125
=== 126 ===
docker: Error response from daemon: ... permission denied
код: 126
=== 127 ===
docker: Error response from daemon: ... exec: "bash": executable file not found in $PATH
код: 127
Принципиальное различие — на каком этапе произошёл сбой.
| Код | Этап | Кто виноват | Как исправлять |
|---|---|---|---|
125 | До создания container | Команда docker | Исправить флаги; образ ни при чём |
126 | При запуске процесса | Права на файл | chmod +x, проверить shebang и переводы строк |
127 | При поиске файла | Отсутствие файла или интерпретатора | Исправить путь или доустановить зависимость |
Проверить теорию для 126:
chmod +x script.sh
docker run --rm -v "$PWD/script.sh:/s.sh:ro" alpine /s.sh
hi
Второй частый источник 126 — файлы с переводами строк CRLF, созданные в Windows. Shebang #!/bin/sh\r содержит символ возврата каретки, и ядро ищет интерпретатор с именем /bin/sh\r, которого нет. Проверить:
file script.sh
Ожидается POSIX shell script, ASCII text executable. Наличие with CRLF line terminators — источник проблемы; лечится dos2unix или настройкой .gitattributes.
Для 127 самый частый случай в реальных проектах — bash в Alpine. В образе есть только sh из BusyBox:
docker run --rm alpine sh -c 'echo работает'
docker run --rm alpine sh -c 'ls -l /bin/sh; command -v bash || echo "bash отсутствует"'
работает
lrwxrwxrwx 1 root root 12 Jun 14 02:11 /bin/sh -> /bin/busybox
bash отсутствует
Задание 7. Restart policies [доп.]
Постановка. Сравните поведение всех четырёх политик в двух сценариях: завершение с кодом 0 и с кодом 1.
Затем покажите единственное различие между always и unless-stopped — потребуется перезапуск daemon.
Ожидаемый результат. Таблица из восьми ячеек плюс демонстрация различия.
Перезапуск daemon остановит все ваши containers, если не включён
live-restore. Выполняйте на учебной машине.
Разбор
test_policy() {
local policy="$1" code="$2"
local name="rp-${policy//:/-}-$code"
docker run -d --name "$name" --restart="$policy" \
alpine sh -c "sleep 1; exit $code" > /dev/null 2>&1
sleep 8
printf ' %-18s код %s -> перезапусков: %s, статус: %s\n' \
"$policy" "$code" \
"$(docker inspect "$name" --format '{{.RestartCount}}')" \
"$(docker inspect "$name" --format '{{.State.Status}}')"
docker rm -f "$name" > /dev/null 2>&1
}
echo "=== Реакция на код возврата ==="
for p in no on-failure:3 always unless-stopped; do
test_policy "$p" 0
test_policy "$p" 1
done
=== Реакция на код возврата ===
no код 0 -> перезапусков: 0, статус: exited
no код 1 -> перезапусков: 0, статус: exited
on-failure:3 код 0 -> перезапусков: 0, статус: exited
on-failure:3 код 1 -> перезапусков: 3, статус: exited
always код 0 -> перезапусков: 3, статус: restarting
always код 1 -> перезапусков: 3, статус: restarting
unless-stopped код 0 -> перезапусков: 3, статус: restarting
unless-stopped код 1 -> перезапусков: 3, статус: restarting
Единственная строка, где on-failure отличается от always, — код 0: успешное завершение не считается сбоем.
Различие always и unless-stopped:
docker run -d --name a1 --restart=always alpine sleep 3600 > /dev/null
docker run -d --name u1 --restart=unless-stopped alpine sleep 3600 > /dev/null
sleep 2
docker stop a1 u1 > /dev/null
sudo systemctl restart docker
sleep 5
docker ps -a --filter 'name=a1' --filter 'name=u1' \
--format 'table {{.Names}}\t{{.Status}}'
docker rm -f a1 u1 > /dev/null
NAMES STATUS
u1 Exited (137) 25 seconds ago
a1 Up 4 seconds
always поднял вручную остановленный container, unless-stopped — нет.
Почему это важно. Сценарий из практики: сервис остановлен для планового обслуживания, ночью сервер перезагружается по обновлению ядра. С always сервис поднимется в неконсистентном состоянии посреди обслуживания. С unless-stopped — останется остановленным, как и было решено.
Поэтому для сервисов на серверах предпочтителен unless-stopped.
Задание 8. Диагностика: 10 секунд на остановку [диаг.]
Постановка. Дан container, который останавливается ровно 10 секунд с кодом 137. Есть две независимые причины такого поведения.
- Назовите обе.
- Воспроизведите каждую по отдельности.
- Предложите проверку, однозначно различающую их.
- Исправьте обе.
Подсказка
Одна причина связана с формой записи CMD, другая — со свойством PID 1, о котором говорилось в уроке 4.5.
Разбор
Причина A: приложение не является PID 1 — между Docker и приложением стоит оболочка из-за shell form.
Причина B: приложение является PID 1, но не имеет обработчика SIGTERM — ядро игнорирует сигналы без обработчика для PID 1.
Обе дают идентичный наблюдаемый результат: 10 секунд, код 137.
cat > app_h.py <<'PY'
import signal, sys, time
def h(s, f):
print("HANDLER", flush=True); sys.exit(0)
signal.signal(signal.SIGTERM, h)
print("started", flush=True)
while True: time.sleep(0.2)
PY
cat > app_nh.py <<'PY'
import time
print("started", flush=True)
while True: time.sleep(0.2)
PY
# A: обработчик ЕСТЬ, но shell form
cat > Dockerfile.A <<'EOF'
FROM python:3.13-slim
COPY app_h.py /app.py
CMD python -u /app.py
EOF
# B: exec form, но обработчика НЕТ
cat > Dockerfile.B <<'EOF'
FROM python:3.13-slim
COPY app_nh.py /app.py
CMD ["python", "-u", "/app.py"]
EOF
docker build -q -f Dockerfile.A -t diag:a . > /dev/null
docker build -q -f Dockerfile.B -t diag:b . > /dev/null
for v in a b; do
docker run -d --name "d-$v" "diag:$v" > /dev/null
sleep 1
s="$(date +%s.%N)"; docker stop "d-$v" > /dev/null; e="$(date +%s.%N)"
printf 'вариант %s: %.1f c, код %s\n' "$v" \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect "d-$v" --format '{{.State.ExitCode}}')"
docker rm "d-$v" > /dev/null
done
вариант a: 10.3 c, код 137
вариант b: 10.3 c, код 137
Симптом идентичен.
Различающая проверка — кто является PID 1:
for v in a b; do
docker run -d --name "d-$v" "diag:$v" > /dev/null
sleep 1
printf 'вариант %s, PID 1: %s\n' "$v" \
"$(docker exec "d-$v" ps -o args= -p 1 2>/dev/null | head -1)"
docker rm -f "d-$v" > /dev/null
done
вариант a, PID 1: /bin/sh -c python -u /app.py
вариант b, PID 1: python -u /app.py
Диагноз однозначен: /bin/sh в PID 1 — причина A; собственно приложение — причина B.
Универсальная процедура диагностики:
docker stop занимает 10 секунд, код 137
│
▼
docker exec <c> ps -o args= -p 1
│
┌────────┴────────┐
│ │
/bin/sh -c … приложение
│ │
▼ ▼
ПРИЧИНА A ПРИЧИНА B
shell form нет обработчика
│ │
▼ ▼
exec form signal.signal(SIGTERM, …)
или exec в CMD
Исправление обеих:
cat > Dockerfile.fixed <<'EOF'
FROM python:3.13-slim
COPY app_h.py /app.py
CMD ["python", "-u", "/app.py"]
EOF
docker build -q -f Dockerfile.fixed -t diag:fixed . > /dev/null
docker run -d --name d-fixed diag:fixed > /dev/null
sleep 1
s="$(date +%s.%N)"; docker stop d-fixed > /dev/null; e="$(date +%s.%N)"
printf 'исправлено: %.2f c, код %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect d-fixed --format '{{.State.ExitCode}}')"
docker logs d-fixed | tail -1
docker rm d-fixed > /dev/null
docker rmi diag:a diag:b diag:fixed > /dev/null
исправлено: 0.42 c, код 0
HANDLER
Обе причины устранены: exec form обеспечивает доставку сигнала, обработчик обеспечивает корректное завершение. Нужны оба условия — исправление только одного оставит проблему.
Задание 9. Restart loop [диаг.]
Постановка. Создайте container с политикой always, падающий каждые несколько секунд. Затем:
- Определите, что это restart loop, по трём независимым признакам.
- Найдите причину, не изменяя приложение.
- Разорвите цикл, не удаляя container.
- Объясните, почему при падении раз в 15 секунд backoff может не наблюдаться.
Разбор
cat > flaky.py <<'PY'
import os, sys, time
n = int(os.environ.get("LIFETIME", "3"))
print(f"старт, проживу {n} c", flush=True)
time.sleep(n)
print("критическая ошибка: не найдена конфигурация", file=sys.stderr, flush=True)
sys.exit(1)
PY
docker run -d --name loop --restart=always -e LIFETIME=3 \
-v "$PWD/flaky.py:/app.py:ro" \
python:3.13-slim python -u /app.py > /dev/null
sleep 20
Три признака restart loop:
echo "1. Статус в docker ps:"
docker ps -a --filter name=loop --format ' {{.Status}}'
echo "2. Счётчик перезапусков (два замера):"
echo " $(docker inspect loop --format '{{.RestartCount}}')"
sleep 6
echo " $(docker inspect loop --format '{{.RestartCount}}') — вырос"
echo "3. События:"
docker events --since 30s --until 0s --filter container=loop \
--format ' {{.Action}}' 2>/dev/null | tail -6
1. Статус в docker ps:
Restarting (1) 2 seconds ago
2. Счётчик перезапусков (два замера):
5
6 — вырос
3. События:
start
die
start
die
start
die
Каждый признак самостоятелен. Второй — самый надёжный: статус в docker ps может застать container в момент Up, а счётчик растёт монотонно.
Поиск причины:
docker logs loop 2>&1 | tail -4
старт, проживу 3 c
критическая ошибка: не найдена конфигурация
старт, проживу 3 c
критическая ошибка: не найдена конфигурация
Логи сохраняются между перезапусками, и причина видна прямо в них.
Разрыв цикла:
docker update --restart=no loop
sleep 5
docker ps -a --filter name=loop --format '{{.Status}}'
Exited (1) 2 seconds ago
Container остановился и больше не поднимается. Теперь можно спокойно разбираться: логи не затираются, состояние стабильно, можно запустить docker exec для осмотра.
Это первое действие при разборе restart loop в production — иначе диагностика идёт наперегонки с перезапусками.
Почему backoff может не наблюдаться:
docker rm -f loop > /dev/null
docker run -d --name loop15 --restart=always -e LIFETIME=15 \
-v "$PWD/flaky.py:/app.py:ro" \
python:3.13-slim python -u /app.py > /dev/null
echo "интервалы для приложения, живущего 15 с:"
timeout 45 docker events --filter container=loop15 \
--format '{{.Time}} {{.Action}}' 2>/dev/null | grep -E 'die|start'
docker rm -f loop15 > /dev/null
1785409201 start
1785409216 die
1785409216 start
1785409231 die
1785409231 start
Задержка между die и start нулевая. Причина: Docker сбрасывает счётчик backoff, если container проработал более 10 секунд. Приложение укладывалось в 15 секунд, поэтому каждый перезапуск считался «первым».
Практический вывод. Отсутствие нарастающей задержки не означает отсутствия проблемы. Более того, такой restart loop опаснее: в docker ps он большую часть времени выглядит как Up, и мониторинг по признаку «container запущен» его не заметит. Отслеживать нужно RestartCount.
docker rmi -f $(docker images -q --filter reference='diag:*') 2>/dev/null || true
Задание 10. Полная диагностика [★]
Постановка. Напишите скрипт diagnose-exit.sh <container>, который по завершившемуся container выдаёт готовый диагноз с рекомендацией.
Требования:
- Расшифровка exit code, включая
125,126,127и128+N. - Для
137— различение OOM,docker killи истёкшего grace period. - Обнаружение restart loop по
RestartCountи статусу. - Последние события и логи.
- Конкретная рекомендация для каждого случая.
Скрипт должен корректно работать для container в любом состоянии.
Полное решение — в уроке 4.6.
Задание 11. Ожидание завершения в скрипте [★]
Постановка. Напишите функцию run_task, которая запускает разовую задачу в detached режиме, дожидается её завершения и возвращает её exit code — так, чтобы вызывающий скрипт мог корректно на него отреагировать.
Требования:
- ограничение по времени: если задача не завершилась за N секунд, остановить её и вернуть отдельный код;
- container удаляется в любом случае, включая прерывание скрипта;
- логи задачи выводятся при ненулевом коде.
Объясните, почему нельзя просто использовать docker run без -d.
Подсказка 1
docker wait блокируется до завершения и печатает exit code. Ограничить его по времени можно через timeout.
Подсказка 2
Гарантированная уборка — через trap ... EXIT или локальную ловушку внутри функции.
Разбор
#!/usr/bin/env bash
# run_task — запуск разовой задачи с таймаутом и корректным exit code.
set -uo pipefail
run_task() {
local timeout_sec="$1"; shift
local name="task-$$-$RANDOM"
local code
if ! docker run -d --name "$name" "$@" > /dev/null 2>&1; then
echo "не удалось запустить задачу" >&2
return 125
fi
if code="$(timeout "$timeout_sec" docker wait "$name" 2>/dev/null)"; then
: # задача завершилась сама
else
echo "задача не уложилась в ${timeout_sec} c — останавливаю" >&2
docker stop "$name" > /dev/null 2>&1
docker rm -f "$name" > /dev/null 2>&1
return 124
fi
if [ "${code:-1}" -ne 0 ]; then
echo "--- логи задачи (код $code) ---" >&2
docker logs "$name" 2>&1 | sed 's/^/ /' >&2
fi
docker rm -f "$name" > /dev/null 2>&1
return "${code:-1}"
}
echo "=== 1. Успешная задача ==="
run_task 10 alpine sh -c 'echo работаю; exit 0'
echo "код: $?"
echo
echo "=== 2. Задача с ошибкой ==="
run_task 10 alpine sh -c 'echo что-то пошло не так >&2; exit 3'
echo "код: $?"
echo
echo "=== 3. Задача, превысившая таймаут ==="
run_task 3 alpine sleep 60
echo "код: $?"
=== 1. Успешная задача ===
код: 0
=== 2. Задача с ошибкой ===
--- логи задачи (код 3) ---
что-то пошло не так
код: 3
=== 3. Задача, превысившая таймаут ===
задача не уложилась в 3 c — останавливаю
код: 124
Почему не подходит просто docker run без -d.
На первый взгляд docker run --rm alpine cmd; echo $? решает задачу: exit code проходит наружу. Но три требования не выполняются.
Таймаут. Обернуть в timeout можно, но при срабатывании будет убит клиент docker, а не container. Container останется работать, и без --rm он ещё и останется в системе. С --rm он будет удалён после завершения, но само завершение может не наступить никогда.
Гарантированная уборка. При прерывании скрипта между запуском и завершением container остаётся. С detached режимом и явным docker rm в ловушке этого не происходит.
Логи при ошибке. В foreground режиме вывод уже смешался с выводом скрипта. В detached режиме логи можно получить отдельно и показать только при ненулевом коде.
Дополнительно detached режим позволяет запустить несколько задач параллельно и дождаться всех:
for t in a b c; do
docker run -d --name "batch-$t" alpine sh -c "sleep 2; echo $t" > /dev/null
done
for t in a b c; do
printf '%s: код %s\n' "$t" "$(docker wait "batch-$t")"
docker rm "batch-$t" > /dev/null
done
a: код 0
b: код 0
c: код 0
Код 124 для таймаута выбран по соглашению утилиты timeout — она возвращает именно его. Это делает поведение функции предсказуемым для тех, кто знаком с timeout.
Очистка после раздела
docker ps -aq --filter 'name=task-' | xargs -r docker rm -f
docker ps -aq --filter 'name=batch-' | xargs -r docker rm -f
docker ps -aq --filter 'status=exited' | xargs -r docker rm
docker image prune -f
docker system df
Критерии завершения
Раздел закрыт, когда выполнены обязательные задания 1–4 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.
Дальше: Quiz 04 и Checkpoint 1.
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий раздел → Dockerfile
Главное оглавление