15.5. Валидация образа
Цели
После этого материала вы сможете:
- разделить проверки на статические и требующие запуска — и не платить за вторые зря;
- объяснить, почему
USERв конфигурации не доказывает, что процесс работает не от root; - найти форму
ENTRYPOINT, ломающую доставку сигналов, до выката в эксплуатацию; - отличить объявленный healthcheck от работающего;
- написать smoke test, проверяющий настоящую точку входа, а не подменяющий её;
- автоматизировать набор проверок и встроить его в pipeline.
Предварительные знания
- 15.1. Стратегия тестирования — «код правильный?» против «артефакт правильный?»;
- 6.7. Сигналы и завершение;
- 13.2. Inspect, events, stats —
--format.
Ключевые термины
| Термин | Объяснение |
|---|---|
статическая проверка | Выполняется по метаданным, без запуска |
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 объявлен — это ещё не всё
docker inspect ОБРАЗ --format '{{.Config.User}}'
Непустое значение означает, что USER в Dockerfile есть. Три вещи оно не означает:
Пользователь существует. USER app без useradd app даёт ошибку при запуске: unable to find user app. Конфигурация при этом выглядит правильной.
Процесс действительно от него. Флаг --user при запуске переопределяет USER. Проверка должна смотреть на запущенный процесс.
UID не нулевой. USER 0 и USER root — валидные значения, и статическая проверка «поле не пусто» их пропустит.
Надёжная проверка:
docker run --rm --entrypoint id ОБРАЗ -u # фактический UID
Форма ENTRYPOINT определяет доставку сигналов
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).
Различить формы можно статически:
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 вместе
Фактическая команда — это конкатенация:
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: объявлен и работает
docker inspect ОБРАЗ --format '{{json .Config.Healthcheck}}'
Показывает объявление: команду, интервал, число попыток. О том, проходит ли проверка, не говорит ничего.
Две отдельные ошибки:
Healthcheck унаследован от базового образа. Например, база объявила проверку своего сервиса, а вы собрали другое приложение. Проверка будет провальной всегда. Отключение: HEALTHCHECK NONE.
Healthcheck объявлен, но провальный. Обращается не на тот порт, использует отсутствующую в образе утилиту, требует переменной, которой нет. Обнаруживается только запуском (урок 13.2).
Проверка требует запуска и ожидания:
docker run -d --name probe ОБРАЗ
# ждать перехода из starting в healthy
docker inspect probe --format '{{.State.Health.Status}}'
Smoke test: не подменяйте точку входа
docker run --rm --entrypoint sh ОБРАЗ -c 'echo проверка' # проверяет НИЧЕГО
Подмена --entrypoint отменяет ровно то, что нужно проверить. Такая «проверка» пройдёт и на образе со сломанной точкой входа.
Правильный smoke test запускает образ как есть:
docker run -d --name smoke -p 0:8000 ОБРАЗ
# дождаться готовности, обратиться, проверить ответ
Минимальный набор:
| Что проверить | Как |
|---|---|
| Container не падает сразу | Статус running через 5 секунд |
| Приложение отвечает | Запрос на опубликованный порт |
Завершается по SIGTERM | docker stop укладывается в 2 секунды |
| Код выхода нулевой | .State.ExitCode после stop |
Третий пункт ловит shell form ENTRYPOINT динамически — на случай, если статическая проверка чего-то не увидела.
Container structure tests
Декларативный формат для тех же проверок:
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 без запуска:
cid=$(docker create ОБРАЗ)
docker cp "$cid:/app/src/main.py" - > /dev/null && echo есть
docker rm "$cid"
docker create создаёт container, но не запускает процесс. Копирование выполняет daemon — работает даже в distroless-образах (урок 13.5).
Команды и примеры
Образец: правильный и сломанный образы
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 собран"
Ожидаемый вывод:
═══ сборка обоих образов ═══
val:good собран
val:bad собран
Статические проверки: без запуска
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
Ожидаемый вывод:
═══ конфигурация обоих образов ═══
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 объявлен, но пользователя нет
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
Ожидаемый вывод:
═══ статическая проверка ═══
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 при запуске — расхождение, ради которого нужны обе группы проверок.
Проверка файлов без запуска
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
Ожидаемый вывод:
═══ через 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: объявлен против работающего
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
Ожидаемый вывод:
═══ объявленный и фактический статус ═══
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: запуск как есть
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
Ожидаемый вывод:
═══ 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
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
Ожидаемый вывод:
═══ проверка формата ═══
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; сам инструмент не запускался, и это сказано прямо.
Последний абзац — практический вывод: даже приняв декларативный формат, часть проверок придётся писать скриптом.
Полный набор в одном инструменте
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
Ожидаемый вывод:
═══ правильный образ ═══
Валидация образа: 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.
Требования:
- Собрать два образа: правильный и с четырьмя внесёнными ошибками.
- Реализовать статические проверки:
USER, формаENTRYPOINT, секреты вENV,EXPOSE, healthcheck. - Показать случай, который статическая проверка пропускает, а динамическая ловит.
- Проверить наличие нужных файлов и отсутствие лишних без запуска container'а.
- Написать smoke test, запускающий образ как есть, включая проверку обработки
SIGTERM. - Отличить объявленный healthcheck от работающего.
- Собрать всё в один инструмент с разными кодами возврата и разделением «не проверено» и «пройдено».
Подсказки
Подсказка 1
Shell form распознаётся так: Entrypoint — список, первый элемент /bin/sh, второй -c.
Подсказка 2
Для пункта 3 подойдёт USER app без создания пользователя: конфигурация верна, запуск падает.
Подсказка 3
Пункт 4 решается через docker create плюс docker cp — процесс не запускается, shell в образе не нужен.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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 обращается к одному эндпоинту: он подтверждает, что приложение отвечает, но не проверяет его поведение — это задача тестов приложения, а не валидации артефакта.
Проверка результата
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 |
| Валидация после публикации | «Потом проверим» | Артефакт уже скачан; проверять до |
Контрольные вопросы
На понимание:
- Какие проверки можно выполнить без запуска container'а и почему это дёшево?
- Почему непустое поле
Userне доказывает, что процесс работает не от root? - Как отличить exec form от shell form по выводу
docker inspect? - Чем объявленный healthcheck отличается от работающего?
- Почему
docker createпозволяет проверить файлы в distroless-образе?
На применение:
- Как написать smoke test, который поймает shell form
ENTRYPOINT? - Как проверить отсутствие тестовых зависимостей в образе?
- В каком порядке выполнять проверки и почему именно в таком?
На диагностику:
- Валидация прошла, а container в эксплуатации не запускается. Гипотезы?
docker stopдлится ровно 10 секунд и даёт код137. Причина?
Краткое резюме
- Проверки делятся на статические (по метаданным) и динамические (с запуском).
- Статические стоят миллисекунды и не требуют скачивания слоёв — только конфигурацию.
- Всё, что можно проверить статически, проверяется статически и первым.
- Непустое поле
Userне значит ни что пользователь существует, ни что он не root. - Shell form
ENTRYPOINTраспознаётся статически: первый элемент/bin/sh, второй-c. - При shell form
SIGTERMуходит оболочке и до приложения не доходит. - Объявленный healthcheck может быть провальным всегда — это видно только при запуске.
- Healthcheck может быть унаследован от базового образа; отключается
HEALTHCHECK NONE. - Smoke test запускает образ как есть; подмена
--entrypointотменяет проверяемое. - Проверка файлов возможна без запуска:
docker createплюсdocker cp. - Проверять нужно и наличие нужного, и отсутствие лишнего.
- «Не проверено» должно отличаться от «пройдено» отдельным статусом.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Docker: ENTRYPOINT | https://docs.docker.com/reference/dockerfile/#entrypoint | Exec form и shell form |
Docker: CMD | https://docs.docker.com/reference/dockerfile/#cmd | Взаимодействие с ENTRYPOINT |
Docker: HEALTHCHECK | https://docs.docker.com/reference/dockerfile/#healthcheck | Объявление, HEALTHCHECK NONE |
Docker: USER | https://docs.docker.com/reference/dockerfile/#user | Требование существования пользователя |
Docker: docker create | https://docs.docker.com/reference/cli/docker/container/create/ | Создание без запуска |
Docker: docker cp | https://docs.docker.com/reference/cli/docker/container/cp/ | Копирование без shell в образе |
| OCI Image Spec: config | https://github.com/opencontainers/image-spec/blob/main/config.md | Состав конфигурации образа |
| Container Structure Tests | https://github.com/GoogleContainerTools/container-structure-test | Декларативный формат проверок |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление