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 в эксплуатации | Кончилось место | Удаляет образы, нужные для отката | Целевая очистка с пониманием, что удаляется |
| Нет healthcheck | Container же запущен | «Запущен» не значит «работает» | 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 в образе | Один ли секрет на все сервисы |
| Compose | latest, container_name, version | Разделены ли окружения по смыслу |
| Эксплуатация | Лимиты, healthcheck, ротация логов | Исправления через exec |
| Организационные | Ничего | Всё |
Последняя строка — главная. Организационные антипаттерны наносят наибольший ущерб и не обнаруживаются ни одним инструментом. Линтер, показывающий ноль находок, ничего не говорит о том, знает ли кто-нибудь ещё, как собрать этот образ.
Команды и примеры
Линтер по каталогу
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"
Ожидаемый вывод:
═══ плохая конфигурация ═══
находок: 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
Проверка на чистой конфигурации
Инструмент, находящий проблемы во всём, бесполезен так же, как не находящий ничего.
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
Ожидаемый вывод:
═══ чистая конфигурация ═══
находок: 0 нет
Ни одной находки высокой серьёзности — значит, находки
в плохой конфигурации что-то значат.
код возврата: 0
═══ чего линтер не обнаруживает ═══
группа почему
────────────────────────────────────────────────────────────────────────────────────────────────────────────
Организационные все шесть: знание образа, обновление базовых, чтение отчётов, копирование из статьи
Обработка SIGTERM находится в коде приложения, а не в конфигурации
Процедура восстановления данных существует вне репозитория
Нужен ли порт снаружи зависит от назначения сервиса
Исправления через docker exec происходят в работающей системе
Разделены ли окружения по смыслу требует понимания задачи
не обнаруживается: 6 групп
Проверка на чистой конфигурации нашла ошибку в самом линтере при написании этого урока. Первая редакция правила secret.in_compose срабатывала на строке
DB_PASSWORD_FILE: /run/secrets/db_password
то есть отмечала как утечку правильный приём — передачу пути к файлу секрета вместо самого секрета (урок 12.6). Исправление — исключить ключи с суффиксом _FILE.
Существенно, как ошибка обнаружилась: на плохой конфигурации линтер вёл себя безупречно и давал двадцать четыре осмысленные находки. Правило проявило себя только там, где находок быть не должно.
Отсюда общий вывод, применимый ко всякой проверке: инструмент проверяют на входе, который обязан пройти, а не только на том, который обязан не пройти. Ложное срабатывание дороже пропуска: после нескольких таких срабатываний отчёты перестают читать целиком.
Практическое упражнение
Задание. Постройте линтер по каталогу и проверьте его на двух конфигурациях.
Требования:
- Проверять
Dockerfileиcompose.yaml; не менее пятнадцати правил. - Каждая находка содержит код, место, серьёзность, описание и способ исправления.
- Различать серьёзность и отражать её в коде возврата.
- Дать не менее десяти находок на заведомо плохой конфигурации.
- Дать ноль находок высокой серьёзности на чистой конфигурации.
- Перечислить антипаттерны, которые линтер не обнаруживает в принципе.
- Соотнести правила с разделами каталога и показать покрытие по группам.
Подсказки
Подсказка 1
Правило «COPY . . раньше зависимостей» требует запоминать номера строк обеих инструкций и сравнивать их после обхода.
Подсказка 2
USER app и USER 10001 — разные находки разной серьёзности, а не одна.
Подсказка 3
Покрытие по группам считается сопоставлением префикса кода правила (build., run., data., net., secret., compose., ops.) с разделами каталога.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требования 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 обоснована, у прочих — соглашение. Каталог в шестьдесят две записи не исчерпывающий: он покрывает то, что встречается часто, и не заменяет чтения разделов, на которые ссылается.
Проверка результата
python3 lint.py Dockerfile compose.yaml; echo "код: $?"
Проверьте линтер на своей худшей конфигурации и на лучшей. Если обе дают одинаковый результат — проверять нечем.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Читать каталог как список запретов | Так выглядит | Второе поле объясняет, какую проблему решали |
| Исправлять всё подряд | Находок много | Начать с высокой серьёзности |
| Доверять нулю находок | Линтер прошёл | Организационные антипаттерны не проверяются |
| Не проверять линтер на чистом примере | Находки же есть | Инструмент, отмечающий всё, бесполезен |
| Копировать исправления без понимания | Каталог даёт готовые ответы | Каждая строка ведёт в разбор |
| Считать каталог исчерпывающим | Шестьдесят две записи | Покрывает частое, не всё |
Контрольные вопросы
На понимание:
- Почему в каждой записи есть поле «почему выглядит разумным»?
- Какая группа антипаттернов не обнаруживается инструментами?
- Почему
RUN rm secretне удаляет секрет? - Почему
USER appхуже, чемUSER 10001? - Чем
sleep infinityопаснее падающего container'а?
На применение:
- Как убедиться, что ваш линтер не отмечает всё подряд?
- С каких находок начинать исправление?
- Какие антипаттерны потребуют изменений в коде, а не в конфигурации?
На диагностику:
- Линтер даёт ноль находок. Что это говорит о конфигурации, а что — нет?
docker compose up --scale web=3не работает. Какой антипаттерн проверить первым?
Краткое резюме
- Антипаттерн живёт потому, что решает настоящую проблему негодным способом.
- Поле «почему выглядит разумным» важнее поля «как правильно».
- Каталог разбит на восемь групп; шестьдесят две записи покрывают частое, но не всё.
latest,COPY . .раньше зависимостей и раздельныйapt-get update— три самых частых при сборке.- Shell-форма
CMDделает PID 1 оболочкой, иSIGTERMне доходит до приложения. sleep infinityиrestart: alwaysскрывают отказ, а не устраняют его.- Данные в записываемом слое исчезают;
chmod 777решает проблему прав ценой защиты. - Секрет, удалённый следующей инструкцией, остаётся в слое.
container_nameделает масштабирование невозможным.- Линтер обнаруживает механически проверяемое; организационные антипаттерны — ни один.
- Проверка линтера на чистой конфигурации обязательна: иначе находки ничего не значат.
- Ноль находок не отвечает на вопрос, знает ли кто-нибудь ещё, как собрать образ.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Docker: рекомендации по Dockerfile | https://docs.docker.com/build/building/best-practices/ | Порядок инструкций, кэш, размер |
| Docker: сборка с секретами | https://docs.docker.com/build/building/secrets/ | RUN --mount=type=secret |
| Docker: multi-stage | https://docs.docker.com/build/building/multi-stage/ | Разделение сборки и запуска |
| Compose Specification | https://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 |
| hadolint | https://github.com/hadolint/hadolint | Готовый линтер Dockerfile |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Полный каталог как справочник
Главное оглавление