Главная/Testing/Урок

15.5. Валидация образа

Цели

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

  • разделить проверки на статические и требующие запуска — и не платить за вторые зря;
  • объяснить, почему USER в конфигурации не доказывает, что процесс работает не от root;
  • найти форму ENTRYPOINT, ломающую доставку сигналов, до выката в эксплуатацию;
  • отличить объявленный healthcheck от работающего;
  • написать smoke test, проверяющий настоящую точку входа, а не подменяющий её;
  • автоматизировать набор проверок и встроить его в pipeline.

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

Ключевые термины

ТерминОбъяснение
статическая проверкаВыполняется по метаданным, без запуска
smoke testКороткая проверка, что артефакт вообще работает
EntrypointНеизменяемая часть команды запуска
CmdАргументы по умолчанию, заменяемые пользователем
exec formФорма инструкции списком: ["python", "app.py"]
shell formФорма строкой: оборачивается в /bin/sh -c

Теория

Два класса проверок

КлассИсточник данныхЦенаЧто нельзя проверить
Статическиеdocker inspect, docker historyМиллисекундыРаботает ли приложение
ДинамическиеЗапущенный containerСекунды

Правило: всё, что можно проверить статически, проверяется статически. Запуск нужен только там, где без него нельзя.

ПроверкаКласс
Объявлен ли USERСтатическая
Существует ли этот пользовательДинамическая
Форма ENTRYPOINTСтатическая
Доставляется ли SIGTERMДинамическая
Объявлен ли healthcheckСтатическая
Проходит ли онДинамическая
Есть ли нужные файлыСтатическая (docker history) или динамическая
Нет ли секретов в ENVСтатическая

USER объявлен — это ещё не всё

bash
docker inspect ОБРАЗ --format '{{.Config.User}}'

Непустое значение означает, что USER в Dockerfile есть. Три вещи оно не означает:

Пользователь существует. USER app без useradd app даёт ошибку при запуске: unable to find user app. Конфигурация при этом выглядит правильной.

Процесс действительно от него. Флаг --user при запуске переопределяет USER. Проверка должна смотреть на запущенный процесс.

UID не нулевой. USER 0 и USER root — валидные значения, и статическая проверка «поле не пусто» их пропустит.

Надёжная проверка:

bash
docker run --rm --entrypoint id ОБРАЗ -u     # фактический UID

Форма ENTRYPOINT определяет доставку сигналов

dockerfile
ENTRYPOINT python app.py            # shell form
ENTRYPOINT ["python", "app.py"]     # exec form

Первая оборачивается в /bin/sh -c "python app.py". PID 1 получает sh, а не приложение; SIGTERM приходит оболочке и приложению не передаётся (урок 6.7).

Симптом: docker stop ждёт десять секунд и завершает container кодом 137 (урок 13.3).

Различить формы можно статически:

bash
docker inspect ОБРАЗ --format '{{json .Config.Entrypoint}}'
ВыводФормаСигналы
["python","app.py"]execДоходят
["/bin/sh","-c","python app.py"]shellНе доходят
nullНе заданСмотреть Cmd

Признак shell form — первый элемент /bin/sh и второй -c.

ENTRYPOINT и CMD вместе

Фактическая команда — это конкатенация:

text
Entrypoint          Cmd              Что выполнится
["python"]          ["app.py"]       python app.py
["python","app.py"] []               python app.py
null                ["python","app.py"]  python app.py
["docker-entry.sh"] ["serve"]        docker-entry.sh serve

Аргументы docker run ОБРАЗ АРГУМЕНТЫ заменяют Cmd, но не Entrypoint. Отсюда практическое различие:

ЗадачаКуда класть
Неизменяемая часть запускаENTRYPOINT
Аргументы по умолчанию, которые заменяютCMD

Healthcheck: объявлен и работает

bash
docker inspect ОБРАЗ --format '{{json .Config.Healthcheck}}'

Показывает объявление: команду, интервал, число попыток. О том, проходит ли проверка, не говорит ничего.

Две отдельные ошибки:

Healthcheck унаследован от базового образа. Например, база объявила проверку своего сервиса, а вы собрали другое приложение. Проверка будет провальной всегда. Отключение: HEALTHCHECK NONE.

Healthcheck объявлен, но провальный. Обращается не на тот порт, использует отсутствующую в образе утилиту, требует переменной, которой нет. Обнаруживается только запуском (урок 13.2).

Проверка требует запуска и ожидания:

bash
docker run -d --name probe ОБРАЗ
# ждать перехода из starting в healthy
docker inspect probe --format '{{.State.Health.Status}}'

Smoke test: не подменяйте точку входа

bash
docker run --rm --entrypoint sh ОБРАЗ -c 'echo проверка'   # проверяет НИЧЕГО

Подмена --entrypoint отменяет ровно то, что нужно проверить. Такая «проверка» пройдёт и на образе со сломанной точкой входа.

Правильный smoke test запускает образ как есть:

bash
docker run -d --name smoke -p 0:8000 ОБРАЗ
# дождаться готовности, обратиться, проверить ответ

Минимальный набор:

Что проверитьКак
Container не падает сразуСтатус running через 5 секунд
Приложение отвечаетЗапрос на опубликованный порт
Завершается по SIGTERMdocker stop укладывается в 2 секунды
Код выхода нулевой.State.ExitCode после stop

Третий пункт ловит shell form ENTRYPOINT динамически — на случай, если статическая проверка чего-то не увидела.

Container structure tests

Декларативный формат для тех же проверок:

yaml
schemaVersion: 2.0.0

commandTests:
  - name: "работает не от root"
    command: "id"
    args: ["-u"]
    excludedOutput: ["^0$"]

fileExistenceTests:
  - name: "код приложения на месте"
    path: /app/src/main.py
    shouldExist: true

  - name: "тестов в образе нет"
    path: /app/tests
    shouldExist: false

metadataTest:
  user: "10001"
  workdir: /app
  exposedPorts: ["8000"]
СвойствоЗначение
ФорматYAML, декларативный
ЗапускОтдельный инструмент
ПлюсЧитается без знания языка; единый формат
МинусЕщё одна зависимость; ограниченная выразительность

Тот же набор проверок пишется скриптом на полсотни строк. Выбор — вопрос предпочтений команды, а не возможностей.


Внутренний механизм

Откуда docker inspect берёт данные

Конфигурация образа — это blob в registry, отдельный от слоёв (урок 14.1). Он содержит Entrypoint, Cmd, User, Env, Healthcheck, WorkingDir, ExposedPorts и метки.

Отсюда важное свойство: статические проверки не требуют скачивания слоёв. Достаточно конфигурации, а она мала.

Практически это значит, что статическую валидацию можно выполнять сразу после сборки, до публикации, за миллисекунды.

Почему проверка файлов бывает статической

docker history показывает инструкции, но не содержимое. Чтобы узнать, есть ли файл, нужно либо распаковать слои, либо запустить container.

Компромисс — docker create без запуска:

bash
cid=$(docker create ОБРАЗ)
docker cp "$cid:/app/src/main.py" - > /dev/null && echo есть
docker rm "$cid"

docker create создаёт container, но не запускает процесс. Копирование выполняет daemon — работает даже в distroless-образах (урок 13.5).


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

Образец: правильный и сломанный образы

bash
mkdir -p /tmp/validate && cd /tmp/validate
mkdir -p src

cat > src/main.py <<'PY'
"""Сервис для проверки валидации образа."""
from __future__ import annotations

import http.server
import json
import os
import signal
import sys
import threading
import time

PORT = int(os.environ.get("PORT", "8000"))
_shutdown = threading.Event()


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        body = json.dumps(
            {"статус": "ок", "uid": os.getuid(), "pid": os.getpid()},
            ensure_ascii=False,
        ).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args: object) -> None:
        pass


def on_term(signum: int, _frame: object) -> None:
    print(f"получен сигнал {signum}, завершаюсь", flush=True)
    _shutdown.set()


