Перейти к основному содержимому

Docker

Работа Docker выполняется на машине агента. Install и Build — actions; containers, networks и volumes — записи ресурсов с владельцами.

Установка и сборка​

report, err := activity.Activity(ctx, agent, dockerlib.Install())

build, err := activity.Activity(ctx, agent, dockerlib.Build(dockerlib.BuildSpec{
Context: machine.Workspace() + "/src/app",
Tags: []string{"registry.example/app:sha"},
Push: true,
Target: "release",
BuildArgs: map[string]string{"VERSION": version},
CacheFrom: []string{"type=registry,ref=registry.example/app:cache"},
CacheTo: []string{"type=registry,ref=registry.example/app:cache,mode=max"},
}))

Install использует существующий engine или ставит его package manager'ом машины, затем публикует capability docker. Build использует Buildx/BuildKit машины и возвращает digest и tags. Пути должны быть действительны на машине; machine.Workspace() — общий same-path каталог.

Внешний установщик ограничен тремя минутами, включая дочерние загрузки; при ошибке или таймауте используется пакет дистрибутива. APT ждёт освобождения блокировки другим установщиком до 120 секунд, в том числе при автоматических обновлениях свежей машины. Таймаут передаётся через временный конфиг одного вызова и действует для внешнего установщика и fallback. Существующие настройки APT сохраняются; временный файл удаляется при выходе. Работающие менеджеры пакетов и их lock-файлы остаются нетронутыми.

Install отправляет heartbeat сразу и затем каждые 15 секунд во время установки engine и публикации capability. Таймаут heartbeat должен быть больше этого интервала (например, одна минута), а общий таймаут установки — длиннее. При потере отчёта о завершении activity может повториться после таймаута heartbeat; уже установленный engine используется повторно.

Container​

web := dockerlib.Container(ctx, agent, dockerlib.Spec{
Name: "web",
Config: &container.Config{Image: "nginx:alpine"},
Host: &container.HostConfig{
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyAlways},
},
Scrape: "http://localhost:9187/metrics",
ScrapeEvery: 10 * time.Second,
}, pipeline.WithLabels(map[string]string{"role": "frontend"}))

id := web.Ready(ctx).Id

Config и Host — нативные Go-типы Docker. Декларация идемпотентна по имени: отсутствующий контейнер создаётся и запускается, остановленный — запускается. Scrape просит executor читать Prometheus endpoint и атрибутировать samples записи контейнера. Endpoint читается с запуска контейнера каждые ScrapeEvery — 15 секунд, если не задано, и не чаще раза в секунду — своим шагом, отдельно от 30-секундного такта, который снимает docker stats. Samples уходят как прочитаны, каждая точка — со временем самого scrape, серия заканчивается вместе с экспортёром: rate() по scraped counter видит часы источника. Семейства сохраняют форму, в которой их хранит Prometheus, метки — без изменений:

СемействоУходит как
countercounter
gauge, untypedgauge
histogram<name>_bucket{le} (кумулятивно, вместе с le="+Inf"), <name>_sum, <name>_count — counters
gauge histogram<name>_bucket{le}, <name>_gsum, <name>_gcount — gauges
summary<name>{quantile} — gauges; <name>_sum, <name>_count — counters

Поэтому histogram_quantile(0.99, rate(sql_service_latency_bucket[1m])) работает по scraped экспортёру как по любому Prometheus. Scrape просит текстовую exposition; native (sparse) histograms есть только в protobuf, так что уходят классические бакеты экспортёра, а native-only гистограмма уходит своими _sum, _count и бакетом +Inf. NaN или бесконечность — квантиль summary до первого наблюдения — не показание и пропускается. Недоступный endpoint — предупреждение в логе записи раз на каждую новую ошибку, а не на каждом шаге. Собственные docker.container.cpu.percent и docker.container.memory.bytes такта — gauges его момента.

Логи и последние слова контейнера​

Executor читает лог каждого контейнера потоком с момента запуска: строка попадает в логи записи примерно через секунду, с stream=stdout|stderr. Оборванный поток — перезапуск демона или контейнера — подхватывается с последней отправленной строки, без повторов и пропусков. Когда контейнер останавливается сам, остановка попадает в историю записи (container-exited с кодом выхода) в момент, когда кончается его лог.

Уровень строки — тот, что она называет сама, а не поток, из которого пришла: множество инструментов пишут обычный прогресс в stderr (PostgreSQL пишет туда всё), а pytest печатает провалы в stdout:

СтрокаОткуда уровень
JSON-объектполе level, severity, lvl или L (zap, logrus, slog); числа pino (50 и выше — error, 40 — warn)
logfmtlevel=, lvl=, severity=
текстведущий E1027 glog/klog и E261027 CockroachDB; среди первых шести слов — уровень в скобках ([ERROR], [Warning] / [Note] MySQL) или заглавными, отдельно или перед двоеточием (ERROR:, LOG: PostgreSQL)

