Перейти к содержанию

Узлы и fleet

Сервис fleet решает, где работают агенты, описанные видом Agent: знает машины (узлы), раскладывает на них экземпляры агентов, заводит агентам личности и доставляет им PAT, сообщает фактическое состояние в Control Plane. Статья для инженера эксплуатации, который подключает машины к платформе, и для администратора, который разбирается, почему агент не запустился. Что писать в описании агента — в статье Агенты описанием. Обоснование — TAI-ADR-0052.

Архитектура

Сервис состоит из двух процессов:

  • fleet-controller — работает рядом с Control Plane (профиль compose fleet). Хранит узлы и размещения в SQLite, читает активных агентов из Control Plane, размещает экземпляры по узлам, заводит агентам principal и PAT в IAM, пишет фактическое состояние агентов в Control Plane.
  • fleet-node — агент узла на каждой машине, которая исполняет агентов: сервер, отдельная VM, машина разработчика. Регистрируется одноразовым ключом, тянет своё желаемое состояние, отчитывается о здоровье и запускает контейнеры агентов через Docker socket proxy.
flowchart LR
    subgraph Center["Центр (compose платформы)"]
        CADDY["caddy<br/>/fleet/*"]
        FC["fleet-controller:8040<br/>SQLite /data"]
        CP["control-plane-api"]
        IAM["iam-service"]
        CADDY --> FC
        FC -- "agents.read, agents.status.write" --> CP
        FC -- "iam:agents" --> IAM
    end
    subgraph Node["Узел (любая машина с Docker)"]
        FN["fleet-node"]
        PX["docker-socket-proxy<br/>только контейнеры"]
        A1["fleet-&lt;agent&gt;-0<br/>демон исполнителя"]
        A2["fleet-&lt;agent&gt;-1"]
        FN --> PX --> A1 & A2
    end
    FN -- "HTTPS, только исходящие:<br/>join, long-poll desired, observed" --> CADDY
    A1 -- "GET /agents/me, работа" --> CP

Свойства, на которые можно опираться:

  • Соединения только исходящие. Узел сам ходит к контроллеру; контроллер никогда не вызывает узел и не держит ключей к машинам. Узел за NAT работает.
  • Команд с центра нет. Узел получает только желаемое состояние и сам приводит к нему свои контейнеры.
  • Без центра узел продолжает работать. Последнее полученное желаемое состояние хранится на диске узла; при недоступном контроллере агенты работают, ждут только изменения.
  • Значения секретов не покидают машину. Узел сообщает только имена своих секретов.
  • Образ выбирает узел. Описание агента называет вид исполнителя (claude-code, codex, skills), а какой образ его исполняет, решает конфигурация узла. Образ, присланный контроллером или описанием, узел не запускает.

Цикл сверки

Контроллер выполняет одну процедуру сверки каждые 5 секунд, после регистрации узла и после каждого отчёта узла:

  1. читает активных агентов из Control Plane (GET /api/v1/agents?status=active). Если ядро недоступно, работает с последним известным списком;
  2. помечает offline узлы, которые не отчитывались дольше 60 секунд;
  3. размещает экземпляры агентов (ниже);
  4. для каждого размещения обеспечивает личность агента и PAT, запечатанный ключом узла;
  5. пересобирает желаемое состояние каждого узла. Номер поколения (generation) растёт только при изменении, и это будит long-poll узла;
  6. отзывает PAT, которые больше не нужны;
  7. сообщает фактическое состояние агентов в ядро (PUT /api/v1/agents/{key}/status) — только если отчёт изменился.

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

Размещение и метки

Узел подходит экземпляру агента, если одновременно:

Условие Откуда у узла Откуда у агента
узел online отчёт не старше 60 с —
узел запускает вид исполнителя ключи executors в node.yaml executor.kind
у узла есть все метки labels в node.yaml placement.requires
на узле есть все секреты файлы в secretsDir placement.secrets
есть место capacity.slots и, если заданы с обеих сторон, capacity.cpus и capacity.memoryMb один слот на экземпляр, placement.resources

Сравнение меток: требование имя выполняет метка имя и любая имя=значение; требование имя=значение — только точно такая метка.