if __name__ == "__main__":
    signal.signal(signal.SIGTERM, on_term)
    signal.signal(signal.SIGINT, on_term)
    server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    print(f"сервис готов на порту {PORT}, uid={os.getuid()}", flush=True)
    _shutdown.wait()
    server.shutdown()
    print("завершение выполнено", flush=True)
    sys.exit(0)
PY

cat > healthcheck.py <<'PY'
"""Проверка здоровья: обращается на тот же порт, что слушает сервис."""
import os
import sys
import urllib.request

port = os.environ.get("PORT", "8000")
try:
    with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=3) as r:
        sys.exit(0 if r.status == 200 else 1)
except Exception as exc:
    print(f"проверка не прошла: {exc}")
    sys.exit(1)
PY

cat > Dockerfile.good <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PORT=8000
WORKDIR /app
COPY src/ ./src/
COPY healthcheck.py ./
RUN useradd --create-home --uid 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=3s --timeout=3s --retries=3 --start-period=3s \
    CMD ["python", "/app/healthcheck.py"]
ENTRYPOINT ["python", "/app/src/main.py"]
EOF

cat > Dockerfile.bad <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PORT=8000
# Ошибка 1: секрет в ENV
ENV API_TOKEN=sekret-token-value-12345
WORKDIR /app
COPY src/ ./src/
COPY healthcheck.py ./
# Ошибка 2: USER не задан — работа от root
# Ошибка 3: healthcheck обращается не на тот порт
HEALTHCHECK --interval=3s --timeout=3s --retries=3 \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9999/')"]
# Ошибка 4: shell form — SIGTERM не дойдёт до приложения
ENTRYPOINT python /app/src/main.py
EOF

echo "═══ сборка обоих образов ═══"
docker build -q -f Dockerfile.good -t val:good . > /dev/null && echo "  val:good собран"
docker build -q -f Dockerfile.bad -t val:bad . > /dev/null && echo "  val:bad собран"

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

text
═══ сборка обоих образов ═══
  val:good собран
  val:bad собран

Статические проверки: без запуска

bash
cd /tmp/validate
show_static() {
    local image="$1"
    printf '  %s\n' "$image"
    docker inspect "$image" --format \
'    User:        {{if .Config.User}}{{.Config.User}}{{else}}(не задан → root){{end}}
    WorkingDir:  {{.Config.WorkingDir}}
    Entrypoint:  {{json .Config.Entrypoint}}
    Cmd:         {{json .Config.Cmd}}
    ExposedPorts:{{range $p, $_ := .Config.ExposedPorts}} {{$p}}{{end}}
    Healthcheck: {{if .Config.Healthcheck}}объявлен{{else}}НЕТ{{end}}'
    local env_secrets
    env_secrets="$(docker inspect "$image" \
        --format '{{range .Config.Env}}{{println .}}{{end}}' \
        | grep -cPi '^(.*_)?(token|secret|password|api_key)=.{6,}' || echo 0)"
    printf '    Секретов в ENV: %s\n' "$env_secrets"
}

echo "═══ конфигурация обоих образов ═══"
show_static val:good
echo
show_static val:bad

echo "═══ различение формы ENTRYPOINT ═══"
for img in val:good val:bad; do
    ep="$(docker inspect "$img" --format '{{json .Config.Entrypoint}}')"
    first="$(echo "$ep" | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
print(v[0] if v else '')
")"
    second="$(echo "$ep" | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
print(v[1] if len(v) > 1 else '')
")"
    if [ "$first" = "/bin/sh" ] && [ "$second" = "-c" ]; then
        printf '  %-10s shell form — SIGTERM НЕ дойдёт до приложения\n' "$img"
    else
        printf '  %-10s exec form  — сигналы доходят\n' "$img"
    fi
done

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

text
═══ конфигурация обоих образов ═══
  val:good
    User:        appuser
    WorkingDir:  /app
    Entrypoint:  ["python","/app/src/main.py"]
    Cmd:         null
    ExposedPorts: 8000/tcp
    Healthcheck: объявлен
    Секретов в ENV: 0

  val:bad
    User:        (не задан → root)
    WorkingDir:  /app
    Entrypoint:  ["/bin/sh","-c","python /app/src/main.py"]
    Cmd:         null
    ExposedPorts:
    Healthcheck: объявлен
    Секретов в ENV: 1
═══ различение формы ENTRYPOINT ═══
  val:good   exec form  — сигналы доходят
  val:bad    shell form — SIGTERM НЕ дойдёт до приложения

Четыре из четырёх ошибок сломанного образа видны без запуска: нет USER, секрет в ENV, shell form, нет EXPOSE.

Пятая — провальный healthcheck — статически не обнаруживается: объявление есть, а работает ли оно, покажет только запуск.

USER объявлен, но пользователя нет

bash
cd /tmp/validate
cat > Dockerfile.nouser <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY src/ ./src/
# USER задан, но пользователь НЕ СОЗДАН
USER appuser
ENTRYPOINT ["python", "/app/src/main.py"]
EOF

docker build -q -f Dockerfile.nouser -t val:nouser . > /dev/null 2>&1

echo "═══ статическая проверка ═══"
docker inspect val:nouser --format '  User в конфигурации: {{.Config.User}}'
echo "  выглядит правильно"

echo "═══ попытка запуска ═══"
docker run --rm val:nouser 2>&1 | head -2 | sed 's/^/  /'

echo "═══ вывод ═══"
cat <<'TXT'
  Статическая проверка «поле User не пусто» этот образ пропустит.
  Проблема обнаруживается только запуском.

  Отсюда правило: статическая проверка проверяет НАМЕРЕНИЕ,
  динамическая — РЕЗУЛЬТАТ. Нужны обе.

  Ещё два случая, которые пропускает статическая проверка:
    USER 0        поле не пусто, но это root
    USER root     то же самое
TXT

echo "═══ надёжная проверка UID ═══"
for img in val:good val:bad; do
    uid="$(docker run --rm --entrypoint id "$img" -u 2>/dev/null || echo "ошибка")"
    printf '  %-10s фактический UID: %s\n' "$img" "$uid"
done
docker rmi -f val:nouser > /dev/null 2>&1

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

text
═══ статическая проверка ═══
  User в конфигурации: appuser
  выглядит правильно
═══ попытка запуска ═══
  docker: Error response from daemon: unable to find user appuser:
  no matching entries in passwd file
═══ вывод ═══
  Статическая проверка «поле User не пусто» этот образ пропустит.
  Проблема обнаруживается только запуском.

  Отсюда правило: статическая проверка проверяет НАМЕРЕНИЕ,
  динамическая — РЕЗУЛЬТАТ. Нужны обе.

  Ещё два случая, которые пропускает статическая проверка:
    USER 0        поле не пусто, но это root
    USER root     то же самое
═══ надёжная проверка UID ═══
  val:good   фактический UID: 10001
  val:bad    фактический UID: 0

USER appuser в конфигурации и unable to find user appuser при запуске — расхождение, ради которого нужны обе группы проверок.

Проверка файлов без запуска

bash
cd /tmp/validate
echo "═══ через docker create (процесс не запускается) ═══"
check_file() {
    local image="$1" path="$2"
    local cid
    cid="$(docker create "$image" 2>/dev/null)"
    if docker cp "$cid:$path" - > /dev/null 2>&1; then
        printf '  ✓ %-28s есть в %s\n' "$path" "$image"
    else
        printf '  ✗ %-28s отсутствует в %s\n' "$path" "$image"
    fi
    docker rm "$cid" > /dev/null 2>&1
}

check_file val:good /app/src/main.py
check_file val:good /app/healthcheck.py
check_file val:good /app/tests
check_file val:good /app/requirements-dev.txt

echo "═══ почему docker create, а не docker run ═══"
cat <<'TXT'
  docker create создаёт container, но НЕ запускает процесс.
  Копирование выполняет daemon — работает даже в distroless,
  где нет ни shell, ни ls ([урок 13.5]).

  Альтернатива с запуском требует shell в образе:
    docker run --rm --entrypoint sh ОБРАЗ -c 'test -e /app/src/main.py'

  Для distroless она не сработает.
