Главная/Ограничения Docker/Урок

19.4. Каталог антипаттернов

Цели

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

  • распознавать более сорока антипаттернов в чужих конфигурациях;
  • для каждого называть последствие, а не только правило;
  • объяснять, почему антипаттерн выглядит разумным — это и есть причина его живучести;
  • отличать механически проверяемые случаи от требующих разговора;
  • построить проверку своей конфигурации и убедиться, что она различает хорошее и плохое.

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

Раздел справочный и опирается на весь курс. Ссылки на разборы приведены в таблицах.

Урок предназначен для одного полного прочтения и последующего использования как чек-лист. Полная версия каталога также доступна как справочник.

Как устроен каталог

Каждый антипаттерн описан четырьмя полями:

ПолеЗачем
АнтипаттернЧто именно сделано
Почему выглядит разумнымБез этого поля каталог бесполезен: люди не делают заведомо глупого
Чем оборачиваетсяПоследствие, а не нарушение правила
Как правильноДействие, а не пожелание

Второе поле — главное. Антипаттерн живёт не потому, что о нём не знают, а потому, что он решает настоящую проблему негодным способом. Пока не назван способ лучше, совет «не делайте так» не работает.


Сборка образов

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
FROM ubuntu:latestВсегда свежий базовый образСборка невоспроизводима: вчера и сегодня разные образыКонкретный тег, а лучше digest (14.3)
COPY . . до установки зависимостейОдна инструкция вместо двухЛюбое изменение кода сбрасывает кэш установкиСначала файл зависимостей, потом код (5.7)
RUN apt-get update отдельно от installЛогическое разделение шаговКэшированный update даёт установку из устаревшего индексаОдна инструкция update && install
Кэш пакетного менеджера не удалён в том же слоеУдалили следующей строкойФайлы остались в предыдущем слое; образ большеУдаление в той же RUN (3.2)
COPY secret и RUN rm secretСекрет удалёнОн остаётся в слое и извлекаетсяRUN --mount=type=secret (12.6)
Отладочные инструменты в финальном образеПригодятся при разбореПлощадь атаки растёт; образ тяжелееОтдельная стадия или временный container (13.5)
ADD для локальных файловДелает то же и большеРаспаковывает архивы и качает URL неявноCOPY, а загрузку — явной командой
Нет .dockerignoreНе выглядит обязательнымКонтекст раздувается; .git и секреты попадают в образ.dockerignore с первого дня (5.4)
pip install без фиксации версийВсегда последние версииДве сборки подряд дают разные образыФайл блокировки (6.4)
Сборка в финальном образеПроще один DockerfileКомпиляторы и заголовки едут в эксплуатациюMulti-stage (5.9)
RUN pip install --user от root, затем USERКажется безопаснееПакеты в домашнем каталоге root, недоступныУстановка после смены пользователя или в общий префикс
Каждая команда — своя RUNЧитается лучшеСлоёв больше, кэш дробитсяГруппировать связанные операции

Запуск и жизненный цикл

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
CMD python app.py (shell-форма)Короче и привычнееPID 1 — оболочка; SIGTERM не доходит до приложенияExec-форма (4.6)
Запуск от rootТак работает по умолчаниюUID 0 внутри равен UID 0 снаружи (19.2)USER с числовым идентификатором
USER app вместо USER 10001Читается понятнееKubernetes не может проверить runAsNonRootЧисловой UID (18.2)
Несколько процессов под супервизоромПохоже на привычный серверОтказ одного не виден снаружи; масштабирование невозможноОдин процесс на container (2.4)
sleep infinity как точка входаContainer перестаёт «падать»Скрывает отказ; сервис мёртв, container живУстранить причину выхода
Нет обработки SIGTERMПриложение и так завершаетсяЧерез 10 секунд SIGKILL; незавершённые операцииОбработчик и мягкое завершение (6.7)
restart: always вместо диагностикиСервис «сам поднимается»Цикл падений маскирует причинуРазобрать причину, затем политика перезапуска
tail -f /dev/null рядом с сервисомУдобно заходить внутрьТо же, что выше: container живёт без сервисаОтдельный container для отладки
Долгая инициализация без startup-пробыРаботает жеLiveness убивает приложение до готовностиStartup-проба (11.3)

Данные

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
Данные в записываемом слоеРаботает без настройкиИсчезают при пересоздании container'аТом (7.2)
Bind mount каталога хоста в эксплуатацииФайлы видно снаружиПрава, владельцы, привязка к машинеИменованный том
Нет резервного копирования тома«Данные в Docker»Docker копированием не занимается (19.1)Внешняя процедура копирования и проверка восстановления
chmod 777 на томеРешает проблему прав немедленноЛюбой процесс может всёСовпадающие UID и GID (7.5)
Логи в файл внутри container'аКак на обычном сервереНе видны сборщику; растут в слоеstdout и stderr (13.1)
База данных в эксплуатации без ответа «как восстановим»Compose поднимает её одной строкойПотеря данных при первом же отказеОтветить на шесть вопросов (19.1)
Том для кэша, который не жаль потерятьЕдинообразиеЛишняя сущность в эксплуатацииtmpfs или без тома

Сеть

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
--network host для производительностиУбирает NAT и vethИзоляция снята целиком (19.1)Измерить, действительно ли сеть узкое место
Обращение по IP-адресуАдрес известен и стабиленАдреса меняются при пересозданииОбращение по имени сервиса (8.4)
Публикация портов «на всякий случай»Пригодится для отладкиСервис доступен снаружи без надобностиПубликовать только нужное
links вместо сетейВстречается в старых примерахУстаревший механизмПользовательские сети (8.3)
Все сервисы в одной сетиПроще настроитьСервис базы доступен всемРаздельные сети (9.4)
Публикация на 0.0.0.0 вместо 127.0.0.1Так по умолчаниюПорт открыт наружу, а не только локальноЯвный адрес привязки

Конфигурация и секреты

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
ENV DB_PASSWORD=... в DockerfileПросто и работаетВиден в docker history и в inspectСекрет при запуске (12.6)
Секрет через ARG при сборкеНе попадает в ENVОстаётся в метаданных сборкиRUN --mount=type=secret
.env внутри образаКонфигурация «едет с приложением»Секреты в образе; один образ на окружениеФайл монтируется или задаётся окружением
Отдельный образ на каждое окружениеКажется надёжнееПроверенный образ и запущенный — разныеОдин образ, разная конфигурация (11.2)
Секреты в compose.yaml в репозиторииУдобно и всё в одном местеУтечка при первом же клонированииВнешний файл вне репозитория
Конфигурация правится docker execБыстрее, чем пересобиратьИзменение исчезнет при пересозданииИзменить источник конфигурации
Один общий секрет на все сервисыМеньше сущностейКомпрометация одного равна компрометации всехОтдельный секрет на сервис