Правила выбора:

  • Размещение липкое. Экземпляр, чей узел по-прежнему подходит, остаётся на нём: агенты не перескакивают между узлами.
  • Новый экземпляр идёт на подходящий узел с наибольшим числом свободных слотов; при равенстве — по имени узла.
  • Узел ушёл в offline — его экземпляры переезжают на другой подходящий узел. Если такого нет, экземпляр остаётся за старым узлом (вернётся вместе с ним), а агент получает фазу node_unavailable.
  • Подходящего узла нет вовсе — агент в waiting_for_node с первой причиной, которая исключает все узлы, в таком порядке: no_node (нет ни одного узла online), no_executor_kind, no_label, no_secret, no_capacity.
  • Размещаются только агенты с state: running, identity.kind: agent и без placement: none; число экземпляров — placement.replicas.

Посмотреть, куда что размещено:

curl -sS https://platform.example.com/fleet/api/v1/placements \
  -H "Authorization: Bearer $FLEET_TOKEN"
{
  "items": [
    {"agentKey": "coder", "revision": 4, "nodeId": "<node-id>", "replicas": 1,
     "status": "placed", "reason": null},
    {"agentKey": "publisher", "revision": 2, "nodeId": null, "replicas": 1,
     "status": "pending", "reason": "no_secret"}
  ]
}

Регистрация узла

sequenceDiagram
    autonumber
    participant Adm as Администратор
    participant FC as fleet-controller
    participant FN as fleet-node
    Adm->>FC: join-key (CLI в контейнере или POST /api/v1/join-keys)
    FC-->>Adm: одноразовый ключ, срок действия
    Adm->>FN: ключ → файл joinKeyFile
    FN->>FN: X25519 key pair (закрытый ключ остаётся на узле)
    FN->>FC: POST /api/v1/nodes:join {ключ, name, publicKey, labels, executorKinds, capacity, secrets}
    FC-->>FN: nodeId, nodeToken (один раз; контроллер хранит только хэш)
    loop
        FN->>FC: GET /api/v1/nodes/me/desired?since=&wait=60
        FN->>FC: PUT /api/v1/nodes/me/observed (каждые observeSeconds)
    end

1. Выпустить ключ регистрации

Проще всего — командой внутри контейнера контроллера: она пишет ключ прямо в его базу и не требует токена.

docker compose --profile fleet exec fleet-controller \
  fleet-controller join-key --ttl 3600 --note "worker-1"

Или через API с токеном IAM audience fleet и scope fleet:admin:

curl -sS -X POST https://platform.example.com/fleet/api/v1/join-keys \
  -H "Authorization: Bearer $FLEET_TOKEN" -H 'Content-Type: application/json' \
  -d '{"ttlSeconds": 3600, "note": "worker-1"}'
{"joinKey": "<ключ>", "expiresAt": "2026-01-15T11:00:00Z"}

ttlSeconds — от 60 секунд до 7 суток, по умолчанию 3600. Ключ одноразовый: контроллер хранит его хэш и помечает использованным при регистрации.

2. Положить ключ на узел и запустить его

Ключ записывается в файл joinKeyFile из node.yaml, затем запускается fleet-node --config node.yaml. Узел регистрируется один раз: токен узла, его id и пара ключей сохраняются в stateDir, и при следующих запусках ключ регистрации не нужен.

Ответ регистрации Причина
201 узел зарегистрирован
401 join_key_invalid ключ неизвестен, уже использован или истёк
409 node_name_taken узел с таким name уже зарегистрирован
503 not_configured у контроллера ещё нет service account (см. Контроллер)

Конфигурация узла node.yaml

controllerUrl: https://platform.example.com/fleet
name: worker-1
labels: [claude-subscription, repos]
capacity: {slots: 4, cpus: 8, memoryMb: 16384}
executors:                            # вид исполнителя -> образ этого узла
  claude-code:
    image: agent-runner:1.4.0
    dataPath: /runner
    env:
      CP_TEST_DATABASE_URL: postgresql+psycopg://test:test@db-test:5432/test
  skills:
    image: agent-runner:1.4.0
    dataPath: /runner
    env: {RUNNER_MODE: skills}
stateDir: /var/lib/fleet-node
secretsDir: /etc/fleet-node/secrets
dockerUrl: tcp://docker-proxy:2375
network: fleet-agents
agentEnv:                             # каждому контейнеру агента; без секретов
  CONTROL_PLANE_SERVER: https://platform.example.com
  CONTROL_PLANE_IAM_URL: https://platform.example.com/iam
  CONTROL_PLANE_IAM_SCOPES: control-plane:read control-plane:write