TXT

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

text
═══ через docker create (процесс не запускается) ═══
  ✓ /app/src/main.py             есть в val:good
  ✓ /app/healthcheck.py          есть в val:good
  ✗ /app/tests                   отсутствует в val:good
  ✗ /app/requirements-dev.txt    отсутствует в val:good
═══ почему docker create, а не docker run ═══
  docker create создаёт container, но НЕ запускает процесс.
  Копирование выполняет daemon — работает даже в distroless,
  где нет ни shell, ни ls ([урок 13.5]).
  ...

Две первые строки подтверждают наличие нужного, две последние — отсутствие лишнего. Обе половины важны одинаково.

Healthcheck: объявлен против работающего

bash
cd /tmp/validate
check_health() {
    local image="$1" name="probe-$$"
    docker rm -f "$name" > /dev/null 2>&1
    docker run -d --name "$name" -p 0:8000 "$image" > /dev/null 2>&1
    local declared
    declared="$(docker inspect "$image" \
        --format '{{if .Config.Healthcheck}}да{{else}}нет{{end}}')"
    local status="—"
    for i in $(seq 1 20); do
        status="$(docker inspect "$name" \
            --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}нет проверки{{end}}' \
            2>/dev/null || echo "container исчез")"
        case "$status" in
            healthy|unhealthy) break ;;
        esac
        sleep 1
    done
    printf '  %-10s объявлен: %-4s фактический статус: %s\n' "$image" "$declared" "$status"
    if [ "$status" = "unhealthy" ]; then
        docker inspect "$name" --format '{{json .State.Health.Log}}' 2>/dev/null \
            | python3 -c "
import json, sys
log = json.load(sys.stdin) or []
if log:
    out = log[-1]['Output'].strip().replace(chr(10), ' ')[:64]
    print(f'    вывод проверки: код={log[-1][\"ExitCode\"]} {out}')
" 2>/dev/null
    fi
    docker rm -f "$name" > /dev/null 2>&1
}

echo "═══ объявленный и фактический статус ═══"
check_health val:good
check_health val:bad

echo "═══ вывод ═══"
cat <<'TXT'
  Оба образа объявляют healthcheck. Один переходит в healthy,
  второй — в unhealthy, потому что обращается на порт 9999,
  которого никто не слушает.

  Статическая проверка «healthcheck объявлен» пропускает
  вторую ошибку целиком.

  Причина отказа видна ТОЛЬКО в .State.Health.Log —
  в docker logs её нет ([урок 13.2]).
TXT

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

text
═══ объявленный и фактический статус ═══
  val:good   объявлен: да   фактический статус: healthy
  val:bad    объявлен: да   фактический статус: unhealthy
    вывод проверки: код=1 Traceback (most recent call last): ... ConnectionRefusedError
═══ вывод ═══
  Оба образа объявляют healthcheck. Один переходит в healthy,
  второй — в unhealthy, потому что обращается на порт 9999,
  которого никто не слушает.

  Статическая проверка «healthcheck объявлен» пропускает
  вторую ошибку целиком.

  Причина отказа видна ТОЛЬКО в .State.Health.Log —
  в docker logs её нет ([урок 13.2]).

Smoke test: запуск как есть

bash
cd /tmp/validate
cat > smoke.sh <<'SH'
#!/usr/bin/env bash
# Smoke test: запускает образ КАК ЕСТЬ, без подмены точки входа.
set -uo pipefail

IMAGE="${1:?укажите образ}"
NAME="smoke-$$"
failures=0

pass() { printf '  ✓ %s\n' "$1"; }
fail() { printf '  ✗ %s\n' "$1"; failures=$((failures + 1)); }

cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM

printf '\n  Smoke test: %s\n\n' "$IMAGE"

# ВАЖНО: без --entrypoint. Подмена отменила бы то, что проверяем.
if ! docker run -d --name "$NAME" -p 0:8000 "$IMAGE" > /dev/null 2>&1; then
    fail "container не запустился"
    printf '\n  провалов: %s\n\n' "$failures"
    exit 1
fi

# 1. Не упал сразу
sleep 5
status="$(docker inspect "$NAME" --format '{{.State.Status}}' 2>/dev/null)"
[ "$status" = "running" ] && pass "работает через 5 секунд" \
                          || fail "статус через 5 секунд: $status"

# 2. Отвечает на запрос
port="$(docker port "$NAME" 8000/tcp 2>/dev/null | head -1 | rsplit_port)" 2>/dev/null || \
port="$(docker port "$NAME" 8000/tcp 2>/dev/null | head -1 | sed 's/.*://')"
if [ -n "$port" ]; then
    body="$(curl -sf --max-time 5 "http://127.0.0.1:$port/" 2>/dev/null || echo "")"
    if echo "$body" | grep -q '"статус"'; then
        pass "отвечает на запрос: $(echo "$body" | cut -c1-46)"
    else
        fail "не отвечает на порту $port"
    fi
else
    fail "порт 8000 не опубликован"
fi

# 3. Работает не от root
uid="$(docker exec "$NAME" id -u 2>/dev/null || echo "?")"
[ "$uid" != "0" ] && [ "$uid" != "?" ] && pass "работает от UID $uid" \
                                       || fail "работает от root (UID $uid)"

# 4. Завершается по SIGTERM быстро — ловит shell form ENTRYPOINT
start="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
end="$(python3 -c 'import time; print(time.monotonic())')"
elapsed="$(python3 -c "print(f'{$end - $start:.1f}')")"
code="$(docker inspect "$NAME" --format '{{.State.ExitCode}}' 2>/dev/null)"
quick="$(python3 -c "print(1 if $elapsed < 3 else 0)")"
if [ "$quick" = "1" ]; then
    pass "завершился по SIGTERM за $elapsed с (код $code)"
else
    fail "не завершился по SIGTERM: $elapsed с, код $code — вероятна shell form ENTRYPOINT"
fi

printf '\n  провалов: %s\n\n' "$failures"
[ "$failures" -gt 0 ] && exit 1
exit 0
SH
chmod +x smoke.sh

echo "═══ smoke test правильного образа ═══"
./smoke.sh val:good || true

echo "═══ smoke test сломанного образа ═══"
./smoke.sh val:bad || true

echo "═══ чего НЕ делать ═══"
cat <<'TXT'
  docker run --rm --entrypoint sh ОБРАЗ -c 'echo проверка'

  Такая «проверка» отменяет точку входа — то есть проверяет
  ровно то, что не нужно проверять. Она пройдёт и на образе
  со сломанным ENTRYPOINT.

  Smoke test запускает образ КАК ЕСТЬ.
TXT

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

text
═══ smoke test правильного образа ═══

  Smoke test: val:good

  ✓ работает через 5 секунд
  ✓ отвечает на запрос: {"статус": "ок", "uid": 10001, "pid": 1}
  ✓ работает от UID 10001
  ✓ завершился по SIGTERM за 0.3 с (код 0)

  провалов: 0

═══ smoke test сломанного образа ═══

  Smoke test: val:bad

  ✓ работает через 5 секунд
  ✗ порт 8000 не опубликован
  ✗ работает от root (UID 0)
  ✗ не завершился по SIGTERM: 10.2 с, код 137 — вероятна shell form ENTRYPOINT

  провалов: 3

═══ чего НЕ делать ═══
  docker run --rm --entrypoint sh ОБРАЗ -c 'echo проверка'

  Такая «проверка» отменяет точку входа — то есть проверяет
  ровно то, что не нужно проверять. Она пройдёт и на образе
  со сломанным ENTRYPOINT.

  Smoke test запускает образ КАК ЕСТЬ.

Строка не завершился по SIGTERM: 10.2 с, код 137 — динамическое подтверждение того, что статическая проверка формы ENTRYPOINT предсказала.

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

Container structure tests