Compose

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
depends_on без conditionВыглядит как порядок запускаЖдёт запуска, а не готовностиcondition: service_healthy и повторы в приложении (9.3)
image: myapp:latestВсегда актуальная версияНепонятно, что именно запущеноКонкретный тег или digest
Один файл на все окруженияМеньше файловОтладочные настройки попадают в эксплуатациюБазовый файл плюс override (9.5)
container_name у масштабируемого сервисаУдобно обращаться по имени--scale перестаёт работать: имя занятоОбращение по имени сервиса
build: в файле для эксплуатацииОдин файл на всёВ эксплуатации собирается вместо запуска готовогоРаздельные файлы: сборка и запуск
version: в начале файлаТак во всех старых примерахПоле устарело и игнорируетсяУбрать (9.2)
Тома объявлены, но не в volumes:Работает и такСоздаётся анонимный том; данные теряютсяОбъявить именованный том

Эксплуатация

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
Исправление через docker execБыстро чинит проблемуИсчезнет при пересоздании; расхождение с образомИсправить образ и выкатить
Нет ограничений ресурсовПриложение и так не жрётОдна утечка останавливает всю машинуmem_limit и cpus (11.4)
docker system prune -a в эксплуатацииКончилось местоУдаляет образы, нужные для откатаЦелевая очистка с пониманием, что удаляется
Нет healthcheckContainer же запущен«Запущен» не значит «работает»Healthcheck (11.3)
Перезаписываемый тег prodОдно имя, всегда актуальноеОткатиться некуда: старого образа нетНеизменяемые теги плюс digest
Логи не ограничены по размеруДрайвер по умолчаниюДиск заполняется логамиРотация в настройке драйвера (13.1)
Монтирование docker.sock в сервисНужно управлять container'амиРавно правам root на хосте (19.2)Отдельный агент с ограниченными правами
Сборка образа на машине разработчикаБыстрее, чем ждать CIОбраз невоспроизводим; зависит от машиныСборка в CI (16.1)

Организационные

АнтипаттернПочему выглядит разумнымЧем оборачиваетсяКак правильно
«Завернём всё в Docker»Единообразие как ценностьЧасть задач не решается контейнеризацией (19.3)Вопрос: что перестанет работать без Docker
Один человек знает, как собирается образТак сложилосьТочка отказа в людях, а не в системеDockerfile в репозитории, сборка в CI
Базовые образы не обновляютсяРаботает — не трогайИзвестные уязвимости накапливаютсяРегулярная пересборка (12.7)
Сканирование настроено, результаты не читаютФормально требование выполненоОтчёт без действий равен отсутствию отчётаПороги и остановка сборки (16.4)
Копирование Dockerfile из статьи без разбораЭкономит времяПереносятся и антипаттерны из статьиПонимать каждую инструкцию
Kubernetes «потому что так делают»Индустриальная практикаПостоянная стоимость без выгоды (18.4)Чек-лист принятия решения

Что проверяется механически, а что нет

Существенное различие, без которого каталог превращается в перечень пожеланий:

ГруппаПроверяется линтеромТребует разговора
Сборка образовПочти всё«Понимать каждую инструкцию»
Запуск и жизненный циклФорма CMD, USER, супервизорОбработка SIGTERM внутри кода
ДанныеОтсутствие тома, chmod 777Есть ли процедура восстановления
Сетьnetwork_host, links, публикацияНужен ли порт снаружи
Конфигурация и секретыСекрет в ENV, .env в образеОдин ли секрет на все сервисы
Composelatest, container_name, versionРазделены ли окружения по смыслу
ЭксплуатацияЛимиты, healthcheck, ротация логовИсправления через exec
ОрганизационныеНичегоВсё

Последняя строка — главная. Организационные антипаттерны наносят наибольший ущерб и не обнаруживаются ни одним инструментом. Линтер, показывающий ноль находок, ничего не говорит о том, знает ли кто-нибудь ещё, как собрать этот образ.


Команды и примеры

Линтер по каталогу

bash
mkdir -p /tmp/lint && cd /tmp/lint