fatal, panic и critical записываются как error; notice, note, log и system — как info. Слово строчными внутри фразы (query error:) — текст, а не уровень. Строка без уровня — info.

Перед удалением контейнер выводится из работы: последний сэмпл stats и scrape, затем docker stop с паузой 10 секунд — SIGTERM, чтобы процесс сбросил буферы (последние спаны и метрики OpenTelemetry SDK, последние строки лога базы), — и всё, что он написал после последней отправленной строки, читается и отправляется. Вывод из работы ограничен 20 секундами; молчащий демон не держит teardown.

Телеметрия изнутри контейнера​

Контейнер и job получают в окружение OTEL_EXPORTER_OTLP_ENDPOINT (и OTEL_EXPORTER_OTLP_INSECURE=true), если спека не задаёт endpoint сама. Он указывает на локальный OTLP-приёмник исполнителя — на docker bridge, а для контейнера в host network на loopback; приёмник ставит на всё пришедшее штамп рана и пересылает в Graphene. Инструмент, говорящий на OpenTelemetry (тестовый набор, stroppy), пишет в логи, метрики и трейсы рана без настройки; адрес сервера и токен в контейнер не попадают. См. наблюдаемость.

Job​

Job выполняет контейнер до завершения — одноразовая пара к Container. Это действие, а не ресурс: ничем не владеет, на машине после него остаётся только файл лога.

report, err := activity.Activity(ctx, agent, dockerlib.Job(dockerlib.JobSpec{
Name: "infra-tests",
Config: &container.Config{
Image: "ghcr.io/astral-sh/uv:python3.12-bookworm-slim",
Cmd: []string{"uv", "run", "/work/test_infra.py"},
Env: []string{"FIO_SIZE=1G"},
},
Host: &container.HostConfig{
NetworkMode: "host",
Mounts: []mount.Mount{{Type: mount.TypeBind, Source: "/opt/demo", Target: "/work"}},
},
}), activity.WithGuarantee(activity.AtMostOnce))

Спека — та же пара типов Docker, что у Container; своего словаря job не добавляет. Файлы попадают в контейнер по-докеровски — bind mount каталога на машине. Положить их туда — файловым ресурсом (file.FromEmbed везёт скрипт внутри бинаря пайплайна), а прочитать то, что job записал в mount, — через artifact.FromAgentFile.

Поле отчётаСмысл
ExitCodeкод выхода контейнера
Stdoutтолько stdout, первые 256 КиБ — для машиночитаемого ответа
Tailконец слитого stdout+stderr, последние 16 КиБ — сводка или ошибка
LogPathполный слитый вывод на машине — отдать артефакту

Ненулевой код выхода — исход, а не провал activity: job отработал, что значит его статус — решает пайплайн. Activity падает только тогда, когда контейнер вообще не удалось запустить; если нет образа, ошибка несёт причину неудавшегося pull.

Каждая строка вывода — ещё и лог-запись рана (graphenectl logs run <id>) с именем job и потоком stream, из которого она пришла. Stdout и stderr собирают строки раздельно, поэтому недописанная строка одного потока не вклеивается в другой; файл лога и Tail остаются слитым выводом в том виде, в каком его отдал Docker. Каждая строка записывается с уровнем, который она называет, — так же, как лог контейнера. У выхода своей строки нет: ненулевой код выхода добавляет одну запись-предупреждение job <name> exited with status <n>.

Job заменяет оставшийся контейнер с тем же именем и удаляет свой контейнер на любом пути выхода, включая отмену; AutoRemove сбрасывается, чтобы код выхода не терялся из-за удаления самим Docker. Допустимо ли повторное выполнение — знание вызывающего: job, который грузит данные или шлёт сообщение, хочет AtMostOnce.

В локальных тестах у job нет исхода по умолчанию — то, что печатает контейнер, и есть данные теста:

dockertest.OnJob(world, "runner-1", "infra-tests").
Return(dockerlib.JobReport{Stdout: "{\"ok\":true}\n"}, nil).Once()

Network и volume​

net := dockerlib.Network(ctx, agent, dockerlib.NetworkSpec{
Name: "private",
Options: network.CreateOptions{Driver: "bridge"},
})

data := dockerlib.Volume(ctx, agent, volume.CreateOptions{Name: "db-data"})

Оба используют нативные option structs Docker и сходятся по имени. Finalize удаляет Docker object; данные volume поэтому умирают вместе с владельцем. Чтобы ресурс пережил ран, передайте его stand.

Если агент принадлежит рану, Docker-ресурсы становятся его детьми. На attached agent они остаются детьми рана: чужая запись не может отвечать за ваши декларации.

Локальные тесты​

Моделирование зависимостей и проверка пайплайна описаны в руководстве по локальным тестам.