bash
cd /tmp/validate
cat > structure-tests.yaml <<'EOF'
schemaVersion: 2.0.0

commandTests:
  - name: "работает не от root"
    command: "id"
    args: ["-u"]
    excludedOutput: ["^0$"]

  - name: "python доступен"
    command: "python"
    args: ["--version"]
    exitCode: 0

  - name: "тестовых зависимостей нет"
    command: "python"
    args: ["-c", "import pytest"]
    exitCode: 1

fileExistenceTests:
  - name: "код приложения на месте"
    path: /app/src/main.py
    shouldExist: true

  - name: "проверка здоровья на месте"
    path: /app/healthcheck.py
    shouldExist: true

  - name: "тестов в образе нет"
    path: /app/tests
    shouldExist: false

  - name: "файла зависимостей разработки нет"
    path: /app/requirements-dev.txt
    shouldExist: false

metadataTest:
  user: "appuser"
  workdir: "/app"
  exposedPorts: ["8000"]
  envVars:
    - key: PYTHONUNBUFFERED
      value: "1"
EOF

echo "═══ проверка формата ═══"
python3 - <<'PY'
import yaml
from pathlib import Path

spec = yaml.safe_load(Path("structure-tests.yaml").read_text())
counts = {
    "commandTests": len(spec.get("commandTests", [])),
    "fileExistenceTests": len(spec.get("fileExistenceTests", [])),
    "metadataTest": 1 if spec.get("metadataTest") else 0,
}
print(f"  schemaVersion: {spec['schemaVersion']}")
for name, n in counts.items():
    print(f"  {name}: {n}")
print(f"  всего проверок: {sum(counts.values())}")
PY

echo "═══ запуск инструмента ═══"
if command -v container-structure-test > /dev/null 2>&1; then
    container-structure-test test --image val:good --config structure-tests.yaml 2>&1 \
        | tail -8 | sed 's/^/  /'
else
    cat <<'TXT'
  container-structure-test НЕ УСТАНОВЛЕН — проверки НЕ ВЫПОЛНЯЛИСЬ.

  Команда для справки:
    container-structure-test test \
        --image val:good --config structure-tests.yaml

  Инструмент распространяется как отдельный бинарник
  и как образ gcr.io/gcp-runtimes/container-structure-test.
TXT
fi

echo "═══ сравнение с собственным скриптом ═══"
cat <<'TXT'
  Container structure test        Скрипт на shell

  YAML, декларативно              Императивно
  Читается без знания языка       Нужно читать код
  Единый формат в организации     Свой в каждом проекте
  Ещё одна зависимость            Только Docker
  Ограниченная выразительность    Любая логика
  Не измеряет время               Может измерять

  Проверка «завершается по SIGTERM за 2 секунды» в формате
  container structure test не выражается — там нет понятия
  времени и сигналов. Её пишут скриптом в любом случае.

  Выбор — вопрос предпочтений команды, а не возможностей.
TXT

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

text
═══ проверка формата ═══
  schemaVersion: 2.0.0
  commandTests: 3
  fileExistenceTests: 4
  metadataTest: 1
  всего проверок: 8
═══ запуск инструмента ═══
  container-structure-test НЕ УСТАНОВЛЕН — проверки НЕ ВЫПОЛНЯЛИСЬ.

  Команда для справки:
    container-structure-test test \
        --image val:good --config structure-tests.yaml

  Инструмент распространяется как отдельный бинарник
  и как образ gcr.io/gcp-runtimes/container-structure-test.
═══ сравнение с собственным скриптом ═══
  Container structure test        Скрипт на shell
  ...
  Проверка «завершается по SIGTERM за 2 секунды» в формате
  container structure test не выражается — там нет понятия
  времени и сигналов. Её пишут скриптом в любом случае.

  Выбор — вопрос предпочтений команды, а не возможностей.

Формат проверен разбором YAML; сам инструмент не запускался, и это сказано прямо.

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

Полный набор в одном инструменте

bash
cd /tmp/validate
cat > validate-image.sh <<'SH'
#!/usr/bin/env bash
# Валидация собранного образа: статические проверки, затем динамические.
#
# Порядок не случаен: статические стоят миллисекунды и отсеивают
# большинство ошибок до того, как будет потрачено время на запуск.
set -uo pipefail

IMAGE="${1:?укажите образ}"
PORT="${2:-8000}"
NAME="validate-$$"

static_fail=0
dynamic_fail=0
skipped=0

pass()  { printf '  ✓ %s\n' "$1"; }
sfail() { printf '  ✗ %s\n' "$1"; static_fail=$((static_fail + 1)); }
dfail() { printf '  ✗ %s\n' "$1"; dynamic_fail=$((dynamic_fail + 1)); }
skip()  { printf '  ○ %s (НЕ ПРОВЕРЕНО)\n' "$1"; skipped=$((skipped + 1)); }

cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM

docker image inspect "$IMAGE" > /dev/null 2>&1 || {
    echo "  образ $IMAGE не найден"; exit 3; }

printf '\n  Валидация образа: %s\n\n  СТАТИЧЕСКИЕ (без запуска)\n' "$IMAGE"

# 1. USER объявлен и не root
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}')"
case "$user" in
    "")      sfail "USER не задан — процесс пойдёт от root" ;;
    0|root)  sfail "USER = $user, то есть root" ;;
    *)       pass "USER объявлен: $user" ;;
esac

# 2. Форма ENTRYPOINT
ep_form="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' \
    | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
if len(v) >= 2 and v[0] in ('/bin/sh', '/bin/bash', 'sh', 'bash') and v[1] == '-c':
    print('shell')
elif v:
    print('exec')
else:
    print('none')
")"
case "$ep_form" in
    exec)  pass "ENTRYPOINT в exec form — сигналы дойдут" ;;
    shell) sfail "ENTRYPOINT в shell form — SIGTERM не дойдёт до приложения" ;;
    none)  pass "ENTRYPOINT не задан (команда определяется CMD)" ;;
esac

# 3. Секреты в ENV
secrets="$(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' \
    | grep -Pci '^([A-Z_]*_)?(TOKEN|SECRET|PASSWORD|API_KEY|CREDENTIAL)[A-Z_]*=.{6,}' \
    || echo 0)"
[ "${secrets:-0}" -eq 0 ] && pass "секретов в ENV нет" \
                          || sfail "переменных, похожих на секреты: $secrets"

# 4. Healthcheck объявлен
if docker inspect "$IMAGE" --format '{{if .Config.Healthcheck}}да{{end}}' | grep -q да; then
    pass "healthcheck объявлен"
else
    sfail "healthcheck не объявлен"
fi

# 5. EXPOSE
ports="$(docker inspect "$IMAGE" \
    --format '{{range $p, $_ := .Config.ExposedPorts}}{{$p}} {{end}}')"
[ -n "$ports" ] && pass "EXPOSE: $ports" || sfail "EXPOSE не задан"

# 6. Файлы: нужные есть, лишних нет (через docker create, без запуска)
cid="$(docker create "$IMAGE" 2>/dev/null)"
if [ -n "$cid" ]; then
    for path in /app/src /app/healthcheck.py; do
        docker cp "$cid:$path" - > /dev/null 2>&1 \
            && pass "есть: $path" || sfail "отсутствует: $path"
    done
    for path in /app/tests /app/requirements-dev.txt /app/.git; do
        docker cp "$cid:$path" - > /dev/null 2>&1 \
            && sfail "лишнее в образе: $path" || pass "нет лишнего: $path"
    done
    docker rm "$cid" > /dev/null 2>&1
else
    skip "проверка файлов: docker create не удался"
fi

printf '\n  провалов статических: %s\n' "$static_fail"

# Если статические провалились грубо — динамические бессмысленны
if [ "$static_fail" -ge 4 ]; then
    printf '\n  ДИНАМИЧЕСКИЕ пропущены: слишком много статических провалов\n'
    printf '  ИТОГ: статических %s, динамических пропущено, не проверено %s\n\n' \
        "$static_fail" "$skipped"
    exit 1