cat > lint.py <<'PY'
"""Проверка Dockerfile и compose.yaml по каталогу антипаттернов.

Проверяется только механически обнаружимое. Организационные
антипаттерны не проверяются ни одним инструментом — это отмечено
в отчёте отдельно, чтобы «ноль находок» не читалось как «всё хорошо».
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import yaml

# (код, где, серьёзность, описание, как правильно)
Finding = tuple[str, str, str, str, str]


def lint_dockerfile(text: str) -> list[Finding]:
    found: list[Finding] = []
    lines = [l.rstrip() for l in text.splitlines()]
    joined = "\n".join(lines)

    has_user = False
    numeric_user = False
    copy_all_line = 0
    deps_line = 0

    for i, raw in enumerate(lines, 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        upper = line.upper()

        if upper.startswith("FROM") and ":latest" in line:
            found.append(("build.latest", f"строка {i}", "высокая",
                          "базовый образ по тегу latest",
                          "конкретный тег, лучше digest"))
        if upper.startswith("FROM") and ":" not in line.split()[1]:
            found.append(("build.notag", f"строка {i}", "высокая",
                          "базовый образ без тега — это latest",
                          "конкретный тег, лучше digest"))
        if re.match(r"RUN\s+apt-get\s+update\s*$", line, re.I):
            found.append(("build.split_update", f"строка {i}", "средняя",
                          "apt-get update отдельной инструкцией",
                          "объединить с install в одной RUN"))
        if upper.startswith("ADD ") and "http" not in line and ".tar" not in line:
            found.append(("build.add", f"строка {i}", "низкая",
                          "ADD для локальных файлов",
                          "COPY"))
        if re.match(r"COPY\s+\.\s+\.?", line, re.I):
            copy_all_line = i
        if re.search(r"(requirements\.txt|pyproject\.toml|uv\.lock|poetry\.lock)",
                     line, re.I) and upper.startswith("COPY"):
            deps_line = i
        if upper.startswith("USER"):
            has_user = True
            value = line.split(None, 1)[1].strip() if len(line.split()) > 1 else ""
            numeric_user = bool(re.match(r"^\d+(:\d+)?$", value))
        if upper.startswith("CMD") and not line.strip().endswith("]"):
            found.append(("run.shellform", f"строка {i}", "высокая",
                          "CMD в shell-форме: PID 1 будет оболочкой",
                          "exec-форма: CMD [\"python\", \"-m\", \"app\"]"))
        if upper.startswith("ENTRYPOINT") and not line.strip().endswith("]"):
            found.append(("run.shellform_entry", f"строка {i}", "высокая",
                          "ENTRYPOINT в shell-форме",
                          "exec-форма"))
        if re.search(r"(PASSWORD|SECRET|TOKEN|API_KEY)\s*=", line, re.I) \
                and upper.startswith(("ENV", "ARG")):
            found.append(("secret.in_image", f"строка {i}", "высокая",
                          "секрет в ENV или ARG: виден в истории образа",
                          "RUN --mount=type=secret или секрет при запуске"))
        if "chmod 777" in line or "chmod -R 777" in line:
            found.append(("data.chmod777", f"строка {i}", "высокая",
                          "chmod 777",
                          "совпадающие UID и GID"))
        if re.search(r"pip\s+install", line, re.I) and "==" not in line \
                and "-r " not in line and "--no-deps" not in line:
            found.append(("build.unpinned", f"строка {i}", "средняя",
                          "установка пакетов без фиксации версий",
                          "файл блокировки или явные версии"))
        if "sleep infinity" in line or "tail -f /dev/null" in line:
            found.append(("run.fake_alive", f"строка {i}", "высокая",
                          "container держится живым искусственно",
                          "устранить причину выхода процесса"))

    if copy_all_line and deps_line and copy_all_line < deps_line:
        found.append(("build.copy_order", f"строка {copy_all_line}", "средняя",
                      "COPY . . раньше установки зависимостей",
                      "сначала файл зависимостей, потом код"))
    if not has_user:
        found.append(("run.root", "файл", "высокая",
                      "USER не задан: запуск от root",
                      "USER с числовым идентификатором"))
    elif not numeric_user:
        found.append(("run.named_user", "файл", "средняя",
                      "USER задан именем, а не числом",
                      "числовой UID: Kubernetes проверяет runAsNonRoot"))
    if "apt-get install" in joined and "rm -rf /var/lib/apt/lists" not in joined:
        found.append(("build.apt_cache", "файл", "средняя",
                      "кэш apt не удалён",
                      "rm -rf /var/lib/apt/lists/* в той же RUN"))
    return found


def lint_compose(doc: dict) -> list[Finding]:
    found: list[Finding] = []
    if "version" in doc:
        found.append(("compose.version", "корень", "низкая",
                      "поле version устарело и игнорируется", "убрать"))

    declared_volumes = set((doc.get("volumes") or {}).keys())

    for name, svc in (doc.get("services") or {}).items():
        svc = svc or {}
        where = f"сервис {name}"

        image = str(svc.get("image", ""))
        if image.endswith(":latest") or (image and ":" not in image):
            found.append(("compose.latest", where, "высокая",
                          "образ по тегу latest или без тега",
                          "конкретный тег или digest"))
        if "container_name" in svc:
            found.append(("compose.container_name", where, "средняя",
                          "container_name делает --scale невозможным",
                          "обращаться по имени сервиса"))
        if "build" in svc and "image" not in svc:
            found.append(("compose.build_in_prod", where, "низкая",
                          "сборка описана без имени образа",
                          "раздельные файлы: сборка и запуск"))
        if isinstance(svc.get("depends_on"), list):
            found.append(("compose.depends_no_condition", where, "средняя",
                          "depends_on без condition: ждёт запуска, не готовности",
                          "condition: service_healthy и повторы в приложении"))
        if svc.get("network_mode") == "host":
            found.append(("net.host", where, "высокая",
                          "network_mode: host снимает сетевую изоляцию",
                          "измерить, действительно ли сеть узкое место"))
        if "links" in svc:
            found.append(("net.links", where, "средняя",
                          "links — устаревший механизм",
                          "пользовательские сети"))
        if not svc.get("healthcheck") and "image" in svc:
            found.append(("ops.no_healthcheck", where, "средняя",
                          "нет healthcheck: «запущен» не значит «работает»",
                          "healthcheck с проверкой готовности"))
        limits = (svc.get("deploy") or {}).get("resources", {}).get("limits")
        if not limits and not svc.get("mem_limit"):
            found.append(("ops.no_limits", where, "средняя",
                          "нет ограничений ресурсов",
                          "mem_limit и cpus"))
        user = str(svc.get("user", ""))
        if user in ("0", "root", "0:0"):
            found.append(("run.root", where, "высокая",
                          "запуск от root",
                          "числовой непривилегированный UID"))
        env = svc.get("environment") or {}
        pairs = env.items() if isinstance(env, dict) else (
            (e.split("=", 1)[0], e.split("=", 1)[-1]) for e in env if "=" in e)
        for key, value in pairs:
            # Суффикс _FILE означает ПУТЬ к секрету, а не сам секрет:
            # без этого исключения правило срабатывает на правильном приёме.
            if re.search(r"(PASSWORD|SECRET|TOKEN|API_KEY)", str(key), re.I) \
                    and not str(key).upper().endswith("_FILE") \
                    and value and not str(value).startswith("$"):
                found.append(("secret.in_compose", where, "высокая",
                              f"секрет в открытом виде: {key}",
                              "внешний файл вне репозитория"))
        for v in svc.get("volumes") or []:
            spec = v if isinstance(v, str) else str(v.get("source", ""))
            source = spec.split(":")[0]
            if source.startswith("/") and source != "/var/run/docker.sock":
                found.append(("data.bind_mount", where, "низкая",
                              f"bind mount каталога хоста: {source}",
                              "именованный том"))
            elif "docker.sock" in spec:
                found.append(("ops.docker_sock", where, "высокая",
                              "монтирование сокета равно правам root на хосте",
                              "агент с ограниченными правами"))
            elif source and not source.startswith((".", "/")) \
                    and source not in declared_volumes:
                found.append(("data.undeclared_volume", where, "средняя",
                              f"том {source} не объявлен в volumes:",
                              "объявить именованный том"))
    return found


# Антипаттерны, которые линтер не обнаруживает в принципе
NOT_DETECTABLE = [
    ("Организационные", "все шесть: знание образа, обновление базовых, "
     "чтение отчётов, копирование из статьи"),
    ("Обработка SIGTERM", "находится в коде приложения, а не в конфигурации"),
    ("Процедура восстановления данных", "существует вне репозитория"),
    ("Нужен ли порт снаружи", "зависит от назначения сервиса"),
    ("Исправления через docker exec", "происходят в работающей системе"),
    ("Разделены ли окружения по смыслу", "требует понимания задачи"),
]


def main(argv: list[str]) -> int:
    findings: list[Finding] = []
    for path in argv:
        p = Path(path)
        if not p.exists():
            continue
        if p.name.lower().startswith("dockerfile"):
            findings += lint_dockerfile(p.read_text(encoding="utf-8"))
        elif p.suffix in (".yaml", ".yml"):
            doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
            findings += lint_compose(doc)

    order = {"высокая": 0, "средняя": 1, "низкая": 2}
    findings.sort(key=lambda f: (order[f[2]], f[0]))

    by_severity: dict[str, int] = {}
    for _, _, sev, _, _ in findings:
        by_severity[sev] = by_severity.get(sev, 0) + 1

    print(json.dumps({
        "находок": len(findings),
        "по_серьёзности": by_severity,
        "список": [{"код": c, "где": w, "серьёзность": s,
                    "что": d, "как_правильно": h}
                   for c, w, s, d, h in findings],
        "не_обнаруживается": NOT_DETECTABLE,
    }, ensure_ascii=False))
    return 1 if by_severity.get("высокая") else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
PY

cat > Dockerfile.bad <<'DOCKERFILE'
FROM python:latest

ENV DB_PASSWORD=hunter2

COPY . .
COPY requirements.txt .

RUN apt-get update
RUN apt-get install -y curl gcc
RUN pip install flask requests
RUN chmod -R 777 /app

CMD python app.py
DOCKERFILE

cat > compose.bad.yaml <<'EOF'
version: "3.8"

services:
  web:
    image: myapp:latest
    container_name: web
    network_mode: host
    user: "0:0"
    environment:
      DB_PASSWORD: hunter2
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/data:/data
    depends_on:
      - db

  db:
    image: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data
EOF

echo "═══ плохая конфигурация ═══"
python3 lint.py Dockerfile.bad compose.bad.yaml > bad.json; code=$?
python3 - <<'PY'
import json

d = json.load(open("bad.json"))
print(f"  находок: {d['находок']}  {d['по_серьёзности']}")
print()
print(f"  {'серьёзность':<12} {'код':<28} {'где':<14} что")
print("  " + "─" * 104)
for f in d["список"]:
    print(f"  {f['серьёзность']:<12} {f['код']:<28} {f['где']:<14} {f['что']}")
PY
echo "  код возврата: $code"

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

text
═══ плохая конфигурация ═══
  находок: 24  {'высокая': 11, 'средняя': 11, 'низкая': 2}

  серьёзность  код                          где            что
  ────────────────────────────────────────────────────────────────────────────────────────────────────────
  высокая      build.latest                 строка 1       базовый образ по тегу latest
  высокая      compose.latest               сервис web     образ по тегу latest или без тега
  высокая      compose.latest               сервис db      образ по тегу latest или без тега
  высокая      data.chmod777                строка 11      chmod 777
  высокая      net.host                     сервис web     network_mode: host снимает сетевую изоляцию
  высокая      ops.docker_sock              сервис web     монтирование сокета равно правам root на хосте
  высокая      run.root                     файл           USER не задан: запуск от root
  высокая      run.root                     сервис web     запуск от root
  высокая      run.shellform                строка 13      CMD в shell-форме: PID 1 будет оболочкой
  высокая      secret.in_compose            сервис web     секрет в открытом виде: DB_PASSWORD
  высокая      secret.in_image              строка 3       секрет в ENV или ARG: виден в истории образа
  средняя      build.apt_cache              файл           кэш apt не удалён
  средняя      build.copy_order             строка 5       COPY . . раньше установки зависимостей
  средняя      build.split_update           строка 8       apt-get update отдельной инструкцией
  средняя      build.unpinned               строка 10      установка пакетов без фиксации версий
  средняя      compose.container_name       сервис web     container_name делает --scale невозможным
  средняя      compose.depends_no_condition сервис web     depends_on без condition: ждёт запуска, не готовности
  ...
  низкая       compose.version              корень         поле version устарело и игнорируется
  низкая       data.bind_mount              сервис web     bind mount каталога хоста: /opt/data
  код возврата: 1

Проверка на чистой конфигурации

Инструмент, находящий проблемы во всём, бесполезен так же, как не находящий ничего.

bash
cd /tmp/lint

cat > Dockerfile.good <<'DOCKERFILE'
# syntax=docker/dockerfile:1
FROM python:3.13-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS base

WORKDIR /app

COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-deps -r requirements.lock

COPY src/ ./src/

USER 10001:10001

ENTRYPOINT ["python", "-m", "app.main"]
DOCKERFILE

cat > compose.good.yaml <<'EOF'
services:
  web:
    image: ghcr.io/org/app:1.4.2
    user: "10001:10001"
    read_only: true
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
      interval: 10s
    deploy:
      resources:
        limits: {cpus: "0.5", memory: 256M}
    depends_on:
      db:
        condition: service_healthy
    networks: [backend]

  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
    deploy:
      resources:
        limits: {cpus: "1.0", memory: 1G}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [backend]

volumes:
  pgdata:

networks:
  backend:
EOF

echo "═══ чистая конфигурация ═══"
python3 lint.py Dockerfile.good compose.good.yaml > good.json; code=$?
python3 - <<'PY'
import json

d = json.load(open("good.json"))
print(f"  находок: {d['находок']}  {d['по_серьёзности'] or 'нет'}")
for f in d["список"]:
    print(f"  {f['серьёзность']:<12} {f['код']:<28} {f['где']:<14} {f['что']}")
print()
print("  Ни одной находки высокой серьёзности — значит, находки")
print("  в плохой конфигурации что-то значат.")
PY
echo "  код возврата: $code"

echo
echo "═══ чего линтер не обнаруживает ═══"
python3 - <<'PY'
import json

d = json.load(open("good.json"))
print(f"  {'группа':<40} почему")
print("  " + "─" * 108)
for name, why in d["не_обнаруживается"]:
    print(f"  {name:<40} {why}")
print()
print(f"  не обнаруживается: {len(d['не_обнаруживается'])} групп")
print()
print("  Организационные антипаттерны наносят наибольший ущерб")
print("  и не находятся ни одним инструментом. «Ноль находок»")
print("  не говорит, знает ли кто-нибудь ещё, как собрать этот образ.")
PY

cd /tmp && rm -rf /tmp/lint

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

text
═══ чистая конфигурация ═══
  находок: 0  нет

  Ни одной находки высокой серьёзности — значит, находки
  в плохой конфигурации что-то значат.
  код возврата: 0

═══ чего линтер не обнаруживает ═══
  группа                                   почему
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Организационные                          все шесть: знание образа, обновление базовых, чтение отчётов, копирование из статьи
  Обработка SIGTERM                        находится в коде приложения, а не в конфигурации
  Процедура восстановления данных          существует вне репозитория
  Нужен ли порт снаружи                    зависит от назначения сервиса
  Исправления через docker exec            происходят в работающей системе
  Разделены ли окружения по смыслу         требует понимания задачи

  не обнаруживается: 6 групп

Проверка на чистой конфигурации нашла ошибку в самом линтере при написании этого урока. Первая редакция правила secret.in_compose срабатывала на строке

yaml
DB_PASSWORD_FILE: /run/secrets/db_password

то есть отмечала как утечку правильный приём — передачу пути к файлу секрета вместо самого секрета (урок 12.6). Исправление — исключить ключи с суффиксом _FILE.

Существенно, как ошибка обнаружилась: на плохой конфигурации линтер вёл себя безупречно и давал двадцать четыре осмысленные находки. Правило проявило себя только там, где находок быть не должно.

Отсюда общий вывод, применимый ко всякой проверке: инструмент проверяют на входе, который обязан пройти, а не только на том, который обязан не пройти. Ложное срабатывание дороже пропуска: после нескольких таких срабатываний отчёты перестают читать целиком.


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

Задание. Постройте линтер по каталогу и проверьте его на двух конфигурациях.

Требования:

  1. Проверять Dockerfile и compose.yaml; не менее пятнадцати правил.
  2. Каждая находка содержит код, место, серьёзность, описание и способ исправления.
  3. Различать серьёзность и отражать её в коде возврата.
  4. Дать не менее десяти находок на заведомо плохой конфигурации.
  5. Дать ноль находок высокой серьёзности на чистой конфигурации.
  6. Перечислить антипаттерны, которые линтер не обнаруживает в принципе.
  7. Соотнести правила с разделами каталога и показать покрытие по группам.

Подсказки

Подсказка 1

Правило «COPY . . раньше зависимостей» требует запоминать номера строк обеих инструкций и сравнивать их после обхода.

Подсказка 2

USER app и USER 10001 — разные находки разной серьёзности, а не одна.

Подсказка 3

Покрытие по группам считается сопоставлением префикса кода правила (build., run., data., net., secret., compose., ops.) с разделами каталога.

Решение

Показать решение
bash
mkdir -p /tmp/antilab && cd /tmp/antilab

cat > catalog.py <<'PY'
"""Каталог антипаттернов: группы и правила.

