Главная/Проверка знаний/Проверка знаний

Итоговый практический экзамен

Контейнеризовать незнакомое приложение и подготовить его к эксплуатации.

Время: 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.pyHTTP-интерфейс
linkcheck/__main__.pyТочка входа

Интерфейс

МетодПутьНазначение
GET/healthСостояние сервиса
POST/checkПоставить задачу; тело {"urls": [...]}
GET/jobsСписок готовых отчётов
GET/jobs/IDОтчёт по идентификатору

Исходный код

linkcheck/__init__.py:

python
__version__ = "0.4.2"

linkcheck/config.py:

python
"""Настройки. Читаются из файла рядом с модулем."""
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:

json
{
  "port": 8080,
  "workers": 4
}

linkcheck/store.py:

python
"""Хранение результатов проверок на диске."""
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:

python
"""Проверка доступности ссылок.

Работает в пуле потоков: сетевое ожидание, а не вычисления.
"""
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:

python
"""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:

python
from .server import main

main()

Проверка, что приложение работает

bash
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Работает с корневой файловой системой только для чтения
6docker stop завершает сервис менее чем за 5 секунд
7Отчёты переживают пересоздание container'а
8Healthcheck различает «запущен» и «готов»
9Логи пригодны для машинного разбора
10Ограничения памяти и процессора заданы и обоснованы
11compose.yaml поднимает сервис одной командой
12Тесты; их провал останавливает сборку
13README с командами, переменными и обоснованием решений
14Раздел «чего решение не делает»

Ограничения

  • Менять код приложения можно, но каждое изменение должно быть обосновано в README. Изменение, которого можно было избежать настройкой, считается ошибкой.
  • Добавлять зависимости можно, но каждая должна быть обоснована. Приложение работает на стандартной библиотеке.
  • Образ не более 250 MB.

Что снимает работу независимо от баллов

Список — из rubric:

  1. Секреты в слоях образа.
  2. Запуск от root без письменного обоснования.
  3. Не воспроизводится с чистой машины.
  4. Не завершается по docker stop в пределах grace period.

Порядок работы

ЧасЧто делать
1Запустить приложение локально. Прочитать код целиком. Составить список того, что помешает эксплуатации
2Решить по каждому пункту: настройка, изменение кода или «оставить как есть»
3–4Dockerfile, compose.yaml, изменения в коде
5Тесты и проверки образа
6README, проверка из чистого клона, самооценка по rubric

Первый час — самый важный. Список препятствий, составленный до написания первой строки Dockerfile, определяет всё остальное. Начав с Dockerfile, вы будете подгонять решения под уже написанное.


Подсказка о том, как читать это приложение

Не «что здесь плохо написано», а что помешает этому работать в эксплуатации. Это разные списки.

Полезные вопросы к каждому файлу:

ВопросЗачем
Откуда берётся значение и можно ли его изменить снаружи?Требование 3
Что записывается на диск и куда именно?Требования 5 и 7
Что произойдёт при получении SIGTERM прямо сейчас?Требование 6
Что означает ответ этого эндпоинта?Требование 8
Куда идёт этот вывод — в stdout или в stderr?Требование 9
Это состояние переживёт перезапуск?Требование 7

Критерии завершения

Работа готова, когда все утверждения проверены командой:

bash
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. Сколько препятствий вы нашли в первый час и сколько обнаружилось позже? Второе число — мера того, насколько внимательно был прочитан код.
  2. Какие изменения в коде вы внесли и можно ли было обойтись без них? Изменение, которого можно было избежать настройкой, — ошибка, даже если оно улучшило код.
  3. Что вы решили оставить как есть и почему? Список должен быть непустым: не всё, что вы бы написали иначе, требует исправления.

Разбор

Открывать после сдачи работы. До этого — не открывать: список препятствий и есть содержание экзамена.

Показать разбор

Препятствия, заложенные в приложение

Их девять. Найти все за первый час — отличный результат; семь — хороший.

ЧтоГдеЧто сломается
1host: 127.0.0.1 по умолчаниюconfig.pyСервис недоступен снаружи container'а
2Настройки читаются из файла внутри пакетаconfig.pyИзменить без пересборки нельзя
3Каталог отчётов создаётся при импортеserver.py, store.pyПадение при read_only
4Нет обработки SIGTERMserver.pydocker stop длится весь grace period
5/health отвечает 200 при warmed_up: falseserver.pyТрафик идёт на непрогретый сервис
6Логи через basicConfig — в stderr, неструктурированныеserver.pyНе разбираются машиной
7Фоновая работа в daemon-потокеserver.pyРезультаты теряются при остановке
8JOBS в памяти, отчёты на дискеserver.pyСостояние рассогласовано после перезапуска
9cache_dir объявлен и не используетсяconfig.pyЛовушка: тома для него не нужно

Разбор по пунктам

1. Адрес привязки. Настройка, а не код: host берётся из настроек, значит достаточно задать 0.0.0.0 снаружи — как только решён пункт 2. Менять умолчание в коде не требуется.

2. Настройки внутри пакета. Единственное обязательное изменение кода. Файл settings.json лежит рядом с модулем и попадает в образ; изменить его без пересборки нельзя, а значит требование 3 не выполнить настройкой.

Минимальное исправление — добавить чтение окружения поверх файла:

python
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() не останавливается сам.

python
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 без переписывания вызовов:

python
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

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

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
Практические проекты
Главное оглавление

Markdown на GitHub ↗