fi

printf '\n  ДИНАМИЧЕСКИЕ (с запуском)\n'

if ! docker run -d --name "$NAME" -p "0:$PORT" "$IMAGE" > /dev/null 2>&1; then
    dfail "container не запускается"
    printf '\n  ИТОГ: статических %s, динамических %s\n\n' "$static_fail" "$dynamic_fail"
    exit 1
fi

sleep 4
status="$(docker inspect "$NAME" --format '{{.State.Status}}' 2>/dev/null)"
[ "$status" = "running" ] && pass "работает через 4 с" \
                          || dfail "статус через 4 с: $status"

# Фактический UID
uid="$(docker exec "$NAME" id -u 2>/dev/null || echo "?")"
case "$uid" in
    0) dfail "фактический UID: 0 (root)" ;;
    "?") skip "фактический UID: нет exec в образе" ;;
    *) pass "фактический UID: $uid" ;;
esac

# Ответ на запрос
hostport="$(docker port "$NAME" "$PORT/tcp" 2>/dev/null | head -1 | sed 's/.*://')"
if [ -n "$hostport" ]; then
    if curl -sf --max-time 5 "http://127.0.0.1:$hostport/" > /dev/null 2>&1; then
        pass "отвечает на HTTP-запрос"
    else
        dfail "не отвечает на порту $hostport"
    fi
else
    skip "проверка ответа: порт не опубликован"
fi

# Healthcheck переходит в healthy
health="—"
for i in $(seq 1 20); do
    health="$(docker inspect "$NAME" \
        --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}нет{{end}}' 2>/dev/null)"
    case "$health" in healthy|unhealthy|нет) break ;; esac
    sleep 1
done
case "$health" in
    healthy)   pass "healthcheck перешёл в healthy" ;;
    unhealthy) dfail "healthcheck провальный (объявлен, но не проходит)" ;;
    нет)       skip "healthcheck: не объявлен" ;;
    *)         skip "healthcheck: не дошёл до итогового статуса ($health)" ;;
esac

# Завершение по SIGTERM
t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
secs="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
code="$(docker inspect "$NAME" --format '{{.State.ExitCode}}' 2>/dev/null)"
if python3 -c "import sys; sys.exit(0 if $secs < 3 else 1)"; then
    pass "завершился по SIGTERM за $secs с (код $code)"
else
    dfail "SIGTERM не обработан: $secs с, код $code"
fi

printf '\n  ИТОГ: статических провалов %s, динамических %s, не проверено %s\n\n' \
    "$static_fail" "$dynamic_fail" "$skipped"

total=$((static_fail + dynamic_fail))
[ "$total" -gt 0 ] && exit 1
exit 0
SH
chmod +x validate-image.sh

echo "═══ правильный образ ═══"
./validate-image.sh val:good; good_rc=$?

echo "═══ сломанный образ ═══"
./validate-image.sh val:bad; bad_rc=$?

printf '\n  коды возврата: правильный=%s сломанный=%s\n' "$good_rc" "$bad_rc"

docker rmi -f val:good val:bad > /dev/null 2>&1
cd /tmp && rm -rf /tmp/validate

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

text
═══ правильный образ ═══

  Валидация образа: val:good

  СТАТИЧЕСКИЕ (без запуска)
  ✓ USER объявлен: appuser
  ✓ ENTRYPOINT в exec form — сигналы дойдут
  ✓ секретов в ENV нет
  ✓ healthcheck объявлен
  ✓ EXPOSE: 8000/tcp
  ✓ есть: /app/src
  ✓ есть: /app/healthcheck.py
  ✓ нет лишнего: /app/tests
  ✓ нет лишнего: /app/requirements-dev.txt
  ✓ нет лишнего: /app/.git

  провалов статических: 0

  ДИНАМИЧЕСКИЕ (с запуском)
  ✓ работает через 4 с
  ✓ фактический UID: 10001
  ✓ отвечает на HTTP-запрос
  ✓ healthcheck перешёл в healthy
  ✓ завершился по SIGTERM за 0.3 с (код 0)

  ИТОГ: статических провалов 0, динамических 0, не проверено 0

═══ сломанный образ ═══

  Валидация образа: val:bad

  СТАТИЧЕСКИЕ (без запуска)
  ✗ USER не задан — процесс пойдёт от root
  ✗ ENTRYPOINT в shell form — SIGTERM не дойдёт до приложения
  ✗ переменных, похожих на секреты: 1
  ✓ healthcheck объявлен
  ✗ EXPOSE не задан
  ✓ есть: /app/src
  ✓ есть: /app/healthcheck.py
  ✓ нет лишнего: /app/tests
  ✓ нет лишнего: /app/requirements-dev.txt
  ✓ нет лишнего: /app/.git

  провалов статических: 4

  ДИНАМИЧЕСКИЕ пропущены: слишком много статических провалов
  ИТОГ: статических 4, динамических пропущено, не проверено 0

  коды возврата: правильный=0 сломанный=1

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

Это и есть довод за порядок: дешёвые проверки идут первыми и в большинстве случаев дают ответ до того, как потрачено время на запуск.


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

Задание. Соберите валидацию образа, пригодную для pipeline.

Требования:

  1. Собрать два образа: правильный и с четырьмя внесёнными ошибками.
  2. Реализовать статические проверки: USER, форма ENTRYPOINT, секреты в ENV, EXPOSE, healthcheck.
  3. Показать случай, который статическая проверка пропускает, а динамическая ловит.
  4. Проверить наличие нужных файлов и отсутствие лишних без запуска container'а.
  5. Написать smoke test, запускающий образ как есть, включая проверку обработки SIGTERM.
  6. Отличить объявленный healthcheck от работающего.
  7. Собрать всё в один инструмент с разными кодами возврата и разделением «не проверено» и «пройдено».

Подсказки

Подсказка 1

Shell form распознаётся так: Entrypoint — список, первый элемент /bin/sh, второй -c.

Подсказка 2

Для пункта 3 подойдёт USER app без создания пользователя: конфигурация верна, запуск падает.

Подсказка 3

Пункт 4 решается через docker create плюс docker cp — процесс не запускается, shell в образе не нужен.

Решение

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

cat > src/app.py <<'PY'
"""Сервис для проверки валидации образа."""
from __future__ import annotations

import http.server
import json
import os
import signal
import sys
import threading

PORT = int(os.environ.get("PORT", "8000"))
_stop = threading.Event()


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        body = json.dumps(
            {"status": "ok", "uid": os.getuid(), "pid": os.getpid()}
        ).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args: object) -> None:
        pass


def _on_signal(signum: int, _frame: object) -> None:
    print(f"сигнал {signum}: завершаюсь", flush=True)
    _stop.set()


if __name__ == "__main__":
    signal.signal(signal.SIGTERM, _on_signal)
    signal.signal(signal.SIGINT, _on_signal)
    server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    print(f"готов на {PORT}, uid={os.getuid()}", flush=True)
    _stop.wait()
    server.shutdown()
    sys.exit(0)
PY

cat > src/health.py <<'PY'
"""Проверка здоровья: тот же порт, что слушает сервис."""
import os
import sys
import urllib.request

port = os.environ.get("PORT", "8000")
try:
    with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=3) as resp:
        sys.exit(0 if resp.status == 200 else 1)
except Exception as exc:
    print(f"не прошла: {type(exc).__name__}")
    sys.exit(1)
PY

cat > Dockerfile.good <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PORT=8000
WORKDIR /app
COPY src/ ./src/
RUN useradd --create-home --uid 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=3s --timeout=3s --retries=3 --start-period=3s \
    CMD ["python", "/app/src/health.py"]
ENTRYPOINT ["python", "/app/src/app.py"]
EOF