Каталог отделён от линтера намеренно. Список групп используется
и правилами, и отчётом о покрытии, и перечнем необнаружимого —
иначе три места разъезжаются при первом же изменении.
"""
from __future__ import annotations

GROUPS = {
    "build": "Сборка образов",
    "run": "Запуск и жизненный цикл",
    "data": "Данные",
    "net": "Сеть",
    "secret": "Конфигурация и секреты",
    "compose": "Compose",
    "ops": "Эксплуатация",
    "org": "Организационные",
}

# Антипаттерны, не обнаруживаемые статически — с указанием группы
NOT_DETECTABLE = [
    ("org", "Один человек знает, как собирается образ", "знание вне репозитория"),
    ("org", "Базовые образы не обновляются", "видно по датам, а не по файлам"),
    ("org", "Сканирование настроено, отчёты не читают", "процесс, а не конфигурация"),
    ("org", "Dockerfile скопирован из статьи без разбора", "не отличим от написанного"),
    ("org", "«Завернём всё в Docker»", "решение принято до конфигурации"),
    ("org", "Kubernetes «потому что так делают»", "то же"),
    ("run", "Нет обработки SIGTERM", "находится в коде приложения"),
    ("data", "Нет процедуры восстановления", "существует вне репозитория"),
    ("net", "Порт не нужен снаружи", "зависит от назначения сервиса"),
    ("ops", "Исправления через docker exec", "происходят в работающей системе"),
    ("compose", "Окружения не разделены по смыслу", "требует понимания задачи"),
]
PY

cat > lint.py <<'PY'
"""Линтер по каталогу антипаттернов.