joinKeyFile: /etc/fleet-node/join-key
observeSeconds: 20
Поле По умолчанию Значение
controllerUrl — (обязательно) адрес контроллера; за периметром платформы — https://<хост>/fleet
name — (обязательно) имя узла ^[a-z0-9][a-z0-9.-]*$, до 100 символов; уникально в fleet. Попадает в поле node фактического состояния агента
labels [] метки: имя или имя=значение (^[a-z0-9][a-z0-9.-]*(=[a-zA-Z0-9._-]+)?$)
capacity.slots — (обязательно) сколько экземпляров агентов узел держит, 0–100
capacity.cpus — CPU для агентов; учитывается, если у агента задан resources.cpus
capacity.memoryMb — память для агентов, от 64; учитывается, если у агента задан resources.memoryMb
executors — (обязательно) вид исполнителя → {image, env, dataPath}. Ключи — виды, которые узел объявляет контроллеру
executors.<вид>.image — образ; должен уже быть на машине (см. Типичные проблемы)
executors.<вид>.env {} дополнительное окружение контейнеров этого вида, без секретов
executors.<вид>.dataPath — путь внутри контейнера, куда монтируется именованный volume реплики (рабочие копии, зеркала)
stateDir — (обязательно) состояние узла: токен, ключи, последнее желаемое состояние, PAT агентов (каталог 0700, файлы 0600)
hostStateDir = stateDir тот же каталог, как его видит Docker-демон; нужен, когда узел сам работает в контейнере
secretsDir — (обязательно) каталог секретов: по файлу на секрет
hostSecretsDir = secretsDir тот же каталог глазами Docker-демона
dockerUrl unix:///var/run/docker.sock Docker Engine API; unix://… или tcp://… (socket proxy)
network — Docker-сеть контейнеров агентов
agentEnv {} окружение каждого контейнера агента: адреса Control Plane и IAM, scopes
joinKeyFile — файл одноразового ключа регистрации; после регистрации не нужен
observeSeconds 20 период отчёта и локальной сверки, 5–300
  • ${ИМЯ} в файле подставляется из окружения процесса узла; незаданная переменная — ошибка старта с перечнем имён. Строки-комментарии (# …) не проверяются и не подставляются.
  • Неизвестное поле — ошибка старта: конфигурация проверяется строго.
  • Конфигурация читается при старте. После правки меток, ёмкости или executors перезапустите fleet-node; новые значения уйдут контроллеру со следующим отчётом.

Узел в контейнере: пути хоста

PAT агента и секреты монтируются в контейнеры агентов bind-mount'ом по пути хоста. Если fleet-node сам работает в контейнере, задайте hostStateDir и hostSecretsDir так, как эти каталоги видит Docker-демон, или смонтируйте их в контейнер узла по тем же путям, что на хосте.

Секреты узла

secretsDir — каталог, где каждый секрет лежит отдельным файлом, имя файла — имя секрета из placement.secrets описаний агентов:

/etc/fleet-node/secrets/          0700
├── claude-oauth-token            0600  токен подписки кодового агента
└── github-token                  0600  токен forge для публикации веток
  • Узел сообщает контроллеру только имена файлов, подходящих под ^[a-z0-9][a-z0-9-]{0,62}$; остальные файлы (README, agent.pat) не предлагаются.
  • Список перечитывается при каждом отчёте: добавленный файл станет виден контроллеру через observeSeconds, перезапуск узла не нужен.
  • В контейнер агента попадают только секреты, которые названы в его описании, — по одному файлу, только для чтения, в /run/secrets/<имя>.
  • Файлы должны читаться пользователем, под которым работает образ исполнителя (у референсного образа — uid 10001).

Что получает контейнер агента

Где Что
/run/secrets/agent-pat PAT агента, открытый из запечатанной формы; только чтение
/run/secrets/<имя> каждый секрет из placement.secrets; только чтение
CONTROL_PLANE_AGENT_KEY ключ агента
IAM_PRINCIPAL, CONTROL_PLANE_IAM_TENANT чья это личность в IAM
FLEET_REPLICA номер экземпляра
agentEnv, executors.<вид>.env окружение из node.yaml
<dataPath> именованный volume fleet-<agent>-<replica>-data, чтение и запись; переживает перезапуски и новые ревизии

Контейнер называется fleet-<agent>-<replica>, помечен fleet.managed=1 (узел не трогает контейнеры без этой метки), запускается с init и без политики перезапуска Docker — перезапускает узел. Лимиты resources.cpus и resources.memoryMb описания становятся лимитами контейнера.

Референсный образ исполнителя (deploy/runner/docker/Dockerfile) по CONTROL_PLANE_AGENT_KEY понимает, что запущен узлом: берёт PAT из /run/secrets/agent-pat в окружение процесса (IAM_CREDENTIAL_MODE=environment), токен подписки и токен forge — из /run/secrets/claude-oauth-token и /run/secrets/github-token, если они смонтированы, и запускает демон. Всю остальную конфигурацию демон берёт из ревизии агента (GET /agents/me), зеркала репозиториев заводит сам на volume реплики.

Личность агента и доставка PAT

sequenceDiagram
    autonumber
    participant FC as fleet-controller
    participant IAM as IAM
    participant CP as Control Plane
    participant FN as fleet-node
    FC->>IAM: POST /tenants/{t}/agents (principal вида agent, владелец — контроллер)
    FC->>CP: PUT /agents/{key}/identity {issuer, iamTenantId, iamPrincipalId}
    CP-->>CP: principal CP + связка с permissions ревизии
    FC->>IAM: POST /tenants/{t}/agents/{id}/platform-access-tokens (Idempotency-Key)
    FC->>FC: seal(PAT, публичный ключ узла) — открытый PAT не хранится
    FC-->>FN: desired: identity.credential {credentialId, ciphertext, expiresAt}
    FN->>FN: открыть закрытым ключом → stateDir/agents/<key>/agent-pat (0600)
  • Principal. Каждому активному агенту вида agent — размещённому или с placement: none — контроллер заводит principal в IAM правом iam:agents и сообщает его ядру. Principal CP и связку с правами выводит само ядро из ревизии; прав выдавать права у контроллера нет. Агенты вида service контроллер не трогает — их учётку выпускает bootstrap.
  • PAT на размещение. Для каждой пары «агент — узел» свой PAT. Он шифруется публичным ключом X25519 этого узла (libsodium sealed box, X25519 + XSalsa20-Poly1305), контроллер хранит только шифротекст. Открыть его может только узел размещения.
  • Потолок PAT. Audience — всегда control-plane и те audiences из skills.audiences агента, которые перечислены в FLEET_TOKEN_AUDIENCES контроллера; потолок scope — те scopes из FLEET_TOKEN_SCOPES, audience которых PAT получает (IAM отклоняет потолок со scope, которого не допускает ни одна audience токена: 422 invalid_scope_ceiling). Scope относится к audience по префиксу (control-plane:read → control-plane); scope другой audience записывается как audience=scope, например notification-service=notifications:send. IAM сверх того не даст агенту больше, чем держит сам service account контроллера, а iam:agents не делегируется никогда.
  • Ротация. Контроллер запрашивает PAT сроком FLEET_TOKEN_TTL_SECONDS (по умолчанию 7 дней — это и потолок IAM для PAT агента, IAM_AGENT_PAT_MAX_TTL_SECONDS; больше — 422 expiry_too_long) и за 2 дня до истечения (не больше трети срока) выпускает новый. Узел получает его со следующим желаемым состоянием; контейнер агента пересоздаётся (сменился credential). Прежний PAT отзывается через 10 минут.
  • Отзыв. PAT размещения, которого больше нет (агент переехал, остановлен, выведен из оборота или пропал из списка активных), отзывается на следующей сверке. При переезде новый узел получает новый PAT.
  • Пока личность или PAT не готовы (IAM или ядро недоступны), агент остаётся в фазе pending с reason.code: identity_pending, а контроллер повторяет попытку на каждой сверке.

Узел: как приводит контейнеры к желаемому состоянию

Узел держит два цикла над одним и тем же состоянием:

  • pull — long-poll GET /api/v1/nodes/me/desired?since=<generation>&wait=60. Новое поколение сохраняется в stateDir/desired.json и сразу применяется. При сетевых ошибках — повтор через 2, 5, 10, 30, 60 секунд. Ответ 401 (контроллер больше не принимает токен узла) останавливает процесс узла;
  • report — каждые observeSeconds локальная сверка контейнеров и PUT /api/v1/nodes/me/observed. Если Docker недоступен, узел сообщает health: degraded и продолжает попытки.

Правила для контейнеров:

Событие Что делает узел
экземпляра нет создаёт и запускает контейнер
изменились образ, окружение, секреты, ресурсы, сеть или credential останавливает старый контейнер в фоне с таймаутом drainSeconds агента, удаляет, на следующем проходе создаёт новый
новая ревизия агента ничего: контейнер не трогается, исполнитель сам выходит с кодом 75 после прогона
контейнер вышел с кодом 0 или 75 запускает снова сразу
контейнер вышел с другим кодом перезапуск с паузой 5, 10, 20… до 300 секунд; три сбоя подряд — состояние crashloop
экземпляр больше не нужен (агент остановлен, выведен, переехал, replicas уменьшили) останавливает контейнер в фоне и удаляет. Если агент целиком пропал из желаемого состояния, таймаут остановки — 30 секунд
вид исполнителя не настроен, секрета нет, PAT не открывается ключом узла контейнер не создаётся; в отчёте экземпляр stopped с причиной

Остановка всегда идёт в фоне: долгий дренаж одного агента не задерживает остальные. Узел удаляет открытые PAT агентов, которых больше нет в желаемом состоянии.

drainSeconds и SIGKILL

docker stop получает ровно drainSeconds агента. Демон на SIGTERM даёт прогону в полёте те же drainSeconds и затем отменяет его (cancelled, причина drained, задача возвращается в очередь). Отмене нужно время на опрос прогона (до 30 с), поэтому при исчерпании срока SIGKILL может прийти раньше: run останется running до истечения аренды и будет закрыт восстановлением (restart_recovery).

Наблюдаемое состояние

Отчёт узла — PUT /api/v1/nodes/me/observed:

Поле Значение
appliedGeneration поколение, которое узел применил
health ok или degraded (Docker недоступен)
labels, executorKinds, capacity, secrets текущие метки, виды, ёмкость и имена секретов
replicas[] agentKey, revision, replica, state, restarts, lastExitCode, message
version версия пакета fleet на узле

Состояния экземпляра: starting, running, draining, crashloop, stopped. Из них контроллер выводит фазу агента в Control Plane: хотя бы один crashloop — фаза crash_looping с сообщением экземпляра; готовых (running) экземпляров не меньше желаемого — running; иначе pending. observedRevision — наименьшая ревизия среди работающих экземпляров.

Отказ узла и контроллера

Что случилось Что происходит
узел пропал (выключен, сеть) через 60 с без отчёта он offline; экземпляры переезжают на подходящие узлы с новыми PAT, старые PAT отзываются. Некуда переехать — node_unavailable, экземпляр ждёт возвращения узла
контроллер недоступен узлы продолжают работать по desired.json; новые ревизии, остановки и переезды ждут контроллера
Control Plane недоступен контроллеру контроллер работает с последним известным списком агентов
контроллер перезапущен узлы, размещения, личности и шифротексты PAT — в SQLite на volume fleet_data; желаемое состояние узлов пересобирается при первом запросе
узел вернулся после переезда его прежние экземпляры отсутствуют в новом желаемом состоянии — узел их останавливает

Отключённый узел продолжает исполнять

Узел без связи с контроллером не знает, что его агентов перенесли. Пока он недоступен, но работает (например, потерял только выход к контроллеру), его контейнеры продолжают брать работу со своим прежним PAT, пока контроллер не отзовёт его на следующей сверке после переезда. Если нужно гарантированно остановить машину — остановите fleet-node и контейнеры fleet-* на ней.

Контроллер

Запуск

docker compose --profile fleet up -d fleet-controller
Параметр Значение
Профиль fleet
Образ ${IMAGE_PREFIX:-taimen}/fleet-controller:${IMAGE_TAG:-local}, Dockerfile fleet/Dockerfile, контекст ${FLEET_BUILD_CONTEXT:-.} (корень: platform-auth-sdk подключён соседней папкой)
Порт 8040, на хост не публикуется
Путь в Caddy /fleet/* (префикс срезается)
Volume fleet_data → /data (SQLite fleet.sqlite)
Healthcheck GET /healthz на 127.0.0.1:8040
Лимит памяти ${FLEET_MEM_LIMIT:-128m}
Зависит от control-plane-api, iam-service (healthy)
Пользователь uid 10001

Учётка контроллера

Контроллер — сервис платформы, описанный агентом fleet-controller в пакете platform-services (identity.kind: service, placement: none, см. Агенты-сервисы):

  • в Control Plane — права agents.read и agents.status.write;
  • в IAM — audiences control-plane, iam, notification-service и потолок control-plane:read, control-plane:write, iam:agents, notifications:send.

deploy/bootstrap.py на шаге 5d выпускает этот service account и пишет secrets/fleet-iam.env (FLEET_CLIENT_ID, FLEET_CLIENT_SECRET). После этого контроллер нужно пересоздать: docker compose --profile fleet up -d fleet-controller. Пока файла нет, все маршруты, кроме /healthz, отвечают 503 not_configured.

Bootstrap также регистрирует в IAM audience fleet со scopes fleet:read и fleet:admin — для администраторских вызовов API контроллера.

Переменные окружения

Переменная Значение в compose.yml Смысл
FLEET_DATA_DIR /data каталог базы SQLite
FLEET_CONTROL_PLANE_URL http://control-plane-api:8000 Control Plane внутри сети
FLEET_IAM_URL http://iam-service:8010 IAM внутри сети
FLEET_IAM_ISSUER ${TAIMEN_PUBLIC_URL}/iam публичный issuer IAM; с ним привязываются личности агентов и проверяются административные токены
FLEET_IAM_TENANT ${IAM_TENANT_ID} IAM tenant
FLEET_JWKS_URL http://iam-service:8010/.well-known/jwks.json JWKS для проверки административных токенов
FLEET_CLIENT_ID, FLEET_CLIENT_SECRET из secrets/fleet-iam.env service account контроллера
FLEET_AUDIENCE не задана, по умолчанию fleet audience административных токенов
FLEET_TOKEN_AUDIENCES control-plane notification-service какие audiences может получить PAT агента (через пробел)
FLEET_TOKEN_SCOPES control-plane:read control-plane:write notification-service=notifications:send потолок scope PAT агентов (через пробел); scope не своей по префиксу audience — audience=scope
FLEET_TOKEN_TTL_SECONDS не задана, по умолчанию 7 дней срок PAT агента, не больше IAM_AGENT_PAT_MAX_TTL_SECONDS

В .env задаются FLEET_MEM_LIMIT, FLEET_BUILD_CONTEXT и VOLUME_FLEET_DATA (см. Переменные окружения).

API контроллера

Публично — под https://<хост>/fleet. Контракт — fleet/openapi/fleet.json.

Метод и путь Кто Что делает
GET /healthz все {"status": "ok"}
POST /api/v1/join-keys токен audience fleet, fleet:admin выпустить одноразовый ключ регистрации
GET /api/v1/nodes fleet:read или fleet:admin узлы: метки, виды, ёмкость, имена секретов, health, status (online/offline), lastSeenAt, version
GET /api/v1/placements fleet:read или fleet:admin размещения агентов: status (placed, pending, stopped), reason
POST /api/v1/nodes:join одноразовый ключ в теле регистрация узла
GET /api/v1/nodes/me/desired токен узла long-poll желаемого состояния (since, wait ≤ 60)
PUT /api/v1/nodes/me/observed токен узла отчёт узла, 204

Ошибки административных маршрутов: 401 invalid_token (токен не прошёл проверку), 403 insufficient_scope. Маршруты узла: 401 node_token_invalid.

curl -sS https://platform.example.com/fleet/api/v1/nodes \
  -H "Authorization: Bearer $FLEET_TOKEN"
{
  "items": [
    {
      "id": "<node-id>",
      "name": "worker-1",
      "labels": ["claude-subscription", "repos"],
      "executorKinds": ["claude-code", "skills"],
      "capacity": {"slots": 4, "cpus": 8.0, "memoryMb": 16384},
      "secrets": ["claude-oauth-token", "github-token"],
      "health": "ok",
      "status": "online",
      "lastSeenAt": "2026-01-15T10:00:20Z",
      "version": "0.1.0"
    }
  ]
}

Удаления узла и перевыпуска его токена в API нет. Узел, который больше не нужен, достаточно выключить: через 60 секунд он offline, и его агенты переедут.

Развёртывание узла

Узлу нужны Docker, исходящий HTTPS к контроллеру и каталог секретов. Референсные раскладки суперпроекта — deploy/fleet/laptop/ (машина разработчика с кодовыми агентами), deploy/fleet/staging/ (узел рядом со стеком платформы, см. ниже) и общий пример deploy/fleet/node.example.yaml. Compose узла поднимает:

Сервис Зачем
agent-runner-image (или git-connector-image) только сборка образа исполнителя (deploy/runner/docker/Dockerfile, для коннектора — integrations/selfdev/Dockerfile); контейнеры агентов создаёт узел
docker-proxy tecnativa/docker-socket-proxy: разрешены CONTAINERS, VOLUMES, POST; закрыты EXEC, IMAGES, NETWORKS, BUILD
fleet-node образ fleet/Dockerfile, команда fleet-node --config /etc/fleet-node/node.yaml, uid 10001; каталоги состояния и секретов смонтированы по путям хоста
служебные сервисы агентов например одноразовые тестовые базы на tmpfs в сети network узла; адреса передаются агентам через executors.<вид>.env

Порядок первого запуска:

# на машине узла
install -d -m 0700 /var/lib/fleet-node /etc/fleet-node/secrets
install -m 0600 /dev/null /etc/fleet-node/secrets/claude-oauth-token   # и заполнить
echo '<ключ регистрации>' > /etc/fleet-node/join-key
docker compose -f <compose узла> up -d --build
docker compose -f <compose узла> logs -f fleet-node

В журнале узла появится joined the fleet as <node-id>, затем started fleet-<agent>-0 для размещённых агентов. Проверка с центра — GET /fleet/api/v1/nodes и GET /api/v1/agents/{key}/status.

Узел стенда и агент git-connector

Источник наблюдений git — такой же агент, как кодовый исполнитель, только с видом исполнителя git-connector (см. Агенты описанием). Отдельного сервиса коннектора в compose платформы нет: что наблюдать, задаёт описание агента, а процесс запускает узел fleet. Удобно держать для него отдельный небольшой узел рядом со стеком платформы — тогда наблюдения идут, даже когда машины разработчиков выключены.

# node.yaml узла стенда
controllerUrl: ${FLEET_CONTROLLER_URL}
name: staging
labels: [staging]
capacity: {slots: 1, cpus: 1, memoryMb: 512}
executors:
  git-connector:
    image: taimen/git-connector:local
    dataPath: /data
stateDir: ${FLEET_HOME}/fleet
secretsDir: ${FLEET_HOME}/fleet-secrets
dockerUrl: tcp://docker-proxy:2375
network: fleet-staging-agents
agentEnv:
  CONTROL_PLANE_SERVER: ${CONTROL_PLANE_SERVER}
  CONTROL_PLANE_IAM_URL: ${CONTROL_PLANE_IAM_URL}
  CONTROL_PLANE_IAM_SCOPES: control-plane:read control-plane:write
joinKeyFile: ${FLEET_HOME}/fleet/join-key
observeSeconds: 20

Описание агента требует метку и секрет узла:

placement:
  requires: [staging]
  secrets: [github-token]
  resources: {cpus: 1, memoryMb: 384}

Как это работает:

  • Образ. taimen/git-connector собирается из integrations/selfdev/Dockerfile (контекст — корень суперпроекта), пользователь 10001. Его entrypoint видит CONTROL_PLANE_AGENT_KEY от узла и запускает коннектор в режиме агента.
  • Личность. PAT агента — /run/secrets/agent-pat (выпускает и доставляет контроллер, как любому агенту); entrypoint передаёт его обмену IAM в режиме environment, scope по умолчанию control-plane:read control-plane:write.
  • Секрет forge. /run/secrets/github-token (файл github-token в secretsDir узла) — токен на чтение репозиториев и прогонов CI; entrypoint настраивает его credential helper'ом git. Путь можно переопределить GITHUB_TOKEN_FILE.
  • Конфигурация. repositories, observe, интервал и прочее коннектор берёт из executor.params своей ревизии (GET /agents/me), workspace — из раздела work описания. Новая ревизия — выход с кодом 75, узел запускает процесс заново.
  • Курсор. Том реплики fleet-<agent>-<replica>-data монтируется в dataPath (/data); образ и описание узла договариваются об одном значении — переменная образа CONNECTOR_DATA_DIR=/data. Там лежат курсор .connector-state.json (последние отданные ревизии) и клоны репозиториев repos/: перезапуск и новая ревизия не повторяют наблюдений.
  • Ёмкость. Одного слота достаточно; лимит памяти контейнера — из resources.memoryMb описания.

Первый запуск узла стенда:

install -d -m 700 -o 10001 -g 10001 /opt/taimen/fleet-node/fleet /opt/taimen/fleet-node/fleet-secrets
install -m 600 -o 10001 -g 10001 <файл токена> /opt/taimen/fleet-node/fleet-secrets/github-token
docker compose --profile fleet exec fleet-controller fleet-controller join-key --ttl 3600 \
  > /opt/taimen/fleet-node/fleet/join-key && chown 10001:10001 /opt/taimen/fleet-node/fleet/join-key
docker compose -f deploy/fleet/staging/compose.yml up -d --build

FLEET_HOME (по умолчанию /opt/taimen/fleet-node), FLEET_CONTROLLER_URL, CONTROL_PLANE_SERVER и CONTROL_PLANE_IAM_URL задаются окружением compose узла. Агент включается правкой описания (state: running) и применением пакета.

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

Если раньше коннектор работал отдельным процессом со своим курсором, положите его файл в том реплики (fleet-<agent>-0-data:/data/.connector-state.json) до первого запуска агента: иначе первый проход заново выдаст наблюдения прогонов CI. Коммиты и реестр ADR ядро и так не повторяет.

Код коннектора обновляется пересборкой образа: узел сравнивает строку image, поэтому работающий контейнер остаётся на прежнем образе, пока его не пересоздать (см. Типичные проблемы).

Типичные проблемы

Симптом Причина и решение
все маршруты контроллера отвечают 503 not_configured нет secrets/fleet-iam.env: выполнить bootstrap (шаг 5d) и пересоздать fleet-controller
узел падает при старте: node is not joined and no join key file is configured нет токена в stateDir и нет файла joinKeyFile — выпустить ключ и положить в файл
401 join_key_invalid ключ истёк или уже использован — выпустить новый
409 node_name_taken имя занято (в том числе прежней регистрацией этой машины с потерянным stateDir) — задать новое name
узел завершился: the controller no longer accepts this node's credential токен узла не принят (401) — зарегистрировать узел заново с новым именем
unset environment variables … при старте узла в node.yaml есть ${ИМЯ}, которой нет в окружении процесса узла
агент waiting_for_node, no_executor_kind ни один узел online не объявляет executor.kind агента — добавить вид в executors узла
no_label / no_secret нет метки из placement.requires или файла из placement.secrets в secretsDir. Имя файла должно совпадать с именем секрета
no_capacity заняты слоты или не хватает cpus/memoryMb — увеличить capacity узла или уменьшить resources агента
агент pending, identity_pending контроллер не смог завести principal или выпустить PAT: смотреть журнал fleet-controller. Частые причины — IAM недоступен, у service account нет iam:agents, IAM ограничивает срок PAT агента меньше запрашиваемого контроллером (422 expiry_too_long)
экземпляр stopped, credential cannot be opened with this node's key stateDir с ключами узла пересоздан после регистрации — зарегистрировать узел заново
экземпляр stopped, start failed: … Docker не создал контейнер; чаще всего образа нет на машине — socket proxy не даёт узлу скачивать образы, соберите или загрузите образ заранее
агент crash_looping исполнитель падает при старте; причина — в message отчёта и в docker logs fleet-<agent>-<replica>. Выход с кодом 2 — описание агента не исполнимо на этом образе
новый образ под тем же тегом не подхватывается узел сравнивает строку image, а не содержимое: пересборка под тем же тегом не пересоздаёт контейнер, а перезапуск после выхода 75 стартует прежний контейнер. Поменяйте тег в executors.<вид>.image и перезапустите fleet-node
контейнер агента не видит PAT или секрет узел работает в контейнере без hostStateDir/hostSecretsDir, либо файлы не читаются uid образа исполнителя
правка node.yaml не видна контроллеру конфигурация читается при старте — перезапустить fleet-node
git-connector в waiting_for_node, no_label / no_secret нет узла с меткой из placement.requires или файла github-token в secretsDir узла
контейнер git-connector выходит с кодом 2 params описания не исполнимы (нет репозиториев, ciRuns без ciRepository, повтор имён) или principal не связан с агентом — причина в docker logs

См. также