Итоговый практический экзамен
Контейнеризовать незнакомое приложение и подготовить его к эксплуатации.
Время: 6 часов.
Вес в итоговом балле: 30 %.
Оценивание: rubric, проходной результат 70.
Экзамен отличается от проектов одним: приложение написано не вами, и оно содержит решения, которые вы бы не приняли. Часть из них придётся изменить, часть — обойти, а про какие-то решить, что трогать их не нужно.
Это и есть то, что происходит в работе чаще, чем разработка с нуля.
Что дано
Приложение linkcheck версии 0.4.2 — сервис проверки доступности ссылок. Принимает список адресов, проверяет их в несколько потоков, сохраняет отчёт, отдаёт по идентификатору.
Зависимостей нет: только стандартная библиотека Python.
Устройство
| Файл | Назначение |
|---|---|
linkcheck/__init__.py | Версия |
linkcheck/config.py | Настройки |
linkcheck/settings.json | Значения настроек |
linkcheck/store.py | Хранение отчётов на диске |
linkcheck/checker.py | Проверка ссылок в пуле потоков |
linkcheck/server.py | HTTP-интерфейс |
linkcheck/__main__.py | Точка входа |
Интерфейс
| Метод | Путь | Назначение |
|---|---|---|
GET | /health | Состояние сервиса |
POST | /check | Поставить задачу; тело {"urls": [...]} |
GET | /jobs | Список готовых отчётов |
GET | /jobs/ID | Отчёт по идентификатору |
Исходный код
linkcheck/__init__.py:
__version__ = "0.4.2"
linkcheck/config.py:
"""Настройки. Читаются из файла рядом с модулем."""
from __future__ import annotations
import json
from pathlib import Path
DEFAULTS = {
"host": "127.0.0.1",
"port": 8080,
"workers": 4,
"timeout_seconds": 10,
"cache_dir": "/var/cache/linkcheck",
"report_dir": "/var/lib/linkcheck/reports",
"warmup_seconds": 12,
}
CONFIG_PATH = Path(__file__).with_name("settings.json")
def load() -> dict:
settings = dict(DEFAULTS)
if CONFIG_PATH.exists():
settings.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
return settings
linkcheck/settings.json:
{
"port": 8080,
"workers": 4
}
linkcheck/store.py:
"""Хранение результатов проверок на диске."""
from __future__ import annotations
import json
import time
from pathlib import Path
class ReportStore:
def __init__(self, report_dir: str) -> None:
self.dir = Path(report_dir)
self.dir.mkdir(parents=True, exist_ok=True)
def save(self, job_id: str, payload: dict) -> Path:
path = self.dir / f"{job_id}.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
def load(self, job_id: str) -> dict | None:
path = self.dir / f"{job_id}.json"
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def list_ids(self) -> list[str]:
return sorted(p.stem for p in self.dir.glob("*.json"))
def prune(self, older_than_seconds: int) -> int:
now = time.time()
removed = 0
for p in self.dir.glob("*.json"):
if now - p.stat().st_mtime > older_than_seconds:
p.unlink()
removed += 1
return removed
linkcheck/checker.py:
"""Проверка доступности ссылок.
Работает в пуле потоков: сетевое ожидание, а не вычисления.
"""
from __future__ import annotations
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
def check_one(url: str, timeout: int) -> dict:
started = time.perf_counter()
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
status = response.status
error = None
except urllib.error.HTTPError as exc:
status, error = exc.code, None
except Exception as exc:
status, error = None, f"{type(exc).__name__}: {exc}"
return {
"url": url,
"status": status,
"error": error,
"ms": round((time.perf_counter() - started) * 1000, 1),
}
def check_all(urls: list[str], workers: int, timeout: int) -> list[dict]:
with ThreadPoolExecutor(max_workers=workers) as pool:
return list(pool.map(lambda u: check_one(u, timeout), urls))
linkcheck/server.py:
"""HTTP-интерфейс на стандартной библиотеке."""
from __future__ import annotations
import json
import logging
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from . import __version__
from .checker import check_all
from .config import load
from .store import ReportStore
log = logging.getLogger("linkcheck")
SETTINGS = load()
STORE = ReportStore(SETTINGS["report_dir"])
STARTED_AT = time.time()
JOBS: dict[str, str] = {}
def warmed_up() -> bool:
"""Прогрев: первые секунды сервис отвечает, но работает медленно."""
return time.time() - STARTED_AT >= SETTINGS["warmup_seconds"]
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send(self, code: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt: str, *args) -> None:
log.info(fmt % args)
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/health":
self._send(200, {"status": "ok", "version": __version__,
"warmed_up": warmed_up()})
elif path == "/jobs":
self._send(200, {"jobs": STORE.list_ids()})
elif path.startswith("/jobs/"):
job_id = path.split("/")[-1]
report = STORE.load(job_id)
if report is None:
self._send(404, {"error": "не найдено", "job_id": job_id})
else:
self._send(200, report)
else:
self._send(404, {"error": "неизвестный путь", "path": path})
def do_POST(self) -> None:
if urlparse(self.path).path != "/check":
self._send(404, {"error": "неизвестный путь"})
return
length = int(self.headers.get("Content-Length", 0))
try:
payload = json.loads(self.rfile.read(length))
urls = payload["urls"]
except Exception as exc:
self._send(400, {"error": f"неверное тело запроса: {exc}"})
return
job_id = uuid.uuid4().hex[:12]
JOBS[job_id] = "running"
def work() -> None:
results = check_all(urls, SETTINGS["workers"], SETTINGS["timeout_seconds"])
STORE.save(job_id, {"job_id": job_id, "results": results,
"finished_at": time.time()})
JOBS[job_id] = "done"
threading.Thread(target=work, daemon=True).start()
self._send(202, {"job_id": job_id, "urls": len(urls)})
def main() -> None:
logging.basicConfig(level=logging.INFO)
server = ThreadingHTTPServer((SETTINGS["host"], SETTINGS["port"]), Handler)
log.info("linkcheck %s слушает %s:%s", __version__,
SETTINGS["host"], SETTINGS["port"])
server.serve_forever()
if __name__ == "__main__":
main()
linkcheck/__main__.py:
from .server import main
main()
Проверка, что приложение работает
python3 -m linkcheck &
sleep 1
curl -s localhost:8080/health
curl -s -X POST localhost:8080/check -H 'content-type: application/json' \
-d '{"urls": ["https://example.com"]}'
Приложение запускается и работает — это проверено. Все трудности, которые вы найдёте, относятся к эксплуатации, а не к работоспособности.
Требования к результату
Обязательные
| № | Требование |
|---|---|
| 1 | Образ собирается из чистого клона одной командой |
| 2 | Сервис доступен снаружи container'а |
| 3 | Конфигурация задаётся без пересборки образа |
| 4 | Запуск не от root, идентификатор числовой |
| 5 | Работает с корневой файловой системой только для чтения |
| 6 | docker stop завершает сервис менее чем за 5 секунд |
| 7 | Отчёты переживают пересоздание container'а |
| 8 | Healthcheck различает «запущен» и «готов» |
| 9 | Логи пригодны для машинного разбора |
| 10 | Ограничения памяти и процессора заданы и обоснованы |
| 11 | compose.yaml поднимает сервис одной командой |
| 12 | Тесты; их провал останавливает сборку |
| 13 | README с командами, переменными и обоснованием решений |
| 14 | Раздел «чего решение не делает» |
Ограничения
- Менять код приложения можно, но каждое изменение должно быть обосновано в
README. Изменение, которого можно было избежать настройкой, считается ошибкой. - Добавлять зависимости можно, но каждая должна быть обоснована. Приложение работает на стандартной библиотеке.
- Образ не более 250 MB.
Что снимает работу независимо от баллов
Список — из rubric:
- Секреты в слоях образа.
- Запуск от
rootбез письменного обоснования. - Не воспроизводится с чистой машины.
- Не завершается по
docker stopв пределах grace period.
Порядок работы
| Час | Что делать |
|---|---|
| 1 | Запустить приложение локально. Прочитать код целиком. Составить список того, что помешает эксплуатации |
| 2 | Решить по каждому пункту: настройка, изменение кода или «оставить как есть» |
| 3–4 | Dockerfile, compose.yaml, изменения в коде |
| 5 | Тесты и проверки образа |
| 6 | README, проверка из чистого клона, самооценка по rubric |
Первый час — самый важный. Список препятствий, составленный до написания первой строки Dockerfile, определяет всё остальное. Начав с Dockerfile, вы будете подгонять решения под уже написанное.
Подсказка о том, как читать это приложение
Не «что здесь плохо написано», а что помешает этому работать в эксплуатации. Это разные списки.
Полезные вопросы к каждому файлу:
| Вопрос | Зачем |
|---|---|
| Откуда берётся значение и можно ли его изменить снаружи? | Требование 3 |
| Что записывается на диск и куда именно? | Требования 5 и 7 |
Что произойдёт при получении SIGTERM прямо сейчас? | Требование 6 |
| Что означает ответ этого эндпоинта? | Требование 8 |
Куда идёт этот вывод — в stdout или в stderr? | Требование 9 |
| Это состояние переживёт перезапуск? | Требование 7 |
Критерии завершения
Работа готова, когда все утверждения проверены командой:
git clone . /tmp/проверка && cd /tmp/проверка
docker compose build
docker compose up -d
sleep 5
curl -s localhost:8080/health # 1, 2
docker compose exec -T linkcheck sh -c 'exit 0' || true # 4: оболочки может не быть
docker image inspect ЛОКАЛЬНЫЙ_ОБРАЗ --format '{{.Config.User}}' # 4
time docker compose stop # 6
docker compose up -d && sleep 5
curl -s localhost:8080/jobs # 7: отчёты на месте
docker compose logs --no-log-prefix | head -3 | \
python3 -c 'import json,sys; [json.loads(l) for l in sys.stdin]; print("логи разбираются")' # 9
docker build --target test . # 12
Самооценка
Заполните rubric по семи категориям. Затем ответьте письменно на три вопроса:
- Сколько препятствий вы нашли в первый час и сколько обнаружилось позже? Второе число — мера того, насколько внимательно был прочитан код.
- Какие изменения в коде вы внесли и можно ли было обойтись без них? Изменение, которого можно было избежать настройкой, — ошибка, даже если оно улучшило код.
- Что вы решили оставить как есть и почему? Список должен быть непустым: не всё, что вы бы написали иначе, требует исправления.
Разбор
Открывать после сдачи работы. До этого — не открывать: список препятствий и есть содержание экзамена.
Показать разбор
Препятствия, заложенные в приложение
Их девять. Найти все за первый час — отличный результат; семь — хороший.
| № | Что | Где | Что сломается |
|---|---|---|---|
| 1 | host: 127.0.0.1 по умолчанию | config.py | Сервис недоступен снаружи container'а |
| 2 | Настройки читаются из файла внутри пакета | config.py | Изменить без пересборки нельзя |
| 3 | Каталог отчётов создаётся при импорте | server.py, store.py | Падение при read_only |
| 4 | Нет обработки SIGTERM | server.py | docker stop длится весь grace period |
| 5 | /health отвечает 200 при warmed_up: false | server.py | Трафик идёт на непрогретый сервис |
| 6 | Логи через basicConfig — в stderr, неструктурированные | server.py | Не разбираются машиной |
| 7 | Фоновая работа в daemon-потоке | server.py | Результаты теряются при остановке |
| 8 | JOBS в памяти, отчёты на диске | server.py | Состояние рассогласовано после перезапуска |
| 9 | cache_dir объявлен и не используется | config.py | Ловушка: тома для него не нужно |
Разбор по пунктам
1. Адрес привязки. Настройка, а не код: host берётся из настроек, значит достаточно задать 0.0.0.0 снаружи — как только решён пункт 2. Менять умолчание в коде не требуется.
2. Настройки внутри пакета. Единственное обязательное изменение кода. Файл settings.json лежит рядом с модулем и попадает в образ; изменить его без пересборки нельзя, а значит требование 3 не выполнить настройкой.
Минимальное исправление — добавить чтение окружения поверх файла:
import os
PREFIX = "LINKCHECK_"
def load() -> dict:
settings = dict(DEFAULTS)
if CONFIG_PATH.exists():
settings.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
for key in DEFAULTS:
env = os.environ.get(PREFIX + key.upper())
if env is not None:
settings[key] = type(DEFAULTS[key])(env)
return settings
Соблазн переписать модуль на pydantic-settings — ошибка: добавляется зависимость там, где хватает восьми строк, а приложение обходилось стандартной библиотекой.
3. Запись на диск. ReportStore.__init__ вызывает mkdir(parents=True) — при read_only: true это отказ, если каталог не смонтирован. Решается настройкой: том в /var/lib/linkcheck/reports. Код менять не нужно.
Заодно это закрывает требование 7: отчёты в томе переживают пересоздание.
4. Обработка SIGTERM. Второе обязательное изменение кода: serve_forever() не останавливается сам.
import signal
def main() -> None:
server = ThreadingHTTPServer((SETTINGS["host"], SETTINGS["port"]), Handler)
def on_term(signum, frame):
log.info("получен сигнал %s, завершаюсь", signum)
threading.Thread(target=server.shutdown, daemon=True).start()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, on_term)
server.serve_forever()
server.server_close()
server.shutdown() вызывается из отдельного потока намеренно: из обработчика сигнала он приведёт к взаимной блокировке, потому что ждёт завершения цикла, внутри которого выполняется.
Тонкость, которую стоит понимать. Запустив приложение локально и послав ему SIGTERM, вы не увидите десятисекундной задержки: обычный процесс умирает от SIGTERM мгновенно, по действию ядра по умолчанию.
Симптом существует только внутри container'а, где приложение имеет PID 1: для PID 1 ядро действий по умолчанию не применяет, и сигнал без обработчика просто игнорируется до прихода SIGKILL (урок 4.5).
Отсюда практический вывод для этого экзамена: проверять требование 6 нужно time docker stop, а не локальным kill. Локальный запуск здесь обманывает.
Что проверяется локально — что обработчик работает: с ним процесс завершается с кодом 0 и успевает записать строку о завершении, без него код возврата равен −15 и записи нет.
5. Прогрев и пробы. /health возвращает 200 всегда, но содержит warmed_up. Здесь возможны два обоснованных решения:
- использовать
/healthкак liveness и добавить/ready, проверяющийwarmed_up— изменение кода; - использовать
/healthкак liveness, а для readiness проверять поле в ответе через--start-period, равныйwarmup_seconds, — только настройка.
Второе решает требование 8 без изменения кода и потому предпочтительнее. Ответ «добавил /ready» тоже принимается — если обоснован.
6. Логи. basicConfig без аргументов пишет в stderr в человекочитаемом виде. Требование 9 — машинный разбор.
Минимальное решение — задать формат JSON без переписывания вызовов:
logging.basicConfig(
level=logging.INFO,
stream=sys.stdout,
format='{"ts":"%(asctime)s","level":"%(levelname)s","message":"%(message)s"}',
)
Хрупко: сообщение с кавычкой сломает разбор. Полноценное решение — свой Formatter. Оба принимаются; хрупкость минимального должна быть названа в README.
7 и 8. Потерянные результаты и рассогласованное состояние. Задача, поставленная в POST /check, выполняется в daemon-потоке: при остановке процесса он не дожидается. Словарь JOBS живёт в памяти и после перезапуска пуст, тогда как отчёты на диске остаются.
Это архитектурные ограничения. Правильный ответ на экзамене — не чинить их, а назвать в README как известные:
Задача, запущенная перед остановкой, будет потеряна: результат нигде не фиксируется до завершения. Устойчивая обработка требует очереди с подтверждением и выходит за рамки контейнеризации.
Попытка «починить» их за оставшееся время почти наверняка ухудшит работу: это переписывание приложения, а не подготовка к эксплуатации.
9. cache_dir. Объявлен в умолчаниях и нигде не используется — grep по коду это показывает. Том для него создавать не нужно; упоминание в README — признак того, что код прочитан.
Что засчитывается как правильный итог
| Решение | Верно |
|---|---|
Два изменения в коде: окружение и SIGTERM | Да |
| Плюс третье: разделение проб | Да, если обосновано |
| Пять и больше изменений | Скорее нет: часть можно было решить настройкой |
| Ноль изменений | Нет: требования 3 и 6 настройкой не закрыть |
| Пункты 7 и 8 названы и не исправлены | Да |
| Пункты 7 и 8 исправлены | Обычно нет: не хватит времени на остальное |
cache_dir упомянут как неиспользуемый | Признак внимательного чтения |
Ориентир по Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
FROM base AS test
COPY linkcheck/ ./linkcheck/
COPY tests/ ./tests/
RUN pip install --no-cache-dir pytest==9.1.1 \
&& python -m pytest -q
FROM base AS runtime
COPY linkcheck/ ./linkcheck/
RUN mkdir -p /var/lib/linkcheck/reports \
&& chown -R 10001:10001 /var/lib/linkcheck
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
CMD ["python", "-c", "import json,urllib.request,sys; \
r=urllib.request.urlopen('http://127.0.0.1:8080/health'); \
sys.exit(0 if json.load(r)['warmed_up'] else 1)"]
ENTRYPOINT ["python", "-m", "linkcheck"]
Зависимостей нет, поэтому стадия установки не нужна — это тот случай, когда multi-stage применяется только ради изоляции тестов.
--start-period=15s больше, чем warmup_seconds: 12: иначе проверки во время прогрева пометят container нездоровым.
Ориентир по compose.yaml
services:
linkcheck:
build: {context: ., target: runtime}
image: linkcheck:0.4.2
environment:
LINKCHECK_HOST: "0.0.0.0"
LINKCHECK_PORT: "8080"
LINKCHECK_WARMUP_SECONDS: "12"
LINKCHECK_REPORT_DIR: /var/lib/linkcheck/reports
ports: ["127.0.0.1:8080:8080"]
volumes:
- reports:/var/lib/linkcheck/reports
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
stop_grace_period: 10s
deploy:
resources:
limits: {cpus: "1.0", memory: 256M}
logging:
driver: json-file
options: {max-size: "10m", max-file: "3"}
volumes:
reports:
Про ограничение памяти. Значение обосновывается измерением: workers: 4 потока, каждый держит ответ целиком в памяти. 256 MB — с запасом; называть число без измерения — то, за что снимают балл в категории Reliability.
Типичные ошибки на этом экзамене
| Ошибка | Почему |
|---|---|
| Переписать конфигурацию на стороннюю библиотеку | Зависимость там, где хватает восьми строк |
Начать с Dockerfile, не прочитав код | Решения подгоняются под уже написанное |
Смонтировать том для cache_dir | Каталог не используется |
Оставить host: 127.0.0.1 | Сервис недоступен; работа не принимается |
Проверять SIGTERM локальным kill | Вне container'а симптома нет: процесс не PID 1 |
Использовать /health и как liveness, и как readiness без warmed_up | Трафик пойдёт на непрогретый сервис |
| Чинить потерю задач при остановке | Переписывание приложения вместо контейнеризации |
| Не назвать известные ограничения | Впечатление полноты, которой нет |
chmod 777 на каталоге отчётов | Права решаются владельцем, а не доступом всем |
Что этот экзамен проверяет
Не умение писать Dockerfile — оно проверено проектами. Здесь проверяются три вещи, которые проектами не проверяются вовсе.
Чтение чужого кода с определённой целью. Список препятствий составляется не «что тут плохо», а «что помешает эксплуатации». Это разные списки, и второй короче.
Различение настройки и изменения кода. Из девяти препятствий кодом решаются два. Инженер, меняющий код там, где хватает переменной окружения, создаёт расхождение с исходным проектом на пустом месте.
Умение остановиться. Два препятствия из девяти правильно не чинить, а назвать. Работа, где исправлено всё, почти наверняка не доведена в остальном — шесть часов не резиновые, и выбор, куда их потратить, тоже оценивается.
Навигация
Вернуться к системе проверки
Итоговый теоретический тест
Grading rubric
Практические проекты
Главное оглавление