Проверяет только механически обнаружимое. Всё, что требует
разговора, перечислено отдельно — чтобы «ноль находок»
не читалось как «всё хорошо».
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import yaml

from catalog import GROUPS, NOT_DETECTABLE

SEVERITY_ORDER = {"высокая": 0, "средняя": 1, "низкая": 2}


class Finding:
    def __init__(self, code: str, where: str, severity: str,
                 what: str, fix: str) -> None:
        self.code = code
        self.where = where
        self.severity = severity
        self.what = what
        self.fix = fix

    @property
    def group(self) -> str:
        return self.code.split(".", 1)[0]

    def as_dict(self) -> dict[str, str]:
        return {"код": self.code, "где": self.where,
                "серьёзность": self.severity, "что": self.what,
                "как_правильно": self.fix, "группа": GROUPS[self.group]}


def lint_dockerfile(text: str, name: str) -> list[Finding]:
    out: list[Finding] = []
    lines = text.splitlines()
    joined = "\n".join(lines)
    has_user = numeric_user = False
    copy_all = deps_copy = 0

    def add(code: str, line: int | None, sev: str, what: str, fix: str) -> None:
        where = f"{name}:{line}" if line else name
        out.append(Finding(code, where, sev, what, fix))

    for i, raw in enumerate(lines, 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        upper = line.upper()
        parts = line.split()

        if upper.startswith("FROM"):
            ref = parts[1] if len(parts) > 1 else ""
            if ref.endswith(":latest"):
                add("build.latest", i, "высокая",
                    "базовый образ по тегу latest",
                    "конкретный тег, лучше digest")
            elif ":" not in ref and "@" not in ref:
                add("build.notag", i, "высокая",
                    "базовый образ без тега — это latest",
                    "конкретный тег, лучше digest")
        if re.fullmatch(r"RUN\s+apt-get\s+update", line, re.I):
            add("build.split_update", i, "средняя",
                "apt-get update отдельной инструкцией",
                "объединить с install в одной RUN")
        if upper.startswith("ADD ") and "http" not in line and ".tar" not in line:
            add("build.add", i, "низкая", "ADD для локальных файлов", "COPY")
        if re.match(r"COPY\s+\.\s+\.?\s*$", line, re.I):
            copy_all = i
        if upper.startswith("COPY") and re.search(
                r"(requirements|pyproject|uv\.lock|poetry\.lock|package\.json)",
                line, re.I):
            deps_copy = i
        if re.search(r"pip\s+install", line, re.I) and not re.search(
                r"(==|-r\s|--no-deps|\.lock)", line):
            add("build.unpinned", i, "средняя",
                "установка пакетов без фиксации версий",
                "файл блокировки или явные версии")
        if upper.startswith("USER"):
            has_user = True
            value = parts[1] if len(parts) > 1 else ""
            numeric_user = bool(re.fullmatch(r"\d+(:\d+)?", value))
        if upper.startswith("CMD") and not line.endswith("]"):
            add("run.shellform", i, "высокая",
                "CMD в shell-форме: PID 1 будет оболочкой",
                "exec-форма со списком аргументов")
        if upper.startswith("ENTRYPOINT") and not line.endswith("]"):
            add("run.shellform", i, "высокая",
                "ENTRYPOINT в shell-форме",
                "exec-форма со списком аргументов")
        if "sleep infinity" in line or "tail -f /dev/null" in line:
            add("run.fake_alive", i, "высокая",
                "container держится живым искусственно",
                "устранить причину выхода процесса")
        if upper.startswith(("ENV", "ARG")) and re.search(
                r"(PASSWORD|SECRET|TOKEN|API_KEY)", line, re.I):
            add("secret.in_image", i, "высокая",
                "секрет в ENV или ARG: остаётся в истории образа",
                "RUN --mount=type=secret или секрет при запуске")
        if re.search(r"chmod\s+(-R\s+)?777", line):
            add("data.chmod777", i, "высокая", "chmod 777",
                "совпадающие UID и GID")
        if re.search(r"\brm\b.*\b(secret|\.env|credential)", line, re.I):
            add("secret.rm_after_copy", i, "высокая",
                "удаление секрета не убирает его из предыдущего слоя",
                "RUN --mount=type=secret")

    if copy_all and deps_copy and copy_all < deps_copy:
        add("build.copy_order", copy_all, "средняя",
            "COPY . . раньше установки зависимостей",
            "сначала файл зависимостей, потом код")
    if not has_user:
        add("run.root", None, "высокая", "USER не задан: запуск от root",
            "USER с числовым идентификатором")
    elif not numeric_user:
        add("run.named_user", None, "средняя",
            "USER задан именем, а не числом",
            "числовой UID: Kubernetes проверяет runAsNonRoot")
    if "apt-get install" in joined and "/var/lib/apt/lists" not in joined:
        add("build.apt_cache", None, "средняя", "кэш apt не удалён",
            "rm -rf /var/lib/apt/lists/* в той же RUN")
    return out


def lint_compose(doc: dict, name: str) -> list[Finding]:
    out: list[Finding] = []

    def add(code: str, where: str, sev: str, what: str, fix: str) -> None:
        out.append(Finding(code, where, sev, what, fix))

    if "version" in doc:
        add("compose.version", f"{name}:version", "низкая",
            "поле version устарело и игнорируется", "убрать")

    declared = set((doc.get("volumes") or {}).keys())

    for svc_name, svc in (doc.get("services") or {}).items():
        svc = svc or {}
        where = f"{name}:{svc_name}"
        image = str(svc.get("image", ""))

        if image and ("@" not in image):
            tag = image.rsplit(":", 1)[-1] if ":" in image else ""
            if tag == "latest" or not tag:
                add("compose.latest", where, "высокая",
                    "образ по тегу latest или без тега",
                    "конкретный тег или digest")
        if "container_name" in svc:
            add("compose.container_name", where, "средняя",
                "container_name делает --scale невозможным",
                "обращаться по имени сервиса")
        if isinstance(svc.get("depends_on"), list):
            add("compose.depends_no_condition", where, "средняя",
                "depends_on без condition: ждёт запуска, а не готовности",
                "condition: service_healthy и повторы в приложении")
        if svc.get("network_mode") == "host":
            add("net.host", where, "высокая",
                "network_mode: host снимает сетевую изоляцию",
                "проверить, действительно ли сеть узкое место")
        if "links" in svc:
            add("net.links", where, "средняя", "links — устаревший механизм",
                "пользовательские сети")
        for p in svc.get("ports") or []:
            spec = p if isinstance(p, str) else str(p.get("published", ""))
            if spec and not spec.startswith("127.0.0.1"):
                add("net.public_port", where, "низкая",
                    f"порт {spec} публикуется на все адреса",
                    "явный адрес привязки, если наружу не нужно")
        if str(svc.get("user", "")) in ("0", "root", "0:0"):
            add("run.root", where, "высокая", "запуск от root",
                "числовой непривилегированный UID")
        if image and not svc.get("healthcheck"):
            add("ops.no_healthcheck", where, "средняя",
                "нет healthcheck: «запущен» не значит «работает»",
                "healthcheck с проверкой готовности")
        limits = ((svc.get("deploy") or {}).get("resources") or {}).get("limits")
        if not limits and not svc.get("mem_limit"):
            add("ops.no_limits", where, "средняя", "нет ограничений ресурсов",
                "mem_limit и cpus или deploy.resources.limits")

        env = svc.get("environment") or {}
        pairs = list(env.items()) if isinstance(env, dict) else [
            (e.split("=", 1)[0], e.split("=", 1)[-1]) for e in env if "=" in e]
        for key, value in pairs:
            if re.search(r"(PASSWORD|SECRET|TOKEN|API_KEY)", str(key), re.I) \
                    and not str(key).endswith("_FILE") \
                    and value and not str(value).startswith("$"):
                add("secret.in_compose", where, "высокая",
                    f"секрет в открытом виде: {key}",
                    "файл секрета вне репозитория")

        for v in svc.get("volumes") or []:
            spec = v if isinstance(v, str) else \
                f"{v.get('source', '')}:{v.get('target', '')}"
            source = spec.split(":")[0]
            if "docker.sock" in spec:
                add("ops.docker_sock", where, "высокая",
                    "монтирование сокета равно правам root на хосте",
                    "агент с ограниченными правами")
            elif source.startswith("/"):
                add("data.bind_mount", where, "низкая",
                    f"bind mount каталога хоста: {source}",
                    "именованный том")
            elif source and not source.startswith(".") \
                    and source not in declared:
                add("data.undeclared_volume", where, "средняя",
                    f"том {source} не объявлен в volumes:",
                    "объявить именованный том")
    return out


def main(argv: list[str]) -> int:
    findings: list[Finding] = []
    for path in argv:
        p = Path(path)
        if not p.exists():
            continue
        if p.name.lower().startswith("dockerfile"):
            findings += lint_dockerfile(p.read_text(encoding="utf-8"), p.name)
        elif p.suffix in (".yaml", ".yml"):
            doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
            findings += lint_compose(doc, p.name)

    findings.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.code, f.where))

    by_sev: dict[str, int] = {}
    by_group: dict[str, int] = {}
    for f in findings:
        by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
        by_group[f.group] = by_group.get(f.group, 0) + 1

    # Правила, реализованные линтером — для отчёта о покрытии
    implemented = {
        "build": ["latest", "notag", "split_update", "add", "unpinned",
                  "copy_order", "apt_cache"],
        "run": ["shellform", "fake_alive", "root", "named_user"],
        "data": ["chmod777", "bind_mount", "undeclared_volume"],
        "net": ["host", "links", "public_port"],
        "secret": ["in_image", "rm_after_copy", "in_compose"],
        "compose": ["version", "latest", "container_name",
                    "depends_no_condition"],
        "ops": ["no_healthcheck", "no_limits", "docker_sock"],
        "org": [],
    }

    print(json.dumps({
        "находок": len(findings),
        "по_серьёзности": by_sev,
        "по_группам": {GROUPS[g]: n for g, n in by_group.items()},
        "правил": sum(len(v) for v in implemented.values()),
        "покрытие": {GROUPS[g]: len(v) for g, v in implemented.items()},
        "список": [f.as_dict() for f in findings],
        "не_обнаруживается": [
            {"группа": GROUPS[g], "что": what, "почему": why}
            for g, what, why in NOT_DETECTABLE
        ],
    }, ensure_ascii=False))
    return 1 if by_sev.get("высокая") else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
