6.8. CLI-приложение
Цели
После этого материала вы сможете:
- контейнеризировать Python CLI так, чтобы аргументы доходили до приложения;
- выбрать между
ENTRYPOINTиCMDдля CLI и обосновать выбор; - реализовать корректные коды возврата и объяснить их значение;
- разделять результат и диагностику между stdout и stderr;
- обрабатывать данные со стандартного ввода;
- передавать конфигурацию через файл и переменные окружения;
- сделать образ удобным для использования в конвейерах.
Предварительные знания
- 5.4. CMD и ENTRYPOINT — таблица результирующих команд;
- 4.2. Режимы запуска —
-i,-t, потоки; - 4.6. Restart policies и exit codes — коды возврата;
- 6.7. Non-root user.
Рабочий пример этого урока — resources/examples/python-cli/.
Ключевые термины
| Термин | Объяснение |
|---|---|
exit code | Код возврата процесса, 0–255 |
stdin | Стандартный ввод, дескриптор 0 |
конвейер | Соединение команд через канал: cmd1 | cmd2 |
argparse | Стандартный модуль разбора аргументов командной строки |
POSIX-совместимость | Следование соглашениям Unix-утилит |
Теория
Чем CLI отличается от сервиса
Сервис работает постоянно и отвечает на запросы. CLI выполняет одну задачу и завершается. Это меняет требования.
| Сервис | CLI | |
|---|---|---|
| Время жизни | Долгое | Одна операция |
| Основной вывод | Логи | Результат работы |
| Коды возврата | Обычно 0 | Часть интерфейса |
| Стандартный ввод | Не используется | Часто источник данных |
| Аргументы | Задаются один раз | Меняются при каждом запуске |
HEALTHCHECK | Нужен | Бессмыслен |
| Restart policy | unless-stopped | no |
Последние две строки — частая ошибка: применение к CLI шаблона сервиса даёт бесконечный перезапуск успешно выполнившейся задачи (урок 4.6).
ENTRYPOINT для CLI
Для CLI правильная конструкция — ENTRYPOINT с программой и CMD с аргументами по умолчанию:
ENTRYPOINT ["wordfreq"]
CMD ["--help"]
Это даёт естественный интерфейс: образ ведёт себя как сама утилита.
docker run --rm wordfreq # выполнится: wordfreq --help
docker run --rm wordfreq --top 5 # выполнится: wordfreq --top 5
Альтернатива — только CMD — требует повторять имя программы:
docker run --rm wordfreq wordfreq --top 5
Цена ENTRYPOINT: чтобы запустить в образе что-то другое, нужен --entrypoint. Для образа-инструмента это приемлемо.
Коды возврата как интерфейс
Для CLI код возврата — не формальность, а способ сообщить результат вызывающей стороне. Соглашения Unix:
| Код | Значение |
|---|---|
0 | Успех |
1 | Общая ошибка выполнения |
2 | Ошибка использования: неверные аргументы |
3–125 | Специфичные для приложения |
126, 127 | Занято Docker и оболочкой (урок 4.6) |
128+N | Завершение сигналом |
Диапазон 3–125 свободен для собственных кодов. Их стоит документировать: 4 — «файл не найден», 5 — «недостаточно данных».
Модуль argparse возвращает 2 при неверных аргументах автоматически — это соответствует соглашению.
Разделение stdout и stderr
Правило Unix: stdout — результат, stderr — всё остальное.
результат работы ──► stdout (можно передать дальше по конвейеру)
диагностика, прогресс ──► stderr (виден человеку, не мешает конвейеру)
предупреждения, ошибки ──► stderr
Это позволяет использовать утилиту в конвейере:
docker run --rm -i wordfreq --top 3 < text.txt | head -1
Если бы диагностика шла в stdout, она попала бы в head и испортила результат.
Проверка: команда 2>/dev/null должна оставить только полезные данные.
Стандартный ввод
CLI, читающий stdin, требует флага -i при запуске (урок 4.2):
echo "данные" | docker run --rm -i myimage
Без -i стандартный ввод закрыт, и приложение прочитает конец файла.
Флаг -t при этом не нужен и вреден: он сливает stdout и stderr, ломая разделение потоков, и добавляет \r в вывод.
Хорошая практика — поддерживать оба режима: файл как аргумент и stdin при его отсутствии.
Конфигурация
Для CLI приоритет источников конфигурации:
значения по умолчанию в коде
↓ перекрываются
файл конфигурации
↓ перекрываются
переменные окружения
↓ перекрываются
аргументы командной строки
Аргументы имеют высший приоритет — это соответствует ожиданиям пользователя: явно указанное побеждает.
Особенности запуска в container
| Особенность | Следствие |
|---|---|
| Файлы host недоступны | Нужен bind mount для входных данных |
| Рабочий каталог — из образа | Относительные пути разрешаются иначе |
| Вывод в файл идёт в writable layer | Исчезает после --rm; нужен volume |
| UID процесса влияет на созданные файлы | Файлы результата принадлежат этому UID |
Последняя строка важна: если CLI пишет результат в примонтированный каталог, владелец файла определяется UID процесса в container (урок 6.7).
Внутренний механизм
Как аргументы доходят до приложения
При docker run image arg1 arg2 Docker формирует итоговую команду (урок 5.4):
Entrypoint + (аргументы docker run вместо Cmd)
Поэтому:
ENTRYPOINT ["wordfreq"] + arg1 arg2 → wordfreq arg1 arg2
Если ENTRYPOINT записан в shell form, аргументы теряются — это разбиралось в уроке 5.4 и проверяется в практической части.
Почему argparse возвращает 2
Модуль следует соглашению Unix, где код 2 означает ошибку использования. При неверных аргументах он печатает сообщение в stderr, справку и вызывает sys.exit(2).
Это поведение можно переопределить, но обычно не нужно — оно правильное.
Команды и примеры
Рабочий пример
Полный код — в resources/examples/python-cli/. Разберём его ключевые решения.
cd resources/examples/python-cli
ls -R --ignore=__pycache__ | head -20
.:
Dockerfile pyproject.toml README.md src tests wordfreq.example.toml
./src:
wordfreq
./src/wordfreq:
__init__.py __main__.py cli.py config.py
./tests:
test_cli.py
Сборка:
docker build -q -t wordfreq . > /dev/null && echo "образ собран"
Аргументы доходят до приложения
echo "=== без аргументов: сработает CMD ==="
docker run --rm wordfreq 2>&1 | head -3
echo
echo "=== с аргументами: CMD заменяется ==="
echo "кот пёс кот кот пёс мышь" | docker run --rm -i wordfreq --top 2
=== без аргументов: сработает CMD ===
usage: wordfreq [-h] [-c CONFIG] [-n TOP] [--min-length MIN_LENGTH] [--version] [path]
Подсчёт частоты слов в тексте.
=== с аргументами: CMD заменяется ===
кот 3
пёс 2
Обратите внимание на -i: без него стандартный ввод закрыт.
Что было бы при shell form
mkdir -p /tmp/cli-demo && cd /tmp/cli-demo
cat > Dockerfile.shell <<'EOF'
FROM python:3.13-slim
RUN pip install --no-cache-dir --quiet --root-user-action=ignore /dev/null 2>/dev/null || true
COPY probe.py /probe.py
ENTRYPOINT python /probe.py
EOF
cat > Dockerfile.exec <<'EOF'
FROM python:3.13-slim
COPY probe.py /probe.py
ENTRYPOINT ["python", "/probe.py"]
EOF
cat > probe.py <<'PY'
import sys
print(f"полученные аргументы: {sys.argv[1:]}")
PY
for v in shell exec; do
docker build -q -f "Dockerfile.$v" -t "cli:$v" . > /dev/null
printf '%-6s ' "$v"
docker run --rm "cli:$v" --top 5 --verbose
done
shell полученные аргументы: []
exec полученные аргументы: ['--top', '5', '--verbose']
При shell form аргументы потеряны. Причина разбиралась в уроке 5.4: /bin/sh -c "команда" принимает только первый аргумент как команду.
Проверка чужого образа:
for v in shell exec; do
printf '%-6s Entrypoint = %s\n' "$v" \
"$(docker image inspect "cli:$v" --format '{{json .Config.Entrypoint}}')"
done
shell Entrypoint = ["/bin/sh","-c","python /probe.py"]
exec Entrypoint = ["python","/probe.py"]
Наличие /bin/sh","-c" — признак проблемы.
Коды возврата
cd resources/examples/python-cli
run_and_report() {
local label="$1"; shift
"$@" > /dev/null 2>&1
printf ' %-32s код: %s\n' "$label" "$?"
}
echo "=== коды возврата ==="
run_and_report "успех" sh -c 'echo "текст" | docker run --rm -i wordfreq'
run_and_report "файл не найден" docker run --rm wordfreq /nonexistent.txt
run_and_report "неверный аргумент" docker run --rm wordfreq --no-such-option
run_and_report "недопустимое значение" sh -c 'echo x | docker run --rm -i wordfreq --top 0'
=== коды возврата ===
успех код: 0
файл не найден код: 1
неверный аргумент код: 2
недопустимое значение код: 1
Код 2 для неверного аргумента выдал argparse автоматически — это соответствует соглашению Unix.
Практическое применение кодов в скрипте:
process_or_fail() {
local input="$1"
if echo "$input" | docker run --rm -i wordfreq --top 3 2>/dev/null; then
echo " обработано успешно"
else
case $? in
1) echo " ошибка выполнения: проверьте входные данные" ;;
2) echo " ошибка использования: проверьте аргументы" ;;
*) echo " неизвестная ошибка" ;;
esac
fi
}
process_or_fail "слово слово другое"
слово 2
другое 1
обработано успешно
Разделение stdout и stderr
echo "=== полный вывод ==="
echo "раз два раз три" | docker run --rm -i wordfreq --top 2
echo
echo "=== только stdout (результат) ==="
echo "раз два раз три" | docker run --rm -i wordfreq --top 2 2>/dev/null
echo
echo "=== только stderr (диагностика) ==="
echo "раз два раз три" | docker run --rm -i wordfreq --top 2 2>&1 1>/dev/null
=== полный вывод ===
всего уникальных слов: 3
раз 2
два 1
=== только stdout (результат) ===
раз 2
два 1
=== только stderr (диагностика) ===
всего уникальных слов: 3
Разделение работает — результат можно передать дальше по конвейеру, не получив в него диагностику:
echo "яблоко груша яблоко слива яблоко груша" \
| docker run --rm -i wordfreq --top 5 2>/dev/null \
| head -1
яблоко 3
Почему -t ломает конвейер
echo "=== без -t ==="
echo "а б а" | docker run --rm -i wordfreq --top 1 2>/dev/null | od -c | head -1
echo "=== с -t ==="
echo "а б а" | docker run --rm -i -t wordfreq --top 1 2>/dev/null | od -c | head -1
=== без -t ===
0000000 а 342 200 242 2 \n
=== с -t ===
0000000 в с е г о у н и к а л ь н ы х
С -t в stdout попала диагностика — потоки слились (урок 4.2). Конвейер получил не то, что ожидалось.
Правило: -i для данных, -t только для интерактивной работы человека.
Работа с файлами
mkdir -p /tmp/cli-data && cd /tmp/cli-data
cat > input.txt <<'EOF'
Кот сидел на окне. Кот смотрел на птиц.
Птицы летали высоко. Кот мечтал о птицах.
EOF
echo "=== файл через bind mount ==="
docker run --rm -v "$PWD/input.txt:/data/input.txt:ro" \
wordfreq /data/input.txt --top 3 --min-length 3
всего уникальных слов: 12
кот 3
птиц 1
птицы 1
Обратите внимание на :ro — входной файл монтируется только для чтения. Это правильно: CLI не должен его изменять.
Запись результата в примонтированный каталог:
mkdir -p output
docker run --rm \
-v "$PWD/input.txt:/data/input.txt:ro" \
-v "$PWD/output:/out" \
--entrypoint sh wordfreq \
-c 'wordfreq /data/input.txt --top 3 > /out/result.txt 2>/dev/null'
ls -ln output/
cat output/result.txt
-rw-r--r-- 1 10001 10001 34 Jul 30 14:12 result.txt
кот 3
птиц 1
птицы 1
Файл принадлежит UID 10001 — тому пользователю, от которого работает приложение (урок 6.7). Если это не ваш UID на host, файл придётся удалять с sudo.
Обходной путь — задать UID при запуске:
rm -rf output && mkdir -p output
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD/input.txt:/data/input.txt:ro" \
-v "$PWD/output:/out" \
--entrypoint sh wordfreq \
-c 'wordfreq /data/input.txt --top 2 > /out/result.txt 2>/dev/null'
ls -ln output/
echo "владелец совпадает с текущим пользователем: $(id -u):$(id -g)"
-rw-r--r-- 1 1000 1000 22 Jul 30 14:14 result.txt
владелец совпадает с текущим пользователем: 1000:1000
Флаг --user переопределяет USER из образа. Приём стандартный для CLI-образов, создающих файлы.
Конфигурация
cd resources/examples/python-cli
cat wordfreq.example.toml
[wordfreq]
top = 5
min_length = 3
Три источника с возрастающим приоритетом:
TEXT="один два три четыре пять шесть семь восемь"
echo "=== 1. значения по умолчанию (top=10) ==="
echo "$TEXT" | docker run --rm -i wordfreq 2>/dev/null | wc -l
echo "=== 2. файл конфигурации (top=5) ==="
echo "$TEXT" | docker run --rm -i \
-v "$PWD/wordfreq.example.toml:/etc/wordfreq.toml:ro" \
wordfreq --config /etc/wordfreq.toml 2>/dev/null | wc -l
echo "=== 3. переменная окружения (top=3) ==="
echo "$TEXT" | docker run --rm -i \
-v "$PWD/wordfreq.example.toml:/etc/wordfreq.toml:ro" \
-e WORDFREQ_TOP=3 \
wordfreq --config /etc/wordfreq.toml 2>/dev/null | wc -l
echo "=== 4. аргумент командной строки (top=2) ==="
echo "$TEXT" | docker run --rm -i \
-v "$PWD/wordfreq.example.toml:/etc/wordfreq.toml:ro" \
-e WORDFREQ_TOP=3 \
wordfreq --config /etc/wordfreq.toml --top 2 2>/dev/null | wc -l
=== 1. значения по умолчанию (top=10) ===
8
=== 2. файл конфигурации (top=5) ===
5
=== 3. переменная окружения (top=3) ===
3
=== 4. аргумент командной строки (top=2) ===
2
Каждый следующий источник перекрывает предыдущий. В первом случае вывелось 8 строк, а не 10, потому что уникальных слов всего восемь.
Удобство использования
Длинная команда docker run неудобна. Обёртка решает это:
mkdir -p /tmp/cli-wrapper && cd /tmp/cli-wrapper
cat > wordfreq <<'SH'
#!/usr/bin/env bash
# Обёртка: делает образ похожим на обычную утилиту.
set -euo pipefail
IMAGE="${WORDFREQ_IMAGE:-wordfreq}"
args=(--rm)
# -i только если stdin не терминал (то есть данные идут из конвейера или файла)
[ -t 0 ] || args+=(-i)
# UID текущего пользователя: созданные файлы будут принадлежать ему
args+=(--user "$(id -u):$(id -g)")
# Текущий каталог доступен внутри как /work
args+=(-v "$PWD:/work" -w /work)
exec docker run "${args[@]}" "$IMAGE" "$@"
SH
chmod +x wordfreq
cp /tmp/cli-data/input.txt .
echo "=== использование как обычной утилиты ==="
./wordfreq input.txt --top 3
echo
echo "=== в конвейере ==="
echo "альфа бета альфа гамма" | ./wordfreq --top 2 2>/dev/null
=== использование как обычной утилиты ===
всего уникальных слов: 12
кот 3
птиц 1
птицы 1
=== в конвейере ===
альфа 2
бета 1
Обёртка решает четыре задачи: подставляет -i только когда нужно, задаёт UID, монтирует текущий каталог и делает вызов похожим на обычную команду.
Проверка [ -t 0 ] важна: добавлять -i всегда безопасно, но лишний -t испортил бы конвейер.
CLI не сервис
echo "=== ошибка: restart policy для CLI ==="
docker run -d --name cli-loop --restart=always \
--entrypoint sh wordfreq -c 'echo "задача выполнена"; exit 0' > /dev/null
sleep 8
printf ' перезапусков: %s\n' "$(docker inspect cli-loop --format '{{.RestartCount}}')"
printf ' выполнений в логах: %s\n' "$(docker logs cli-loop 2>&1 | grep -c 'задача выполнена')"
docker rm -f cli-loop > /dev/null
=== ошибка: restart policy для CLI ===
перезапусков: 4
выполнений в логах: 5
Успешно завершившаяся задача перезапускалась четыре раза. Для неидемпотентной операции — например, отправки уведомлений — это дало бы пять отправок вместо одной.
Правильно для CLI — политика по умолчанию no и флаг --rm.
Уборка
cd /tmp
docker rmi -f cli:shell cli:exec wordfreq 2>/dev/null || true
rm -rf /tmp/cli-demo /tmp/cli-data /tmp/cli-wrapper
Практическое упражнение
Задание. Контейнеризируйте CLI-утилиту, удовлетворяющую семи требованиям.
Утилита csvstat читает CSV и выводит статистику по числовой колонке.
Требования:
- Аргументы
docker runдоходят до приложения. - Читает файл-аргумент или стандартный ввод при его отсутствии.
- Результат в stdout, диагностика в stderr.
- Коды возврата:
0успех,1ошибка данных,2неверные аргументы,3колонка не найдена. - Работает от непривилегированного пользователя.
- Файлы, созданные в примонтированном каталоге, принадлежат вызывающему пользователю.
- Тесты запускаются как стадия сборки.
Напишите также скрипт-обёртку, делающий вызов похожим на обычную утилиту.
Подсказки
Подсказка 1
Требование 4 с кодом 3 требует явного sys.exit(3); argparse даст 2 автоматически.
Подсказка 2
Требование 6 решается не в образе, а флагом --user при запуске — обёртка подставляет его.
Подсказка 3
Для чтения stdin используйте sys.stdin при отсутствии аргумента пути.
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p /tmp/csvstat/src/csvstat /tmp/csvstat/tests && cd /tmp/csvstat
cat > src/csvstat/__init__.py <<'PY'
"""csvstat — статистика по числовой колонке CSV."""
__version__ = "1.0.0"
PY
cat > src/csvstat/cli.py <<'PY'
"""CLI: статистика по числовой колонке CSV.
Коды возврата:
0 — успех
1 — ошибка данных (файл не найден, некорректный CSV)
2 — ошибка использования (выставляет argparse)
3 — указанная колонка не найдена
"""
from __future__ import annotations
import argparse
import csv
import statistics
import sys
from pathlib import Path
from typing import TextIO
from . import __version__
EXIT_OK = 0
EXIT_DATA_ERROR = 1
EXIT_COLUMN_NOT_FOUND = 3
def compute(rows: list[dict[str, str]], column: str) -> dict[str, float]:
"""Считает статистику по колонке.
Raises:
KeyError: колонка отсутствует.
ValueError: в колонке нет числовых значений.
"""
if not rows:
raise ValueError("нет строк данных")
if column not in rows[0]:
raise KeyError(column)
values: list[float] = []
skipped = 0
for row in rows:
raw = (row.get(column) or "").strip()
if not raw:
skipped += 1
continue
try:
values.append(float(raw))
except ValueError:
skipped += 1
if not values:
raise ValueError(f"в колонке {column!r} нет числовых значений")
return {
"count": len(values),
"skipped": skipped,
"min": min(values),
"max": max(values),
"mean": statistics.fmean(values),
"median": statistics.median(values),
}
def format_stats(stats: dict[str, float]) -> str:
order = ("count", "min", "max", "mean", "median")
width = max(len(k) for k in order)
return "\n".join(
f"{k:<{width}} {stats[k]:g}" if k == "count" else f"{k:<{width}} {stats[k]:.4g}"
for k in order
)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="csvstat",
description="Статистика по числовой колонке CSV.",
)
p.add_argument("path", nargs="?", type=Path,
help="файл CSV; без него читается стандартный ввод")
p.add_argument("-c", "--column", required=True, help="имя колонки")
p.add_argument("-d", "--delimiter", default=",", help="разделитель (по умолчанию запятая)")
p.add_argument("--version", action="version", version=f"csvstat {__version__}")
return p
def read_rows(stream: TextIO, delimiter: str) -> list[dict[str, str]]:
return list(csv.DictReader(stream, delimiter=delimiter))
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.path is not None:
if not args.path.is_file():
print(f"ошибка: файл не найден: {args.path}", file=sys.stderr)
return EXIT_DATA_ERROR
with args.path.open(encoding="utf-8", newline="") as fh:
rows = read_rows(fh, args.delimiter)
else:
rows = read_rows(sys.stdin, args.delimiter)
except (OSError, csv.Error) as exc:
print(f"ошибка чтения: {exc}", file=sys.stderr)
return EXIT_DATA_ERROR
try:
stats = compute(rows, args.column)
except KeyError:
available = ", ".join(rows[0].keys()) if rows else "(нет данных)"
print(f"ошибка: колонка {args.column!r} не найдена", file=sys.stderr)
print(f"доступные колонки: {available}", file=sys.stderr)
return EXIT_COLUMN_NOT_FOUND
except ValueError as exc:
print(f"ошибка: {exc}", file=sys.stderr)
return EXIT_DATA_ERROR
# результат — в stdout
print(format_stats(stats))
# диагностика — в stderr
if stats["skipped"]:
print(f"пропущено нечисловых значений: {stats['skipped']:g}", file=sys.stderr)
return EXIT_OK
PY
cat > src/csvstat/__main__.py <<'PY'
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
PY
cat > tests/test_cli.py <<'PY'
import io
import pytest
from csvstat.cli import compute, main
ROWS = [
{"name": "a", "value": "10"},
{"name": "b", "value": "20"},
{"name": "c", "value": "30"},
]
def test_compute_basic():
s = compute(ROWS, "value")
assert s["count"] == 3
assert s["min"] == 10
assert s["max"] == 30
assert s["mean"] == 20
def test_compute_skips_non_numeric():
rows = ROWS + [{"name": "d", "value": "нечисло"}, {"name": "e", "value": ""}]
s = compute(rows, "value")
assert s["count"] == 3
assert s["skipped"] == 2
def test_compute_missing_column_raises():
with pytest.raises(KeyError):
compute(ROWS, "нет-такой")
def test_compute_no_numeric_raises():
with pytest.raises(ValueError, match="нет числовых"):
compute([{"v": "x"}, {"v": "y"}], "v")
def test_main_reads_stdin(monkeypatch, capsys):
monkeypatch.setattr("sys.stdin", io.StringIO("name,value\na,1\nb,3\n"))
assert main(["--column", "value"]) == 0
assert "mean" in capsys.readouterr().out
def test_main_missing_file_returns_1(capsys):
assert main(["/nonexistent.csv", "--column", "v"]) == 1
assert "не найден" in capsys.readouterr().err
def test_main_missing_column_returns_3(monkeypatch, capsys):
monkeypatch.setattr("sys.stdin", io.StringIO("name,value\na,1\n"))
assert main(["--column", "нет-такой"]) == 3
err = capsys.readouterr().err
assert "не найдена" in err
assert "доступные колонки" in err
def test_main_no_numeric_returns_1(monkeypatch, capsys):
monkeypatch.setattr("sys.stdin", io.StringIO("v\nx\ny\n"))
assert main(["--column", "v"]) == 1
def test_parser_requires_column():
with pytest.raises(SystemExit) as exc:
main([])
assert exc.value.code == 2
def test_result_to_stdout_diagnostics_to_stderr(monkeypatch, capsys):
monkeypatch.setattr("sys.stdin", io.StringIO("v\n1\nx\n2\n"))
assert main(["--column", "v"]) == 0
out, err = capsys.readouterr()
assert "mean" in out
assert "mean" not in err
assert "пропущено" in err
PY
cat > pyproject.toml <<'EOF'
[project]
name = "csvstat"
version = "1.0.0"
description = "Статистика по числовой колонке CSV"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
csvstat = "csvstat.cli:main"
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
EOF
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
README.md
csvstat
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
*.csv
EOF
# tests не исключён: его копирует стадия test, а .dockerignore
# действует на всю сборку сразу (урок 5.1)
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /work
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN python -m venv /opt/venv
COPY pyproject.toml .
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/pip \
pip install .
# Требование 7: тесты как стадия сборки
FROM builder AS test
RUN --mount=type=cache,target=/root/.cache/pip pip install pytest==9.1.1
COPY tests/ ./tests/
RUN pytest -q tests/
FROM base AS runtime
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
USER 10001:10001
# Требование 1: exec form — аргументы доходят до приложения
ENTRYPOINT ["csvstat"]
CMD ["--help"]
EOF
# ── Проверка требований ──
docker build -q -t csvstat . > /dev/null
echo "образ собран"
cat > sample.csv <<'EOF'
name,value,note
alpha,10,ok
beta,20,ok
gamma,30,ok
delta,,пусто
epsilon,нечисло,плохо
EOF
echo
echo "═══ Требование 1: аргументы доходят ═══"
docker run --rm -v "$PWD/sample.csv:/work/sample.csv:ro" csvstat sample.csv --column value
echo
echo "═══ Требование 2: чтение stdin ═══"
docker run --rm -i csvstat --column value < sample.csv 2>/dev/null
echo
echo "═══ Требование 3: разделение потоков ═══"
echo -n " только stdout: "; docker run --rm -i csvstat --column value < sample.csv 2>/dev/null | tr '\n' ' '
echo
echo -n " только stderr: "; docker run --rm -i csvstat --column value < sample.csv 2>&1 1>/dev/null
echo
echo "═══ Требование 4: коды возврата ═══"
docker run --rm -i csvstat --column value < sample.csv > /dev/null 2>&1; echo " успех: $?"
docker run --rm csvstat /nonexistent.csv --column v > /dev/null 2>&1; echo " файл не найден: $?"
docker run --rm csvstat --no-such-flag > /dev/null 2>&1; echo " неверный аргумент: $?"
docker run --rm -i csvstat --column нет-такой < sample.csv > /dev/null 2>&1; echo " колонки нет: $?"
echo
echo "═══ Требование 5: непривилегированный пользователь ═══"
printf ' UID: %s\n' "$(docker run --rm --entrypoint id csvstat -u)"
echo
echo "═══ Требование 6: владелец созданных файлов ═══"
mkdir -p out
docker run --rm --user "$(id -u):$(id -g)" \
-v "$PWD:/work" csvstat sample.csv --column value 2>/dev/null > out/result.txt
printf ' владелец файла: %s (текущий пользователь: %s:%s)\n' \
"$(stat -c '%u:%g' out/result.txt)" "$(id -u)" "$(id -g)"
echo
echo "═══ Требование 7: тесты в сборке ═══"
docker build -q --target test -t csvstat:test . > /dev/null && echo " тесты прошли"
# обёртка
cat > csvstat <<'SH'
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${CSVSTAT_IMAGE:-csvstat}"
args=(--rm --user "$(id -u):$(id -g)" -v "$PWD:/work" -w /work)
[ -t 0 ] || args+=(-i)
exec docker run "${args[@]}" "$IMAGE" "$@"
SH
chmod +x csvstat
echo
echo "═══ Обёртка ═══"
./csvstat sample.csv --column value 2>/dev/null
echo " ─── в конвейере ───"
./csvstat --column value < sample.csv 2>/dev/null | grep mean
Ожидаемый вывод:
образ собран
═══ Требование 1: аргументы доходят ═══
count 3
min 10
max 30
mean 20
median 20
пропущено нечисловых значений: 2
═══ Требование 2: чтение stdin ═══
count 3
min 10
max 30
mean 20
median 20
═══ Требование 3: разделение потоков ═══
только stdout: count 3 min 10 max 30 mean 20 median 20
только stderr: пропущено нечисловых значений: 2
═══ Требование 4: коды возврата ═══
успех: 0
файл не найден: 1
неверный аргумент: 2
колонки нет: 3
═══ Требование 5: непривилегированный пользователь ═══
UID: 10001
═══ Требование 6: владелец созданных файлов ═══
владелец файла: 1000:1000 (текущий пользователь: 1000:1000)
═══ Требование 7: тесты в сборке ═══
тесты прошли
═══ Обёртка ═══
count 3
min 10
max 30
mean 20
median 20
─── в конвейере ───
mean 20
Проверено прогоном 2026-08-04 на Docker Engine 29.7.1: вывод совпадает построчно, кроме требования 6 — там печатаются ваши
uid:gid, и важно лишь то, что оба числа в строке одинаковы. Прогон нашёл в первой редакции решения дефект:.dockerignoreисключалtests, из-за чего требование 7 не выполнялось никогда —docker build --target testзавершался с"/tests": not found, а не прогонял тесты (урок 5.1).
Четыре решения, определяющие качество.
Код 3 для отсутствующей колонки, а не 1. Отдельный код позволяет вызывающему скрипту различить «данные плохие» и «попросили не ту колонку» — реакция на эти случаи разная. Вместе с кодом выводится список доступных колонок, что превращает ошибку в подсказку.
Проверка [ -t 0 ] в обёртке. Флаг -i добавляется только когда stdin не терминал. Добавлять его всегда безопасно, но так обёртка честно отражает намерение. Флаг -t не добавляется никогда — он слил бы потоки и сломал требование 3.
--user "$(id -u):$(id -g)" в обёртке, а не в образе. В образе задан UID 10001 — это правильно для запуска в оркестраторе. Но для CLI, создающего файлы на host, нужен UID вызывающего. Флаг при запуске решает обе задачи без компромисса.
Требование 3 проверяется тестом, а не только вручную. Тест test_result_to_stdout_diagnostics_to_stderr фиксирует разделение потоков в коде. Без него рефакторинг легко переведёт диагностику в stdout и сломает все конвейеры, использующие утилиту.
cd /tmp && docker rmi -f csvstat csvstat:test > /dev/null 2>&1; rm -rf /tmp/csvstat
Проверка результата
cd resources/examples/python-cli
docker build -q -t wf . > /dev/null
echo "а б а в а" | docker run --rm -i wf --top 2 2>/dev/null
echo "код возврата: $?"
docker run --rm wf --no-such-option > /dev/null 2>&1; echo "неверный аргумент: $?"
docker rmi -f wf > /dev/null
Ожидается результат без диагностики, коды 0 и 2.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
ENTRYPOINT в shell form | Короче писать | Аргументы docker run теряются |
Забыт -i при чтении stdin | Не задумывались | Приложение получает конец файла сразу |
-it в конвейере | Скопировано из интерактивных примеров | -t сливает потоки и добавляет \r |
| Диагностика в stdout | Проще писать print | Портит конвейер; использовать file=sys.stderr |
Всегда код 0 или 1 | Не задумывались о кодах | Код возврата — часть интерфейса CLI |
HEALTHCHECK в CLI-образе | Копирование шаблона сервиса | Для разовой задачи бессмыслен |
--restart=always для CLI | Кажется надёжнее | Успешная задача перезапускается бесконечно |
Забыт --rm | Не задумывались | Накапливаются остановленные containers |
| Файлы результата принадлежат чужому UID | UID из образа | --user "$(id -u):$(id -g)" при запуске |
| Входной файл смонтирован на запись | Копируют шаблон | Использовать :ro |
Контрольные вопросы
На понимание:
- Почему для CLI предпочтителен
ENTRYPOINT, а не толькоCMD? - Почему
argparseвозвращает код2при неверных аргументах? - Почему диагностика должна идти в stderr, а не в stdout?
- Почему
-tломает использование CLI в конвейере? - Чем требования к CLI-образу отличаются от требований к образу сервиса?
На применение:
- Как передать данные на стандартный ввод приложения в container?
- Как обеспечить, чтобы созданные файлы принадлежали текущему пользователю host?
- Как сделать вызов образа похожим на обычную утилиту?
На диагностику:
- Аргументы, переданные в
docker run, игнорируются. Что проверить первым? - CLI в конвейере выдаёт лишние строки, хотя
2>/dev/nullуказан. Причина?
Краткое резюме
- CLI отличается от сервиса: разовое выполнение, коды возврата как интерфейс, чтение stdin.
- Для CLI используется
ENTRYPOINTс программой иCMDс аргументами по умолчанию. ENTRYPOINTобязательно в exec form — иначе аргументы теряются.- Коды возврата:
0успех,1ошибка выполнения,2неверные аргументы,3–125свои. - Результат идёт в stdout, диагностика — в stderr; это позволяет использовать конвейер.
- Флаг
-iнужен для чтения stdin,-tв конвейере вреден. - Входные файлы монтируются с
:ro, выходные — в отдельный каталог. - Владелец созданных файлов определяется UID процесса;
--userпереопределяет его. HEALTHCHECKи restart policy для CLI не применяются.- Скрипт-обёртка делает образ удобным: подставляет флаги и монтирует рабочий каталог.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Dockerfile reference: ENTRYPOINT | https://docs.docker.com/reference/dockerfile/#entrypoint | Взаимодействие с CMD, exec form, передача аргументов |
| docker run reference | https://docs.docker.com/reference/cli/docker/container/run/ | Флаги -i, -t, --rm, --user, --entrypoint |
Python: argparse | https://docs.python.org/3/library/argparse.html | Разбор аргументов, код возврата 2 при ошибке использования |
Python: sys | https://docs.python.org/3/library/sys.html | sys.stdin, sys.stderr, sys.exit |
Python: csv | https://docs.python.org/3/library/csv.html | DictReader, обработка ошибок формата |
| GNU Coding Standards: exit status | https://www.gnu.org/prep/standards/html_node/Exit-Status.html | Соглашения о кодах возврата |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Рекомендации по ENTRYPOINT для образов-инструментов |
| Docker storage: bind mounts | https://docs.docker.com/engine/storage/bind-mounts/ | Монтирование файлов, режим :ro |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Flask
Главное оглавление