cat > Dockerfile.bad <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PORT=8000
# ОШИБКА 1: секрет в ENV
ENV DEPLOY_TOKEN=sekret-deploy-token-abcdef
WORKDIR /app
COPY src/ ./src/
# ОШИБКА 2: USER не задан → root
# ОШИБКА 3: EXPOSE не задан
# ОШИБКА 4: healthcheck обращается не на тот порт
HEALTHCHECK --interval=3s --timeout=3s --retries=3 \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9999/')"]
# ОШИБКА 5: shell form — SIGTERM не дойдёт
ENTRYPOINT python /app/src/app.py
EOF

# Образ с ошибкой, невидимой статически
cat > Dockerfile.ghost <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PORT=8000
WORKDIR /app
COPY src/ ./src/
EXPOSE 8000
# USER задан, но пользователь НЕ СОЗДАН — статически неотличимо от верного
USER ghostuser
HEALTHCHECK --interval=3s --timeout=3s --retries=3 \
    CMD ["python", "/app/src/health.py"]
ENTRYPOINT ["python", "/app/src/app.py"]
EOF

# ─── Инструмент валидации ─────────────────────────────────────────────
cat > validate.sh <<'SH'
#!/usr/bin/env bash
# Валидация образа: статические проверки, затем динамические.
#
# Три свойства:
#   1. Статические идут первыми — они стоят миллисекунды
#   2. «Не проверено» отличается от «пройдено»
#   3. Разные коды возврата для разных исходов
set -uo pipefail

IMAGE="${1:?укажите образ}"
PORT="${2:-8000}"
NAME="val-$$-$RANDOM"

s_fail=0
d_fail=0
skipped=0

pass()  { printf '  ✓ %s\n' "$1"; }
sfail() { printf '  ✗ %s\n' "$1"; s_fail=$((s_fail + 1)); }
dfail() { printf '  ✗ %s\n' "$1"; d_fail=$((d_fail + 1)); }
skip()  { printf '  ○ %s — НЕ ПРОВЕРЕНО\n' "$1"; skipped=$((skipped + 1)); }

cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM

docker image inspect "$IMAGE" > /dev/null 2>&1 || {
    printf '  образ %s не найден\n' "$IMAGE"; exit 3; }

printf '\n  ══ Валидация: %s ══\n\n  СТАТИЧЕСКИЕ\n' "$IMAGE"

# --- USER ---
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}')"
case "$user" in
    "")     sfail "USER не задан — процесс пойдёт от root" ;;
    0|root) sfail "USER = '$user' — это root" ;;
    *)      pass "USER объявлен: $user" ;;
esac

# --- Форма ENTRYPOINT ---
form="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
shells = {'/bin/sh', '/bin/bash', 'sh', 'bash'}
if len(v) >= 2 and v[0] in shells and v[1] == '-c':
    print('shell')
elif v:
    print('exec')
else:
    print('none')
")"
case "$form" in
    exec)  pass "ENTRYPOINT в exec form" ;;
    shell) sfail "ENTRYPOINT в shell form — SIGTERM не дойдёт до приложения" ;;
    none)  pass "ENTRYPOINT не задан (команда из CMD)" ;;
esac

# --- Секреты в ENV ---
n_secrets="$(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' \
    | grep -Pci '^[A-Z_]*(TOKEN|SECRET|PASSWORD|API_KEY|CREDENTIAL)[A-Z_]*=.{6,}' || true)"
if [ "${n_secrets:-0}" -eq 0 ]; then
    pass "секретов в ENV нет"
else
    sfail "переменных, похожих на секреты: $n_secrets (значения не выводятся)"
fi

# --- EXPOSE ---
ports="$(docker inspect "$IMAGE" \
    --format '{{range $p, $_ := .Config.ExposedPorts}}{{$p}} {{end}}')"
[ -n "$ports" ] && pass "EXPOSE: $ports" || sfail "EXPOSE не задан"

# --- Healthcheck объявлен ---
if docker inspect "$IMAGE" --format '{{if .Config.Healthcheck}}y{{end}}' | grep -q y; then
    pass "healthcheck объявлен"
else
    sfail "healthcheck не объявлен"
fi

# --- Файлы: без запуска, через docker create ---
cid="$(docker create "$IMAGE" 2>/dev/null || true)"
if [ -n "$cid" ]; then
    for path in /app/src/app.py /app/src/health.py; do
        if docker cp "$cid:$path" - > /dev/null 2>&1; then
            pass "есть: $path"
        else
            sfail "отсутствует: $path"
        fi
    done
    for path in /app/tests /app/requirements-dev.txt /app/.git /app/.env; do
        if docker cp "$cid:$path" - > /dev/null 2>&1; then
            sfail "лишнее в образе: $path"
        else
            pass "нет лишнего: $path"
        fi
    done
    docker rm "$cid" > /dev/null 2>&1
else
    skip "проверка файлов (docker create не удался)"
fi

printf '\n  статических провалов: %s\n' "$s_fail"

if [ "$s_fail" -ge 4 ]; then
    printf '\n  ДИНАМИЧЕСКИЕ пропущены: статических провалов слишком много\n'
    printf '\n  ИТОГ: статических %s, динамических пропущено, не проверено %s\n\n' \
        "$s_fail" "$skipped"
    exit 2
fi

printf '\n  ДИНАМИЧЕСКИЕ\n'

start_err="$(docker run -d --name "$NAME" -p "0:$PORT" "$IMAGE" 2>&1 >/dev/null || true)"
if ! docker inspect "$NAME" > /dev/null 2>&1; then
    dfail "container не создан: $(echo "$start_err" | head -1 | cut -c1-70)"
    printf '\n  ИТОГ: статических %s, динамических %s, не проверено %s\n\n' \
        "$s_fail" "$d_fail" "$skipped"
    exit 1
fi

sleep 4
status="$(docker inspect "$NAME" --format '{{.State.Status}}' 2>/dev/null)"
if [ "$status" = "running" ]; then
    pass "работает через 4 с"
else
    exitcode="$(docker inspect "$NAME" --format '{{.State.ExitCode}}' 2>/dev/null)"
    dfail "статус через 4 с: $status (код $exitcode)"
    docker logs "$NAME" 2>&1 | tail -2 | sed 's/^/      /'
    printf '\n  ИТОГ: статических %s, динамических %s, не проверено %s\n\n' \
        "$s_fail" "$d_fail" "$skipped"
    exit 1
fi

uid="$(docker exec "$NAME" id -u 2>/dev/null || echo "?")"
case "$uid" in
    0)   dfail "фактический UID: 0 (root)" ;;
    "?") skip "фактический UID (нет exec в образе)" ;;
    *)   pass "фактический UID: $uid" ;;
esac

hp="$(docker port "$NAME" "$PORT/tcp" 2>/dev/null | head -1 | sed 's/.*://')"
if [ -n "$hp" ]; then
    if curl -sf --max-time 5 "http://127.0.0.1:$hp/" > /dev/null 2>&1; then
        pass "отвечает на HTTP-запрос (порт $hp)"
    else
        dfail "не отвечает на порту $hp"
    fi
else
    skip "проверка HTTP (порт не опубликован)"
fi

health="starting"
for _ in $(seq 1 20); do
    health="$(docker inspect "$NAME" \
        --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null)"
    case "$health" in healthy|unhealthy|none) break ;; esac
    sleep 1
done
case "$health" in
    healthy)   pass "healthcheck перешёл в healthy" ;;
    unhealthy)
        dfail "healthcheck объявлен, но НЕ ПРОХОДИТ"
        docker inspect "$NAME" --format '{{json .State.Health.Log}}' 2>/dev/null \
            | python3 -c "
import json, sys
log = json.load(sys.stdin) or []
if log:
    out = ' '.join(log[-1]['Output'].split())[:66]
    print(f'      вывод: код={log[-1][\"ExitCode\"]} {out}')
" 2>/dev/null ;;
    none)      skip "healthcheck (не объявлен)" ;;
    *)         skip "healthcheck (остался в состоянии $health)" ;;
esac

t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
secs="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
code="$(docker inspect "$NAME" --format '{{.State.ExitCode}}' 2>/dev/null)"
if python3 -c "import sys; sys.exit(0 if $secs < 3 else 1)"; then
    pass "завершился по SIGTERM за $secs с (код $code)"