PY

# ── заведомо плохая конфигурация ────────────────────────────────────
cat > Dockerfile.bad <<'DOCKERFILE'
FROM python:latest

ENV DB_PASSWORD=hunter2

COPY . .
COPY requirements.txt .

RUN apt-get update
RUN apt-get install -y curl gcc
RUN pip install flask requests
RUN chmod -R 777 /app
RUN rm /app/secret.key

CMD python app.py
DOCKERFILE

cat > compose.bad.yaml <<'EOF'
version: "3.8"

services:
  web:
    image: myapp:latest
    container_name: web
    network_mode: host
    user: "0:0"
    ports:
      - "8080:8000"
    environment:
      DB_PASSWORD: hunter2
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/data:/data
      - cachedata:/cache
    depends_on:
      - db

  db:
    image: postgres
    links:
      - web
EOF

# ── чистая конфигурация ─────────────────────────────────────────────
cat > Dockerfile.good <<'DOCKERFILE'
# syntax=docker/dockerfile:1
FROM python:3.13-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91

WORKDIR /app

COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-deps -r requirements.lock

COPY src/ ./src/

USER 10001:10001

ENTRYPOINT ["python", "-m", "app.main"]
DOCKERFILE

cat > compose.good.yaml <<'EOF'
services:
  web:
    image: ghcr.io/org/app:1.4.2
    user: "10001:10001"
    read_only: true
    ports:
      - "127.0.0.1:8080:8000"
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
      interval: 10s
    deploy:
      resources:
        limits: {cpus: "0.5", memory: 256M}
    depends_on:
      db:
        condition: service_healthy
    networks: [backend]

  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
    deploy:
      resources:
        limits: {cpus: "1.0", memory: 1G}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [backend]

volumes:
  pgdata:

networks:
  backend:
EOF

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

python3 lint.py Dockerfile.bad compose.bad.yaml > bad.json; bad_code=$?
python3 lint.py Dockerfile.good compose.good.yaml > good.json; good_code=$?

printf '\n═══ Требования 1 и 7: правила и покрытие групп ═══\n'
python3 - <<'PY'
import json

