Узлы и 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-<agent>-0<br/>демон исполнителя"]
A2["fleet-<agent>-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 секунд, после регистрации узла и после каждого отчёта узла:
- читает активных агентов из Control Plane (
GET /api/v1/agents?status=active). Если ядро недоступно, работает с последним известным списком; - помечает
offlineузлы, которые не отчитывались дольше 60 секунд; - размещает экземпляры агентов (ниже);
- для каждого размещения обеспечивает личность агента и PAT, запечатанный ключом узла;
- пересобирает желаемое состояние каждого узла. Номер поколения (
generation) растёт только при изменении, и это будит long-poll узла; - отзывает PAT, которые больше не нужны;
- сообщает фактическое состояние агентов в ядро (
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"}'
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-* на ней.
Контроллер¶
Запуск¶
| Параметр | Значение |
|---|---|
| Профиль | 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.
{
"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
Описание агента требует метку и секрет узла:
Как это работает:
- Образ.
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 |
См. также¶
- Агенты описанием — вид исполнителя
git-connector - Конфигурация исполнителя
- Установка исполнителя
- Identity агента
- Сервисы и порты
- Bootstrap