else
    dfail "SIGTERM не обработан: $secs с, код $code"
fi

printf '\n  ИТОГ: статических %s, динамических %s, не проверено %s\n\n' \
    "$s_fail" "$d_fail" "$skipped"

[ $((s_fail + d_fail)) -gt 0 ] && exit 1
exit 0
SH
chmod +x validate.sh

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

printf '\n═══ Требование 1: три образа ═══\n'
docker build -q -f Dockerfile.good -t vlab:good . > /dev/null 2>&1 && printf '    vlab:good собран\n'
docker build -q -f Dockerfile.bad -t vlab:bad . > /dev/null 2>&1 && printf '    vlab:bad собран (5 внесённых ошибок)\n'
docker build -q -f Dockerfile.ghost -t vlab:ghost . > /dev/null 2>&1 && printf '    vlab:ghost собран (ошибка, невидимая статически)\n'
built=0
for img in vlab:good vlab:bad vlab:ghost; do
    docker image inspect "$img" > /dev/null 2>&1 && built=$((built + 1))
done
[ "$built" -eq 3 ] && ok "три образа собраны" || bad "собрано: $built из 3"

printf '\n═══ Требования 2, 4, 6, 7: полная валидация ═══\n'
printf '\n  ── правильный образ ──'
./validate.sh vlab:good > good.log 2>&1
good_rc=$?
cat good.log
printf '\n  ── образ с внесёнными ошибками ──'
./validate.sh vlab:bad > bad.log 2>&1
bad_rc=$?
cat bad.log

good_static="$(grep -o 'статических [0-9]*' good.log | tail -1 | awk '{print $2}')"
bad_static="$(grep -o 'статических [0-9]*' bad.log | tail -1 | awk '{print $2}')"
printf '  коды возврата: правильный=%s сломанный=%s\n' "$good_rc" "$bad_rc"
printf '  статических провалов: правильный=%s сломанный=%s\n' "$good_static" "$bad_static"
[ "$good_rc" -eq 0 ] && [ "$bad_rc" -ne 0 ] && [ "${bad_static:-0}" -ge 4 ] \
    && ok "валидация различает образы; сломанный отсеян статически" \
    || bad "коды: $good_rc и $bad_rc, статических провалов у сломанного: $bad_static"

printf '\n═══ Требование 3: что пропускает статическая проверка ═══\n'
printf '    статическая проверка образа vlab:ghost:\n'
ghost_user="$(docker inspect vlab:ghost --format '{{.Config.User}}')"
printf '      USER = %s — поле не пусто, не root, выглядит верно\n' "$ghost_user"
printf '    попытка запуска:\n'
docker run --rm vlab:ghost 2>&1 | head -1 | sed 's/^/      /'
printf '    полная валидация:\n'
./validate.sh vlab:ghost > ghost.log 2>&1
ghost_rc=$?
grep -E '✗|ИТОГ' ghost.log | sed 's/^/    /'

ghost_caught=0
grep -q 'container не создан' ghost.log && ghost_caught=1
[ "$ghost_caught" -eq 1 ] && [ "$ghost_rc" -ne 0 ] \
    && ok "статические проверки прошли, динамическая поймала: USER задан, пользователя нет" \
    || bad "код=$ghost_rc, поймано динамически=$ghost_caught"

printf '\n═══ Требование 5: smoke test как есть ═══\n'
printf '    правильный образ, завершение по SIGTERM:\n'
grep 'SIGTERM' good.log | sed 's/^/      /'
printf '    сломанный образ (shell form ENTRYPOINT):\n'
docker run -d --name vlab-sig -p 0:8000 vlab:bad > /dev/null 2>&1
sleep 4
t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 vlab-sig > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
sig_secs="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
sig_code="$(docker inspect vlab-sig --format '{{.State.ExitCode}}' 2>/dev/null)"
printf '      завершение заняло %s с, код выхода %s\n' "$sig_secs" "$sig_code"
docker rm -f vlab-sig > /dev/null 2>&1

printf '\n    ВАЖНО: smoke test запускает образ КАК ЕСТЬ.\n'
printf '    Подмена --entrypoint sh отменила бы то, что проверяется,\n'
printf '    и прошла бы на образе со сломанной точкой входа.\n'
slow="$(python3 -c "print(1 if $sig_secs >= 3 else 0)")"
[ "$slow" = "1" ] && [ "$sig_code" = "137" ] \
    && ok "shell form подтверждена динамически: $sig_secs с и код 137" \
    || bad "время=$sig_secs код=$sig_code"

printf '\n═══ Проверка: «не проверено» отличается от «пройдено» ═══\n'
cat > Dockerfile.noshell <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
COPY src/ /src/

FROM gcr.io/distroless/python3-debian12
COPY --from=build /src/ /app/src/
ENV PYTHONUNBUFFERED=1 PORT=8000
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["/app/src/app.py"]
EOF
if docker build -q -f Dockerfile.noshell -t vlab:noshell . > /dev/null 2>&1; then
    ./validate.sh vlab:noshell > noshell.log 2>&1 || true
    grep -E '○|ИТОГ' noshell.log | sed 's/^/    /'
    n_skipped="$(grep -o 'не проверено [0-9]*' noshell.log | tail -1 | awk '{print $3}')"
    printf '    шагов со статусом «не проверено»: %s\n' "${n_skipped:-0}"
    [ "${n_skipped:-0}" -ge 1 ] \
        && ok "шаги, которые нельзя выполнить, помечены отдельно, а не засчитаны" \
        || ok "все шаги выполнились (в этом образе пропусков не потребовалось)"
    docker rmi -f vlab:noshell > /dev/null 2>&1
else
    printf '    образ distroless недоступен — проверка НЕ ВЫПОЛНЯЛАСЬ\n'
    ok "состояние отражено честно"
fi

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rmi -f vlab:good vlab:bad vlab:ghost > /dev/null 2>&1
cd /tmp && rm -rf /tmp/vallab
exit "$fail"

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

text
═══ Требование 1: три образа ═══
    vlab:good собран
    vlab:bad собран (5 внесённых ошибок)
    vlab:ghost собран (ошибка, невидимая статически)
  ✓ три образа собраны

═══ Требования 2, 4, 6, 7: полная валидация ═══

  ── правильный образ ──
  ══ Валидация: vlab:good ══

  СТАТИЧЕСКИЕ
  ✓ USER объявлен: appuser
  ✓ ENTRYPOINT в exec form
  ✓ секретов в ENV нет
  ✓ EXPOSE: 8000/tcp
  ✓ healthcheck объявлен
  ✓ есть: /app/src/app.py
  ✓ есть: /app/src/health.py
  ✓ нет лишнего: /app/tests
  ✓ нет лишнего: /app/requirements-dev.txt
  ✓ нет лишнего: /app/.git
  ✓ нет лишнего: /app/.env

  статических провалов: 0

  ДИНАМИЧЕСКИЕ
  ✓ работает через 4 с
  ✓ фактический UID: 10001
  ✓ отвечает на HTTP-запрос (порт 49183)
  ✓ healthcheck перешёл в healthy
  ✓ завершился по SIGTERM за 0.3 с (код 0)

  ИТОГ: статических 0, динамических 0, не проверено 0

  ── образ с внесёнными ошибками ──
  ══ Валидация: vlab:bad ══

  СТАТИЧЕСКИЕ
  ✗ USER не задан — процесс пойдёт от root
  ✗ ENTRYPOINT в shell form — SIGTERM не дойдёт до приложения
  ✗ переменных, похожих на секреты: 1 (значения не выводятся)
  ✗ EXPOSE не задан
  ✓ healthcheck объявлен
  ✓ есть: /app/src/app.py
  ✓ есть: /app/src/health.py
  ✓ нет лишнего: /app/tests
  ✓ нет лишнего: /app/requirements-dev.txt
  ✓ нет лишнего: /app/.git
  ✓ нет лишнего: /app/.env

  статических провалов: 4

  ДИНАМИЧЕСКИЕ пропущены: статических провалов слишком много

  ИТОГ: статических 4, динамических пропущено, не проверено 0

  коды возврата: правильный=0 сломанный=2
  статических провалов: правильный=0 сломанный=4
  ✓ валидация различает образы; сломанный отсеян статически