d = json.load(open("bad.json"))
print(f"    {'группа каталога':<32} правил в линтере")
print("    " + "─" * 52)
for group, n in d["покрытие"].items():
    mark = "" if n else "  ← не проверяется в принципе"
    print(f"    {group:<32} {n:>4}{mark}")
print()
print(f"    правил всего: {d['правил']}")
print()
print("    Группа «Организационные» пуста намеренно: её антипаттерны")
print("    не обнаруживаются ни одним инструментом.")
PY
rules="$(python3 -c "import json;print(json.load(open('bad.json'))['правил'])")"
printf '\n  правил: %s\n' "$rules"
[ "$rules" -ge 15 ] \
    && ok "правил не меньше пятнадцати, разложены по группам каталога" \
    || bad "правил: $rules"

printf '\n═══ Требования 2, 3 и 4: находки на плохой конфигурации ═══\n'
python3 - <<'PY'
import json

d = json.load(open("bad.json"))
print(f"    {'серьёзность':<12} {'код':<30} {'где':<26} что")
print("    " + "─" * 118)
for f in d["список"]:
    print(f"    {f['серьёзность']:<12} {f['код']:<30} {f['где']:<26} {f['что']}")
print()
print(f"    находок: {d['находок']}   по серьёзности: {d['по_серьёзности']}")
print(f"    по группам: {d['по_группам']}")
print()
print("    Каждая находка содержит пять полей, включая способ исправления:")
example = d["список"][0]
for key, value in example.items():
    print(f"      {key}: {value}")
PY
n_bad="$(python3 -c "import json;print(json.load(open('bad.json'))['находок'])")"
high="$(python3 -c "
import json
print(json.load(open('bad.json'))['по_серьёзности'].get('высокая', 0))")"
printf '\n  находок: %s (высокой серьёзности: %s), код возврата: %s\n' \
    "$n_bad" "$high" "$bad_code"
[ "$n_bad" -ge 10 ] && [ "$bad_code" -eq 1 ] \
    && ok "не менее десяти находок; код возврата отражает серьёзность" \
    || bad "находок $n_bad, код $bad_code"

printf '\n═══ Требование 5: чистая конфигурация ═══\n'
python3 - <<'PY'
import json

d = json.load(open("good.json"))
print(f"    находок: {d['находок']}")
print(f"    по серьёзности: {d['по_серьёзности'] or 'нет'}")
if d["список"]:
    print()
    for f in d["список"]:
        print(f"    {f['серьёзность']:<12} {f['код']:<30} {f['что']}")
print()
high = d["по_серьёзности"].get("высокая", 0)
if high == 0:
    print("    Ни одной находки высокой серьёзности.")
    print()
    print("    Это проверка ЛИНТЕРА, а не конфигурации: инструмент,")
    print("    отмечающий всё подряд, ничем не лучше не отмечающего ничего.")
    print("    Без чистого примера находки в плохой конфигурации")
    print("    не значили бы ничего.")
else:
    print(f"    ПРОБЛЕМА: {high} находок высокой серьёзности на чистом примере")
PY
good_high="$(python3 -c "
import json
print(json.load(open('good.json'))['по_серьёзности'].get('высокая', 0))")"
printf '\n  высокой серьёзности на чистой конфигурации: %s, код возврата: %s\n' \
    "$good_high" "$good_code"
[ "$good_high" -eq 0 ] && [ "$good_code" -eq 0 ] \
    && ok "чистая конфигурация проходит — находки различимы" \
    || bad "высоких: $good_high, код: $good_code"

printf '\n═══ Требование 6: что не обнаруживается в принципе ═══\n'
python3 - <<'PY'
import json

d = json.load(open("good.json"))
print(f"    {'группа':<28} {'антипаттерн':<48} почему не находится")
print("    " + "─" * 122)
for item in d["не_обнаруживается"]:
    print(f"    {item['группа']:<28} {item['что']:<48} {item['почему']}")
print()
org = [i for i in d["не_обнаруживается"] if i["группа"] == "Организационные"]
print(f"    всего: {len(d['не_обнаруживается'])}, из них организационных: {len(org)}")
print()
print("    Организационные антипаттерны наносят наибольший ущерб")
print("    и не находятся ни одним инструментом. «Ноль находок»")
print("    не говорит, знает ли кто-нибудь ещё, как собрать этот образ,")
print("    и обновлялся ли базовый образ за последний год.")
PY
n_nd="$(python3 -c "
import json
print(len(json.load(open('good.json'))['не_обнаруживается']))")"
[ "$n_nd" -ge 8 ] \
    && ok "перечислено необнаружимое, с указанием причины" \
    || bad "не обнаруживается: $n_nd"

printf '\n═══ Что НЕ проверялось ═══\n'
python3 - <<'PY'
NOT_RUN = [
    ("сборка образов", "Docker на машине курса не установлен"),
    ("запуск конфигураций", "то же"),
    ("последствия антипаттернов", "описаны по механизмам, а не воспроизведены"),
    ("полнота каталога", "62 записи — не исчерпывающий список"),
]
print(f"    {'проверка':<34} причина")
print("    " + "─" * 82)
for name, why in NOT_RUN:
    print(f"    {name:<34} {why}")
print()
print("    ВЫПОЛНЕНО: разбор двух Dockerfile и двух compose.yaml,")
print("    сопоставление правил с группами каталога, проверка линтера")
print("    на чистой конфигурации.")
PY
ok "разделено выполненное и невыполненное"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
echo "  примечание: Docker не использовался; проверены конфигурации как документы"

cd /tmp && rm -rf /tmp/antilab
exit "$fail"

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

text
═══ Требования 1 и 7: правила и покрытие групп ═══
    группа каталога                  правил в линтере
    ────────────────────────────────────────────────────
    Сборка образов                      7
    Запуск и жизненный цикл             4
    Данные                              3
    Сеть                                3
    Конфигурация и секреты              3
    Compose                             4
    Эксплуатация                        3
    Организационные                     0  ← не проверяется в принципе

    правил всего: 27

    Группа «Организационные» пуста намеренно: её антипаттерны
    не обнаруживаются ни одним инструментом.

  правил: 27
  ✓ правил не меньше пятнадцати, разложены по группам каталога

═══ Требования 2, 3 и 4: находки на плохой конфигурации ═══
    серьёзность  код                            где                        что
    ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────
    высокая      build.latest                   Dockerfile.bad:1           базовый образ по тегу latest
    высокая      compose.latest                 compose.bad.yaml:db        образ по тегу latest или без тега
    высокая      compose.latest                 compose.bad.yaml:web       образ по тегу latest или без тега
    высокая      data.chmod777                  Dockerfile.bad:11          chmod 777
    высокая      net.host                       compose.bad.yaml:web       network_mode: host снимает сетевую изоляцию
    высокая      ops.docker_sock                compose.bad.yaml:web       монтирование сокета равно правам root на хосте
    высокая      run.root                       Dockerfile.bad             USER не задан: запуск от root
    высокая      run.root                       compose.bad.yaml:web       запуск от root
    высокая      run.shellform                  Dockerfile.bad:14          CMD в shell-форме: PID 1 будет оболочкой
    высокая      secret.in_compose              compose.bad.yaml:web       секрет в открытом виде: DB_PASSWORD
    высокая      secret.in_image                Dockerfile.bad:3           секрет в ENV или ARG: остаётся в истории образа
    высокая      secret.rm_after_copy           Dockerfile.bad:12          удаление секрета не убирает его из предыдущего слоя
    средняя      build.apt_cache                Dockerfile.bad             кэш apt не удалён
    ...

    находок: 27   по серьёзности: {'высокая': 12, 'средняя': 12, 'низкая': 3}
    по группам: {'Сборка образов': 5, 'Compose': 5, 'Данные': 3, 'Сеть': 3, 'Эксплуатация': 5, 'Запуск и жизненный цикл': 3, 'Конфигурация и секреты': 3}

  находок: 27 (высокой серьёзности: 12), код возврата: 1
  ✓ не менее десяти находок; код возврата отражает серьёзность

═══ Требование 5: чистая конфигурация ═══
    находок: 0
    по серьёзности: нет

    Ни одной находки высокой серьёзности.

    Это проверка ЛИНТЕРА, а не конфигурации: инструмент,
    отмечающий всё подряд, ничем не лучше не отмечающего ничего.
    Без чистого примера находки в плохой конфигурации
    не значили бы ничего.

  высокой серьёзности на чистой конфигурации: 0, код возврата: 0
  ✓ чистая конфигурация проходит — находки различимы

═══ Требование 6: что не обнаруживается в принципе ═══
    группа                       антипаттерн                                      почему не находится
    ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
    Организационные              Один человек знает, как собирается образ         знание вне репозитория
    Организационные              Базовые образы не обновляются                    видно по датам, а не по файлам
    ...
    Запуск и жизненный цикл      Нет обработки SIGTERM                            находится в коде приложения
    Данные                       Нет процедуры восстановления                     существует вне репозитория

    всего: 11, из них организационных: 6
  ✓ перечислено необнаружимое, с указанием причины

═══ ИТОГ ═══
  все требования выполнены
  примечание: Docker не использовался; проверены конфигурации как документы

Все требования выполнены.

Три решения, определяющие качество.

Каталог отделён от линтера. Список групп лежит в catalog.py и используется тремя местами: правилами, отчётом о покрытии и перечнем необнаружимого. Держи их порознь — и при первом же добавлении правила отчёт о покрытии начнёт врать. Это не украшение: покрытие — то самое число, которому читатель поверит, не проверяя.

Есть конфигурация, проходящая проверку. Без неё двенадцать находок высокой серьёзности ничего не означали бы: линтер, отмечающий всякую конфигурацию, неотличим от сломанного. Тот же принцип применён в уроке 19.1 к systemd-analyze и в уроке 19.2 к разбору compose.yaml.

Группа «Организационные» присутствует в отчёте с нулём правил. Соблазн был не показывать пустую строку — она портит вид покрытия. Но именно эта строка сообщает главное: шесть антипаттернов, наносящих наибольший ущерб, не находятся никаким инструментом. Линтер с нулём находок не отвечает на вопрос, знает ли кто-нибудь ещё, как собрать этот образ.

Чего решение не делает. Ни один образ не собран и не запущен: Docker отсутствует, и последствия антипаттернов описаны по механизмам из соответствующих разделов, а не воспроизведены. Правила распознают типовые написания и обходятся простыми, например pip install в многострочной инструкции с переносами будет разобран построчно. Серьёзность назначена по опыту: «высокая» у secret.in_image и run.root обоснована, у прочих — соглашение. Каталог в шестьдесят две записи не исчерпывающий: он покрывает то, что встречается часто, и не заменяет чтения разделов, на которые ссылается.

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

bash
python3 lint.py Dockerfile compose.yaml; echo "код: $?"

Проверьте линтер на своей худшей конфигурации и на лучшей. Если обе дают одинаковый результат — проверять нечем.

Типичные ошибки

ОшибкаПричинаИсправление
Читать каталог как список запретовТак выглядитВторое поле объясняет, какую проблему решали
Исправлять всё подрядНаходок многоНачать с высокой серьёзности
Доверять нулю находокЛинтер прошёлОрганизационные антипаттерны не проверяются
Не проверять линтер на чистом примереНаходки же естьИнструмент, отмечающий всё, бесполезен
Копировать исправления без пониманияКаталог даёт готовые ответыКаждая строка ведёт в разбор
Считать каталог исчерпывающимШестьдесят две записиПокрывает частое, не всё

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

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

  1. Почему в каждой записи есть поле «почему выглядит разумным»?
  2. Какая группа антипаттернов не обнаруживается инструментами?
  3. Почему RUN rm secret не удаляет секрет?
  4. Почему USER app хуже, чем USER 10001?
  5. Чем sleep infinity опаснее падающего container'а?

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

  1. Как убедиться, что ваш линтер не отмечает всё подряд?
  2. С каких находок начинать исправление?
  3. Какие антипаттерны потребуют изменений в коде, а не в конфигурации?

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

  1. Линтер даёт ноль находок. Что это говорит о конфигурации, а что — нет?
  2. docker compose up --scale web=3 не работает. Какой антипаттерн проверить первым?

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

  1. Антипаттерн живёт потому, что решает настоящую проблему негодным способом.
  2. Поле «почему выглядит разумным» важнее поля «как правильно».
  3. Каталог разбит на восемь групп; шестьдесят две записи покрывают частое, но не всё.
  4. latest, COPY . . раньше зависимостей и раздельный apt-get update — три самых частых при сборке.
  5. Shell-форма CMD делает PID 1 оболочкой, и SIGTERM не доходит до приложения.
  6. sleep infinity и restart: always скрывают отказ, а не устраняют его.
  7. Данные в записываемом слое исчезают; chmod 777 решает проблему прав ценой защиты.
  8. Секрет, удалённый следующей инструкцией, остаётся в слое.
  9. container_name делает масштабирование невозможным.
  10. Линтер обнаруживает механически проверяемое; организационные антипаттерны — ни один.
  11. Проверка линтера на чистой конфигурации обязательна: иначе находки ничего не значат.
  12. Ноль находок не отвечает на вопрос, знает ли кто-нибудь ещё, как собрать образ.

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

ИсточникСсылкаЧто подтверждает
Docker: рекомендации по Dockerfilehttps://docs.docker.com/build/building/best-practices/Порядок инструкций, кэш, размер
Docker: сборка с секретамиhttps://docs.docker.com/build/building/secrets/RUN --mount=type=secret
Docker: multi-stagehttps://docs.docker.com/build/building/multi-stage/Разделение сборки и запуска
Compose Specificationhttps://github.com/compose-spec/compose-spec/blob/main/spec.mdУстаревшие поля и текущий синтаксис
Docker: логиhttps://docs.docker.com/config/containers/logging/configure/Ротация и драйверы
Docker: ограничения ресурсовhttps://docs.docker.com/config/containers/resource_constraints/mem_limit, cpus
hadolinthttps://github.com/hadolint/hadolintГотовый линтер Dockerfile

Навигация

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

Markdown на GitHub ↗