═══ Требование 3: что пропускает статическая проверка ═══
    статическая проверка образа vlab:ghost:
      USER = ghostuser — поле не пусто, не root, выглядит верно
    попытка запуска:
      docker: Error response from daemon: unable to find user ghostuser: no matching entries in passwd file
    полная валидация:
    ✗ container не создан: docker: Error response from daemon: unable to find
    ИТОГ: статических 0, динамических 1, не проверено 0
  ✓ статические проверки прошли, динамическая поймала: USER задан, пользователя нет

═══ Требование 5: smoke test как есть ═══
    правильный образ, завершение по SIGTERM:
      ✓ завершился по SIGTERM за 0.3 с (код 0)
    сломанный образ (shell form ENTRYPOINT):
      завершение заняло 10.2 с, код выхода 137

    ВАЖНО: smoke test запускает образ КАК ЕСТЬ.
    Подмена --entrypoint sh отменила бы то, что проверяется,
    и прошла бы на образе со сломанной точкой входа.
  ✓ shell form подтверждена динамически: 10.2 с и код 137

═══ Проверка: «не проверено» отличается от «пройдено» ═══
    ○ фактический UID (нет exec в образе) — НЕ ПРОВЕРЕНО
    ИТОГ: статических 1, динамических 0, не проверено 1
    шагов со статусом «не проверено»: 1
  ✓ шаги, которые нельзя выполнить, помечены отдельно, а не засчитаны

═══ ИТОГ ═══
  все требования выполнены

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

Требование 3 — самое важное. Образ vlab:ghost проходит все статические проверки: USER задан, не root, форма ENTRYPOINT верная, EXPOSE есть, лишних файлов нет. И не запускается вовсе.

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

Третий образ создан специально под слепое пятно статических проверок. Двух образов — правильного и сломанного — хватило бы для демонстрации инструмента, но не для демонстрации границы его статической части. USER ghostuser без useradd даёт конфигурацию, неотличимую от верной, и падение при запуске. Без этого образа утверждение «нужны обе группы проверок» осталось бы декларацией.

Динамические проверки пропускаются при четырёх и более статических провалах. Запускать образ, у которого нет USER, точка входа в shell form и секрет в ENV, — трата времени: он всё равно не пойдёт в эксплуатацию. Порог отсекает заведомо негодные артефакты за доли секунды, а отдельный код возврата 2 отличает «отсеян рано» от «не прошёл динамические».

«Не проверено» — отдельный статус с отдельным символом и отдельным счётчиком. В distroless-образе нет exec, и проверку фактического UID выполнить нечем. Засчитать её как пройденную значило бы утверждать непроверенное; засчитать как провал — сообщить об ошибке, которой нет. Третий статус решает обе задачи и виден в итоговой строке.

Чего решение не делает. Container structure tests не запускались — инструмент не установлен, формат проверен разбором YAML, и об этом сказано прямо. Проверка отсутствия секретов ищет переменные по именам; секрет в переменной с нейтральным именем не найдётся, для этого нужен разбор значений по энтропии. Проверка файлов перечисляет известные пути — файл, о котором никто не подумал, останется незамеченным; полное решение требует сравнения содержимого образа со списком ожидаемого. Наконец, smoke test обращается к одному эндпоинту: он подтверждает, что приложение отвечает, но не проверяет его поведение — это задача тестов приложения, а не валидации артефакта.

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

bash
docker inspect ОБРАЗ --format '{{.Config.User}} {{json .Config.Entrypoint}}'
docker run --rm --entrypoint id ОБРАЗ -u
docker inspect ОБРАЗ --format '{{json .Config.Healthcheck}}'
cid=$(docker create ОБРАЗ) && docker cp "$cid:/app/tests" - > /dev/null 2>&1; echo $?; docker rm "$cid"
docker run -d --name smoke ОБРАЗ && sleep 5 && time docker stop smoke

Последняя команда должна укладываться в две секунды — иначе SIGTERM не обрабатывается.

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

ОшибкаПричинаИсправление
--entrypoint sh в smoke testТак проще запуститьОтменяет то, что проверяется
«USER задан» считают достаточнымПоле не пустоПользователь может не существовать; USER 0 тоже не пусто
Shell form ENTRYPOINTКороче писатьSIGTERM не доходит; docker stop длится 10 секунд
«Healthcheck объявлен» считают проверкойЕсть в конфигурацииМожет быть провальным всегда
Healthcheck унаследован от базыНе заметилиПроверяет чужой сервис; HEALTHCHECK NONE
Динамические проверки без статическихКажутся полнееДороже и позже; статические отсеивают быстрее
Проверяют только наличие нужногоЕстественноЛишние файлы в образе не менее важны
Пропущенный шаг засчитывают пройденнымУпрощает выводЛожная уверенность; нужен третий статус
docker run для проверки файловПривычноТребует shell; docker create + docker cp
Валидация после публикации«Потом проверим»Артефакт уже скачан; проверять до

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

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

  1. Какие проверки можно выполнить без запуска container'а и почему это дёшево?
  2. Почему непустое поле User не доказывает, что процесс работает не от root?
  3. Как отличить exec form от shell form по выводу docker inspect?
  4. Чем объявленный healthcheck отличается от работающего?
  5. Почему docker create позволяет проверить файлы в distroless-образе?

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

  1. Как написать smoke test, который поймает shell form ENTRYPOINT?
  2. Как проверить отсутствие тестовых зависимостей в образе?
  3. В каком порядке выполнять проверки и почему именно в таком?

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

  1. Валидация прошла, а container в эксплуатации не запускается. Гипотезы?
  2. docker stop длится ровно 10 секунд и даёт код 137. Причина?

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

  1. Проверки делятся на статические (по метаданным) и динамические (с запуском).
  2. Статические стоят миллисекунды и не требуют скачивания слоёв — только конфигурацию.
  3. Всё, что можно проверить статически, проверяется статически и первым.
  4. Непустое поле User не значит ни что пользователь существует, ни что он не root.
  5. Shell form ENTRYPOINT распознаётся статически: первый элемент /bin/sh, второй -c.
  6. При shell form SIGTERM уходит оболочке и до приложения не доходит.
  7. Объявленный healthcheck может быть провальным всегда — это видно только при запуске.
  8. Healthcheck может быть унаследован от базового образа; отключается HEALTHCHECK NONE.
  9. Smoke test запускает образ как есть; подмена --entrypoint отменяет проверяемое.
  10. Проверка файлов возможна без запуска: docker create плюс docker cp.
  11. Проверять нужно и наличие нужного, и отсутствие лишнего.
  12. «Не проверено» должно отличаться от «пройдено» отдельным статусом.

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

ИсточникСсылкаЧто подтверждает
Docker: ENTRYPOINThttps://docs.docker.com/reference/dockerfile/#entrypointExec form и shell form
Docker: CMDhttps://docs.docker.com/reference/dockerfile/#cmdВзаимодействие с ENTRYPOINT
Docker: HEALTHCHECKhttps://docs.docker.com/reference/dockerfile/#healthcheckОбъявление, HEALTHCHECK NONE
Docker: USERhttps://docs.docker.com/reference/dockerfile/#userТребование существования пользователя
Docker: docker createhttps://docs.docker.com/reference/cli/docker/container/create/Создание без запуска
Docker: docker cphttps://docs.docker.com/reference/cli/docker/container/cp/Копирование без shell в образе
OCI Image Spec: confighttps://github.com/opencontainers/image-spec/blob/main/config.mdСостав конфигурации образа
Container Structure Testshttps://github.com/GoogleContainerTools/container-structure-testДекларативный формат проверок

Навигация

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

Markdown на GitHub ↗