AppProxy 워커 모드¶
이 문서는 Continuum Router가 Backend.AI AppProxy 추론 워커로 동작하는 방식을 명세합니다. 추론 워커는 AppProxy 코디네이터가 제어하는 데이터 플레인 노드로, 다수의 LLM 서빙 컨테이너를 하나의 OpenAI 호환 주소 뒤로 집약합니다.
워커 모드의 표준 레퍼런스로서, 어느 엔지니어(또는 에이전트)라도 프로토콜을 다시 유도하지 않고 각 구성 요소를 검증하거나 확장할 수 있을 만큼 정밀하게 작성되었습니다.
워커는 두 가지 프런트엔드 모드로 제공됩니다.
- 레거시 와일드카드/포트 워커(appproxy v3, §§1–8): 프런트엔드 슬롯당 서킷 하나, Host 헤더 기반 인그레스, 서킷별 JWT, 복제본에서 자동 발견하는 모델 이름.
- ROUTER 워커(§9): 클러스터 전체를 위한 단일 바인딩 주소, 모델 이름 라우팅, API 키 기반 모델 가시성.
이 문서는 라우터(워커) 측을 명세합니다. ROUTER 모드의 Backend.AI 측 동반 문서는 BEP-1053: ROUTER Frontend Mode로, AppProxy 코디네이터와 Manager 변경 사항(FrontendMode.ROUTER 값, 슬롯을 생략하는 서킷, /v2/routers/* 관리 API, 원하는 상태 영속화, 키-모델-서비스 매핑 표면)을 명세합니다. 이 문서가 코디네이터나 Manager 동작을 참조할 때는 BEP-1053이 권위 있는 출처입니다.
1. 동기¶
Backend.AI의 AppProxy는 투명한 L4/L7 프록시 워커로 모델 서비스의 앞단을 맡습니다. 서킷(circuit) 하나가 프런트엔드 슬롯 하나(포트 또는 와일드카드 서브도메인)에 매핑되어 그 뒤의 서빙 컨테이너로 바이트를 전달하고, traffic_ratio에 따라 복제본 간 로드 밸런싱을 수행합니다.
Continuum Router는 이 추론 데이터 플레인을 대신 맡아 그 위에 L7 수준의 LLM 인지 동작을 더할 수 있습니다.
- 하나의
/v1표면 뒤에서 이루어지는 모델 이름 라우팅과 엔드포인트 간 집약 - 프로토콜 변환(OpenAI ↔ Anthropic ↔ Gemini), 스마트 라우팅, prefix/KV 캐시 인지 라우팅, 분리형 prefill/decode
- 폴백 체인, 재시도, 응답 캐싱, Files API
워커 모드에서는 AppProxy 코디네이터가 표준 워커를 다루는 방식 그대로(등록, 하트비트, 서킷 할당) 런타임에 Continuum Router의 백엔드 집합을 제어하고, Continuum Router는 각 서킷을 LLM을 인지하며 헬스 체크와 가중치가 적용되는 백엔드 풀로 구현합니다.
슬롯 기반 구조(배포당 프런트엔드 주소 하나)는 클러스터 수준에서 다음과 같은 공백을 남기는데, ROUTER 모드(§9)는 바로 이 공백을 메우기 위해 존재합니다.
- 단일 표면의 부재. 배포마다 기본 URL이 다릅니다. 모든 OpenAI 호환 SDK가 기대하는 방식, 곧 주소 하나를 들고 모델을 이름으로 고르는 사용이 불가능합니다.
- 모델 추상화의 부재. 사용자는 모델이 아니라 배포를 주소로 지정합니다. 하나의 모델 이름을 여러 배포 엔드포인트로 뒷받침하는 1급 수단(리비전 A/B 테스트, 자원 그룹 간 부하 분산)이 없습니다.
- 키 거버넌스의 부재. 배포 액세스 토큰은 배포 하나에 대한 접근만 부여합니다. 소비자가 클러스터 전체에서 어떤 모델을 보고 쓸 수 있는지 범위를 정하는 API 키 표면이 없습니다.
2. 배경: AppProxy 아키텍처¶
AppProxy는 세 부분으로 구성됩니다(Backend.AI src/ai/backend/appproxy/).
- 코디네이터: 컨트롤 플레인입니다. PostgreSQL(워커, 서킷, 엔드포인트, 토큰의 원천 데이터 저장소)을 기반으로 하는 aiohttp REST 서버로, 서킷을 워커에 스케줄링하고 라우팅 변경을 밀어냅니다.
- 워커: 데이터 플레인입니다. 코디네이터에 등록하고 하트비트를 보내며, 자신에게 할당된 서킷의 트래픽을 프록시합니다.
- Common: 공유 타입, 이벤트 버스, 설정을 담는 공통 모듈입니다.
핵심 엔티티¶
| 엔티티 | 의미 |
|---|---|
| Worker | 프록시 노드. 고유한 authority로 식별됩니다(HA 복제본 간에는 nodes 카운터로 공유). frontend_mode(wildcard/port/router), protocol(http/h2/tcp/…), hostname, api_port를 가지며, 슬롯 모드에서는 슬롯 공간(port_range 또는 wildcard_domain)도 가집니다. status ∈ ALIVE/LOST/TERMINATED. |
| Endpoint | 추론 배포(모델 서비스). id == DeploymentID. 서킷과 1:1 관계이며, 선택적 health_check_config를 가집니다. Manager 측에서는 복제본, 리비전, 스케일링 상태를 갖는 EndpointRow입니다. |
| Circuit | 워커로 푸시되는 중심 라우팅 객체. 프런트엔드(슬롯 또는, ROUTER 모드에서는 워커 자체)를 백엔드 대상 목록(route_info)에 바인딩합니다. app_mode ∈ interactive/inference. 추론의 경우 endpoint_id와 runtime_variant를 가집니다. |
| RouteInfo | 서킷 내부의 백엔드 대상 하나(복제본 세션 하나): kernel_host, kernel_port, protocol, traffic_ratio, session_id, route_id. |
| Slot | WILDCARD/PORT 모드가 사용하는 프런트엔드 용량의 단위(범위 내 포트 하나 또는 서브도메인 하나). 코디네이터가 슬롯을 할당하고 워커는 이를 따릅니다. ROUTER 모드에는 슬롯이 없습니다(§9). |
ROUTER 모드는 코디네이터/Manager 측 엔티티 세 가지를 추가합니다(BEP-1053에서 명세, 워커 측 소비는 §9).
| 엔티티 | 의미 |
|---|---|
| 퍼블리케이션(Publication) | 배포를 모델 이름으로 접근 가능하게 만드는 Manager 정의 엔티티. 기본 모델 이름(과 선택적 별칭)을 하나의 authority 위에서 하나 이상의 엔드포인트(각각 분배 ratio 보유)에 바인딩하며 control_mode를 가집니다. '모델 게시/게시 해제' = 퍼블리케이션 생성/삭제(§9.2). |
| 모델 API 키 | 라우터에 제시하는 소비자 자격 증명(sk-…). 모델 단위 허용 목록을 가지며, 라우터는 SHA-256 해시만 보관합니다(§9.7, §9.8). |
| Authority / 노드 | authority는 논리적 라우터 정체성입니다(HA 복제본이 공유). 퍼블리케이션과 키는 authority 단위로 범위가 정해집니다. 노드는 authority 아래의 라우터 프로세스 하나로, 생존 추적용 임시 node_id로 식별됩니다(§9.5). |
프런트엔드 모드¶
| 모드 | 서킷별 프런트엔드 | 슬롯 관리 |
|---|---|---|
wildcard |
wildcard_domain 아래의 서브도메인 |
코디네이터가 서브도메인 할당 |
port |
port_range에서 뽑은 포트 |
코디네이터가 포트 할당 |
router (BEP-1053) |
없음: 모든 서킷이 워커의 단일 주소를 공유하고, 요청의 model 이름이 주소 지정 키가 됩니다 |
전부 생략 |
전송 (코디네이터와 워커의 통신 방식)¶
서로 다른 세 가지 채널이 있습니다.
- 워커 → 코디네이터: HTTP REST. 등록, 하트비트, 등록 해제, 초기 서킷 풀(pull)에 사용합니다. 공유
X-BackendAI-Token: <api_secret>헤더로 인증합니다. - 코디네이터 → 워커: Redis Pub/Sub(레거시 모드): 서킷 생성/라우트 업데이트/제거를
events_all-appproxy채널로 브로드캐스트하고, 생성 시에는 워커가 ack를 보냅니다. - 코디네이터 → Traefik: etcd(Traefik 모드): 코디네이터가 Traefik 동적 설정을 etcd에 기록하고 Traefik이 프록시합니다. 이 모드에서는 워커에 서킷 단위 신호가 전달되지 않습니다.
모드는 코디네이터 전역 설정(proxy_coordinator.enable_traefik)입니다. 이 구분이 설계 결정 중 하나로 이어집니다(§4와 §5.5 참고).
ROUTER 모드는 추가 채널을 사용하지 않습니다. 키-모델-서비스 매핑은 같은 두 워커 채널을 타고 이동합니다. Redis Pub/Sub 이벤트로 브로드캐스트되고, 풀 경로에서는 REST 원하는 상태 스냅샷으로 제공됩니다(§9.9). 코디네이터→라우터 푸시 연결은 없습니다. 코디네이터는 워커에 먼저 접속하지 않으므로 워커는 NAT나 클러스터 인그레스 뒤에서도 동작합니다.
3. 개념 매핑¶
추론 경로는 Continuum Router의 기존 모델에 거의 1:1로 매핑됩니다(이 표는 레거시 와일드카드 워커의 매핑이고, ROUTER 모드의 대응 표는 §9.2에 있습니다).
| AppProxy | Continuum Router |
|---|---|
| Worker (authority, frontend_mode, 슬롯 공간) | 워커로 등록된 라우터 인스턴스 |
| Endpoint (추론 모델 서비스) | 모델 (해당 모델을 서빙하는 백엔드 집합) |
Circuit (app_mode=inference, route_info[]) |
모델 → Vec<BackendConfig> 매핑 |
RouteInfo {kernel_host, kernel_port, traffic_ratio} |
BackendConfig {url: http://host:port, weight ∝ ratio, models: [model]} |
| Slot (서브도메인/포트) | 인그레스 주소 지정 키 (§5.2 참고) |
| RoutePool 가중 랜덤 + 헬스 체크 | WeightedRoundRobin + HealthChecker |
AppProxy 추론 서킷은 'traffic_ratio로 가중치를 둔 한 모델의 N개 복제본'입니다. 이는 구성원이 models = [<model>]을 공유하고 복제본별 weight를 갖는 Continuum Router 백엔드 그룹과 정확히 같은 구조입니다. 따라서 변환은 기계적이며 공유 데이터 플레인이 선택, 헬스 필터링, 재시도, 폴백을 제공합니다.
4. 설계 결정¶
다섯 가지 결정이 이 통합의 골격을 이룹니다.
- 외부 어댑터가 아닌 네이티브 모듈. 통합은 Cargo 피처(§8) 뒤에서 Continuum Router 내부에 위치합니다. 서킷은 공유 핫 리로드
config_sender채널로 데이터 플레인에 전달됩니다. - 와일드카드 프로토콜 호환. 레거시 와일드카드 워커(§§5–8)는 와일드카드 추론 워커 프로토콜을 사용합니다. 모델 이름은 각 복제본의
/v1/models를 자동 발견해 얻습니다. - 슬롯을 존중하는 와일드카드 인그레스(레거시). 레거시 워커는 와일드카드 슬롯 공간을 등록하고 각 요청을 HTTP
Host(서브도메인)로 서킷에 해석하며, 캐치올(catch-all) 호스트에서는 모델 이름 집약을 사용할 수 있습니다. 코디네이터가 할당한 슬롯은 형식적인 잔재가 아니라 실제 인그레스 주소입니다(§5.2 참고). - 두 전송 채널 모두 지원. 풀 기반 리컨사일(reconcile)을 기본으로 하고(어떤 코디네이터 모드에서도 동작), Redis Pub/Sub 이벤트 오버레이를 더합니다(레거시 모드 지원 + 낮은 지연). 풀은 누락된 이벤트의 안전망 역할도 합니다.
- 두 모드를 모두 지원. ROUTER 프런트엔드 모드(§9)는 슬롯 모델을 사용하지 않으며 코디네이터 측 BEP-1053 지원이 필요하고 서킷별 JWT 대신 모델 API 키를 사용합니다. 두 모드는 별도 피처로 제공되며 코디네이터 클라이언트/이벤트 코덱/리컨사일-적용 코어를 하나로 공유합니다(§8).
5. 아키텍처¶
5.1 구성 요소 개요¶
wildcard DNS: *.models.example.com ──► continuum-router host
│
external client │ single socket (e.g. :443)
POST https://ep-abc.models.example.com/v1/chat/completions
│ Host: ep-abc.models.example.com
▼
┌────────────────────────────────────────────────────────────────┐
│ continuum-router (one worker, frontend_mode = wildcard) │
│ │
│ appproxy module (feature = "appproxy") │
│ ├── coordinator client (REST: register/heartbeat/pull) │
│ ├── worker service (lifecycle loops) ── circuit registry │
│ ├── reconcile (circuit → BackendConfig → config_sender) ──┐ │
│ ├── events (Redis Pub/Sub subscribe + ack) │ │
│ └── ingress middleware (Host subdomain → model) ──┐ │ │
│ ▼ ▼ │
│ existing pipeline: model router → backend pool ◄── hot reload │
│ (health, retry, fallback) │
└────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
kernel1:port kernel2:port (LLM serving containers = backends)
통합은 appproxy 모듈과 인그레스 미들웨어로 구성됩니다. 서킷 상태는 기존 핫 리로드 메커니즘을 거쳐 백엔드 상태가 되고, 요청 라우팅은 기존 모델 라우터를 재사용합니다.
5.2 등록과 슬롯 모델¶
라우터는 와일드카드 추론 워커로 등록합니다. '단일 주소'와 '슬롯 존중'은 서로 충돌하지 않습니다. 와일드카드 도메인 자체가 단일 주소이고, 각 서킷의 서브도메인은 같은 소켓을 가리키는 가상 주소입니다.
등록 시 슬롯 공간을 알립니다.
frontend_mode = wildcard
wildcard_domain = ".models.example.com"
wildcard_traffic_port = 443 # the router's /v1 socket
hostname = <router host>
available_slots = -1 # wildcard → unbounded; never runs out
accepted_traffics = [inference]
코디네이터는 해당 도메인 안에서 추론 서킷마다 서브도메인을 하나씩 할당하고, Circuit.get_endpoint_url()은 https://ep-abc.models.example.com/을 생성합니다. 매니저는 이 엔드포인트 URL을 사용자에게 전달합니다. 운영자는 와일드카드 DNS(*.models.example.com → router)를 한 번만 설정하면 됩니다.
PORT 모드(서킷당 포트 하나)는 라우터가 리스닝 소켓을 동적으로 열고 닫아야 하므로 지원하지 않습니다(§11 참고). 외부에 서비스되는 추론에서는 와일드카드 + TLS가 일반적입니다.
5.3 서킷 → 백엔드 변환¶
각 추론 서킷은 RouteInfo 복제본마다 BackendConfig 하나로 변환되어 기존 런타임 변경 경로를 통해 적용됩니다.
for each circuit assigned to this authority:
model = discover_model(circuit) # from a replica's /v1/models, keyed by endpoint_id
for each route in circuit.route_info:
BackendConfig {
name: "appproxy-<circuit_id>-r<route_id>",
backend_type: Generic, # OpenAI-compatible; Vllm if known
url: "http://{route.kernel_host}:{route.kernel_port}",
weight: weight_from(route.traffic_ratio),
models: [model], # what find_backends_for_model matches
..Default
}
적용 단계는 관리자 API의 패턴(src/admin_config/backend_api.rs)을 그대로 재사용합니다.
let _guard = config_modification_lock().write().await; // serialise with admin API
let cfg = state.current_config(); // re-read under lock
let mut new_cfg = (*cfg).clone();
reconcile new_cfg.backends so that the set of appproxy-* backends
equals the desired set derived from the current circuits;
state.config_sender.send(Arc::new(new_cfg)); // drives hot reload
이어서 HotReloadService가 이전/새 설정을 비교해 새 백엔드를 추가하고, 제거된 백엔드를 점진적으로 드레인하며, 헬스 체커를 동기화하고, 모델 캐시를 무효화합니다. 백엔드 풀 코드는 전혀 건드리지 않습니다. 이 모듈이 소유한 백엔드는 appproxy- 접두사로 네임스페이스가 분리되어 있어, 리컨사일은 자신의 항목만 추가/제거할 뿐 정적으로 설정된 백엔드를 결코 건드리지 않습니다.
기존의 두 가지 특성이 이 방식과 잘 맞아떨어집니다.
- 런타임 설정 변경은 메모리에만 적용됩니다(디스크에 기록되지 않음). 코디네이터가 데이터의 원천이고, 라우터는 재시작 시 초기 풀로 다시 동기화합니다. 이는 제약이 아니라 의도된 동작입니다.
- 타입 지정(typed) 백엔드 풀은 핫 리로드되지 않지만 여기서는 무관합니다. 서빙 컨테이너는 URL 기반 풀로 라우팅되는 일반 OpenAI 호환 HTTP 백엔드이기 때문입니다.
5.4 인그레스 해석 (Host/서브도메인 → 모델)¶
새 Axum 미들웨어가 요청에서 대상 서킷/모델을 해석합니다.
Host헤더를 읽고, 설정된wildcard_domain접미사를 제거해 서브도메인을 얻습니다.- 서브도메인을 메모리 내 서킷 레지스트리(워커 서비스가 소유하며 리컨사일/이벤트 시 갱신)에서 조회합니다 → 서킷 → 정식(canonical) 모델.
IngressTarget { circuit_id, model }요청 확장(extension)을 삽입합니다.- 비공개 추론 서킷(
open_to_public == false)의 경우Authorization: Bearer <jwt>를 검증합니다(jwt_secret을 사용한 HS256, 디코딩된id가 서킷 id와 일치해야 함). AppProxy 워커 인증과 동일한 방식입니다.
핸들러는 공유 읽기 지점에서 바디의 model 필드보다 주입된 모델을 우선합니다. 이후 요청은 select_backend_with_retry로 들어가며 선택 로직이 각 백엔드의 models 목록과 모델 이름을 대조해 해석합니다.
와일드카드 도메인 자체(또는 설정된 집약 호스트)로 들어온 요청은 서브도메인 범위 지정을 건너뛰고 모든 서킷에 걸쳐 일반 모델 이름 라우팅을 사용합니다. 이것이 엔드포인트 간 집약 표면입니다.
폴백 참여 (범위 제한 폴백)¶
등록된 서킷은 fallback.fallback_chains에 참여합니다. 복제본이 모두 다운된 서킷(route_info가 비어 있어 살아 있는 백엔드가 없는 상태)으로 요청이 해석되더라도, 인그레스 미들웨어는 요청을 해당 서킷의 정식 모델에 고정해 일반 파이프라인으로 넘깁니다. 이어서 select_backend_with_retry가 그 모델의 백엔드를 찾지 못하면 FallbackService가 이어받아, 서킷의 모델을 키로 하는 체인(예: vllm-real-poc → gpt-4o-mini)에 도달합니다. '배포가 다운되면 트래픽이 OpenAI로 간다'는 동작입니다. 서킷별 인증(open_to_public, 베어러 토큰, allowed_client_ips)은 이 폴스루(fall-through) 이전에 적용되므로, 폴백 경로가 인증 우회 통로가 되는 일은 없습니다.
폴스루의 범위는 제한되어 있어 등록된 서킷에만 적용됩니다. 알 수 없는 서브도메인(레지스트리에 서킷이 없는 경우)으로의 요청은 여전히 404 endpoint_not_found입니다. 알 수 없는 서브도메인은 모델 이름이 아니라 서킷 식별자이므로, 모델 레지스트리/폴백 경로로 진입하지 않습니다.
5.5 업데이트 전송¶
워커는 서로 보완하는 두 메커니즘으로 서킷 집합을 최신으로 유지합니다.
- 풀 리컨사일(기본, 항상 켜짐). 등록 후 워커는
/api/worker/{id}/circuits를GET해 리컨사일하고, 이후 타이머(reconcile_interval)로 반복합니다. 이것만으로도 Traefik 모드(코디네이터가 etcd에 기록하고 워커에 신호를 보내지 않는 모드)에서는 완전히 올바르게 동작하며, 누락된 이벤트의 안전망이 됩니다. - Redis Pub/Sub 오버레이(레거시 모드 + 낮은 지연). 워커는
events_all-appproxy를 구독해 생성/라우트 업데이트/제거 델타를 약 1초 안에 적용하고, 생성에는 ack를 보냅니다. 레거시 모드에서는 필수입니다. 코디네이터가 서킷 생성(initialize_legacy_circuit) 중 워커의 ack를 최대 15초까지 기다리며 블로킹하고, 타임아웃 시E10001 Proxy worker not responding을 발생시키기 때문입니다. 라우트 업데이트와 제거는 fire-and-forget입니다.
네 가지 서킷 이벤트가 모두 브로드캐스트(Pub/Sub)이므로, 워커에는 SUBSCRIBE(인바운드 3종)와 PUBLISH(ack 1종)만 있으면 됩니다. 서킷 라이프사이클에 Redis Streams나 컨슈머 그룹은 필요하지 않습니다.
6. 와이어 프로토콜 레퍼런스¶
6.1 코디네이터 REST API (워커 범위)¶
기본 URL은 coordinator_url입니다. 모든 요청에는 다음 헤더가 포함됩니다.
X-BackendAI-Token: <api_secret>X-BackendAI-RequestID: <uuid4>
| 메서드와 경로 | 용도 | 비고 |
|---|---|---|
PUT /api/worker |
등록/업서트 (authority 기준 멱등) |
{id, slots, …} 반환. HA: 재등록 시 nodes 증가 |
PATCH /api/worker/{id} |
하트비트 | 바디 없음. heartbeat_period마다 전송(기본 10초). 코디네이터 타임아웃 30초 |
DELETE /api/worker/{id} |
등록 해제 | nodes 감소. 마지막 노드 → LOST |
GET /api/worker/{id}/circuits |
전체 서킷 스냅샷 | {circuits: [SerializableCircuit, …]} |
GET /api/circuit/{id} |
서킷 1개 조회 | |
DELETE /api/circuit/{id} |
서킷 제거 |
와일드카드 모드의 등록 요청 바디(WorkerRequestModel)는 다음과 같습니다.
{
"authority": "continuum-router-1",
"frontend_mode": "wildcard",
"protocol": "http",
"hostname": "router.example.com",
"tls_listen": false,
"tls_advertised": true,
"api_port": 8080,
"accepted_traffics": ["inference"],
"filtered_apps_only": false,
"app_filters": [],
"traefik_last_used_marker_path": null,
"wildcard_domain": ".models.example.com",
"wildcard_traffic_port": 443
}
응답에는 할당된 id(워커 UUID, 이후 호출을 위해 캐시)와 계산된 slots가 포함됩니다.
6.2 서킷과 라우트 데이터 모델¶
SerializableCircuit(REST 스냅샷이 반환하고 이벤트에 포함되는 JSON 형태):
| 필드 | 타입 | 비고 |
|---|---|---|
id |
UUID | |
app |
string | 추론에서는 "" |
protocol |
enum | http/grpc/h2/tcp/preopen/vnc/rdp |
worker |
UUID | 호스팅 워커 |
app_mode |
enum | interactive/inference |
frontend_mode |
enum | wildcard/port/router |
port |
int? | frontend_mode == port일 때만 설정. ROUTER 모드에서는 null |
subdomain |
string? | frontend_mode == wildcard일 때만 설정. ROUTER 모드에서는 null |
endpoint_id |
UUID? | 추론 전용. ROUTER 모드에서는 모델 매핑의 조인 키(§9.6) |
runtime_variant |
string? | 추론 전용 |
open_to_public |
bool | true면 인증 생략(레거시). ROUTER 모드에서는 무시(§9.7) |
allowed_client_ips |
string? | 쉼표로 구분한 CIDR 목록(레거시 인그레스용. ROUTER 모드는 키 단위 허용 목록 사용, §9.7) |
route_info |
RouteInfo[] | 백엔드 대상 목록 |
session_ids |
UUID[] | |
envs |
object | |
created_at / updated_at |
datetime | ISO-8601 |
RouteInfo:
| 필드 | 타입 | 비고 |
|---|---|---|
route_id |
UUID? | 같은 host:port에서 route_id가 바뀌면 커널 교체를 의미 |
session_id |
UUID | 필수 |
session_name |
string? | |
kernel_host |
string? | None → localhost |
kernel_port |
int | 1–65535 |
protocol |
enum | |
traffic_ratio |
float | 기본값 1.0. 백엔드 weight로 매핑 |
Rust serde 관련 참고 사항:
- 입력은 kebab-case와 snake_case 별칭을 모두 허용하고(예:
route-id와route_id), 출력은 snake_case로 내보냅니다. extra = "ignore"의미론: 알 수 없는 필드를 허용해(#[serde(default)]/ 알 수 없는 필드 무시) 코디네이터 측 필드 추가가 파싱을 깨뜨리지 않게 합니다.
6.3 Redis 이벤트 엔벨로프¶
네 가지 서킷 이벤트는 모두 events_all-appproxy로 PUBLISH되는 JSON 객체로 브로드캐스트됩니다.
{
"name": "<event_name>",
"source": "<agent-id>",
"args": "<base64(msgpack(args_tuple))>",
"metadata": "{\"request_id\":null,\"user\":null}"
}
args는 msgpack 배열의 base64입니다. 이 이벤트들에서 배열 원소는 문자열뿐입니다. msgpack ext 타입도 없고, msgpack 계층의 UUID/datetime/enum 인코딩도 없습니다(이런 값은 내부 JSON 안에 미리 인코딩되어 있음). Rust 구현에서는 JSON 객체 →argsbase64 디코딩 → 문자열로 이루어진 msgpack 배열 → 원소별 JSON 파싱만 처리하면 됩니다.metadata는 정확히request_id와user만 갖는 JSON 문자열입니다(키를 추가하면 코디네이터의 파서가 예외를 일으킵니다).{"request_id":null,"user":null}을 내보내거나 인바운드request_id를 그대로 돌려보냅니다.- 워커가 내보내는 이벤트의
source는"appproxy-worker"입니다. 라우팅에는 사용되지 않으며, 워커는 인바운드 이벤트를target_worker_authority로 필터링합니다.
이벤트 페이로드:
name |
방향 | args 튜플 |
|---|---|---|
appproxy_circuit_created_event |
인바운드 | (authority, circuits_json), circuits_json = SerializableCircuit의 JSON 배열 |
appproxy_circuit_removed_event |
인바운드 | (authority, circuits_json) |
appproxy_circuit_route_updated_event |
인바운드 | (authority, circuit_json, routes_json) (단일 서킷 + RouteInfo[]) |
appproxy_worker_circuit_added_event |
아웃바운드 (ack) | (authority, circuits_json), 인바운드 circuits_json을 그대로 되돌려 보냄 |
ROUTER 모드는 같은 엔벨로프에 이벤트 다섯 종을 추가합니다(§9.10 참고).
구체적인 ack 예시(authority = "worker01", circuits_json = "[]"): msgpack(["worker01","[]"]) = 92 a8 worker01 a2 5b 5d → base64 kqh3b3JrZXIwMaJbXQ==이며, 이를 name = appproxy_worker_circuit_added_event, source = appproxy-worker로 events_all-appproxy에 PUBLISH합니다.
이벤트 버스용 Redis DB 인덱스는 배포 환경의 'stream' 역할 DB이며 반드시 설정해야 합니다(redis_url / DB 선택자). 코디네이터의 Redis 프로필과 맞는지 확인합니다.
7. 설정¶
라우터 설정의 선택적 섹션은 appproxy 피처로 게이트됩니다(ROUTER 모드 섹션 appproxy_router는 §9.11에서 명세).
appproxy:
enabled: true
coordinator_url: "http://coordinator:10200"
api_secret: "${APPPROXY_API_SECRET}" # X-BackendAI-Token
jwt_secret: "${APPPROXY_JWT_SECRET}" # HS256 circuit/bearer verification
redis_url: "redis://valkey:6379/4" # event bus DB (stream role)
authority: "continuum-router-1"
hostname: "router.example.com"
frontend_mode: "wildcard"
wildcard_domain: ".models.example.com"
aggregation_hosts: [] # extra Hosts that skip subdomain scoping
wildcard_traffic_port: 443
tls_advertised: true
heartbeat_period: "10s"
reconcile_interval: "15s"
events_enabled: true # Redis Pub/Sub overlay on/off
시크릿은 backends[].api_key와 동일하게 ${ENV_VAR} 보간을 지원합니다.
aggregation_hosts는 선택 사항이고 기본값은 빈 목록입니다. 와일드카드 정점(apex) 도메인(wildcard_domain에서 앞의 점을 뺀 것, 예: models.example.com)은 암묵적으로 항상 집약 표면이므로, 추가 베니티 호스트나 집약 호스트 이름이 필요할 때만 여기에 나열합니다. Host가 이 목록(또는 정점 도메인)과 일치하는 요청은 서킷별 서브도메인 범위 지정을 건너뛰고 모든 서킷에 걸쳐 일반 모델 이름 라우팅을 사용합니다(§5.4).
8. 모듈 구조¶
워커는 공유 코어와 모드별 레이어로 나뉘며, wildcard/port 워커와 ROUTER 모드가 코디네이터 클라이언트, 이벤트 코덱, 조정-적용 구현을 공유합니다.
src/appproxy/ # 최상위: 안정적인 공개 표면 재내보내기
├── mod.rs # crate::appproxy::* 재내보내기; 안정적인 events:: 파사드
├── events.rs # appproxy::events::* 공개 재내보내기
├── common/ # feature = "appproxy-common" (공유 코어, 레거시 의존성 없음)
│ ├── mod.rs # 모듈 수준 문서; ROUTER 모드 재사용 기반
│ ├── client.rs # CoordinatorClient; REST 등록/하트비트/풀; X-BackendAI-Token
│ ├── config.rs # AppProxyWorkerConfig 기반
│ ├── events.rs # 봉투 코덱(base64+msgpack), EventHandler 트레이트, 재연결 구독자
│ ├── reconcile.rs # 서킷 → BackendConfig → config_sender (잠금 하에)
│ ├── status.rs # /status 관리 핸들러
│ └── types.rs # SerializableCircuit, RouteInfo, 열거형 (이중 별칭 serde)
├── legacy/ # feature = "appproxy-legacy" (appproxy v3, common 필요)
│ ├── mod.rs
│ ├── events.rs # LegacyEventHandler: 레지스트리 기반 서킷 생성/업데이트/제거 + ack
│ ├── ingress.rs # Host 서브도메인 → IngressTarget 미들웨어; apply_ingress_model_override
│ ├── jwt.rs # verify_circuit_token (HS256); JwtError
│ ├── registry.rs # AppProxyRegistry: 서브도메인 키 서킷 저장소
│ └── worker.rs # run_worker: 등록 → 풀 → 하트비트 → 조정 라이프사이클
└── router/ # feature = "appproxy-router" (ROUTER 모드)
├── mod.rs
├── config.rs # RouterWorkerConfig (슬롯/JWT 필드 없음; 시크릿 리댁팅 Debug)
├── client.rs # RouterCoordinatorClient: ROUTER 등록 + router-config 풀
├── events.rs # ROUTER 모드 Redis 이벤트 페이로드 (모델/키 업데이트·제거 + applied-ack)
├── keys.rs # RouterKeyStore: 해시 인증, 가시성, 노드별 속도 제한, IP 허용 목록
├── overlay.rs # ROUTER 이벤트 오버레이: 구독자 연결 + 노드별 applied-ack
├── reconcile.rs # RouterReconcileState: 서킷 × 퍼블리케이션 → 백엔드 (+ 키 집합)
├── types.rs # ModelPublication, ModelApiKey, RouterRegisterRequest (와이어 타입)
└── worker.rs # run_router_worker 라이프사이클; RouterKeyStore 등록
common/ 트리는 ROUTER 모드의 문서화된 재사용 기반입니다. appproxy-common 아래에서 독립적으로 빌드되며, 레거시 전용 심볼(jsonwebtoken, 서브도메인 레지스트리, Host 인그레스 레이어)에 의존하지 않습니다. ROUTER 모드는 동일한 공유 구독자에 자체 EventHandler를 연결합니다.
연결 지점:
Cargo.toml: 네 가지 피처가 있습니다.appproxy-common = ["dep:redis", "dep:deadpool-redis", "dep:rmp-serde"]은 자체 완결형 공유 코어이고(rmp-serde는 msgpack 엔벨로프 코덱이 공유되므로 여기 위치), 공통 리컨사일은 중립적인 상시 컴파일 설정 변경 잠금을 사용하고 ROUTER IP 필터링은 중립적인 허용 목록 헬퍼를 사용하므로 어느 경로도 Admin API 피처를 요구하지 않습니다.appproxy-legacy = ["appproxy-common", "dep:jsonwebtoken"]은 appproxy v3 워커(jsonwebtoken은 레거시 전용)이며,appproxy-router = ["appproxy-common"]은 ROUTER 프런트엔드 모드이고,appproxy = ["appproxy-legacy"]는 와일드카드/포트 워커를 선택하는 별칭입니다.full에는 포함되지 않지만 공식 릴리스 바이너리는 Release 워크플로우에서appproxy-router를 켠 채로 빌드됩니다.appproxy-legacy는 소스 빌드 옵트인이고 ROUTER 모드는appproxy_router설정 섹션이 있어야 활성화됩니다. CI는 기본 피처를 끈appproxy-common그래프를 검사하여 Admin 의존성이 다시 유입되는 것을 방지합니다.src/lib.rs:pub mod appproxy;.src/core/config/models/config.rs:pub appproxy: Option<AppProxyWorkerConfig>와pub appproxy_router: Option<RouterWorkerConfig>.src/server/mod.rs::build_router: 워커/status라우트를 등록하고(두 피처 공통), 해석된 모델이 속도 제한기에 보이도록 레거시 인그레스 미들웨어를 속도 제한 레이어 바로 바깥에 삽입합니다.src/server/serve.rs: 핫 리로드 블록 다음에서cfg.appproxy.enabled(레거시)일 때appproxy::run_worker(...)를,cfg.appproxy_router.enabled(ROUTER)일 때appproxy::run_router_worker(...)를 스폰합니다.src/http/middleware/auth.rs: 동적 API 인증 미들웨어가 ROUTER 모드 활성 시 ROUTER 키 저장소를 참조합니다(§9.7).src/models/handlers.rs:/v1/models가 게시된 모델 집합에 대해 가시성 필터링을 수행합니다(§9.7).
9. ROUTER 워커 (appproxy-router 모드)¶
ROUTER 워커는 §§5–8에서 설명한 레거시 와일드카드 워커의 appproxy-router 피처 대응체입니다. 레거시 워커가 Host 헤더 기반 서브도메인 인그레스와 서킷별 JWT 인증을 사용하는 반면, ROUTER 워커는 단일 바인딩 주소에서 모델 이름 라우팅과 키별 모델 가시성을 제공합니다. 서브도메인 디스패치도, 서킷별 JWT도, 슬롯도 없습니다.
이는 와일드카드 설계에서 의도적으로 벗어난 것입니다. 서킷마다 와일드카드 서브도메인을 두면 배포당 주소 하나라는 구조가 재현되고, 모든 배포에 DNS/TLS 와일드카드가 강제됩니다. ROUTER 모드에서는 코디네이터가 슬롯 관리를 전부 생략하고, Circuit.get_endpoint_url()은 모든 서킷에 대해 워커의 단일 공개 기본 URL을 반환하며, Manager가 사용자에게 전달하는 것은 배포별 URL 대신 (기본 URL, 모델 이름, API 키) 세 값의 조합입니다.
코디네이터/Manager 측(FrontendMode.ROUTER 열거형 값, 슬롯을 생략하는 등록, /v2/routers/* 관리 API, router-config 이벤트, 스냅샷 엔드포인트)은 BEP-1053에서 명세합니다. 이보다 오래된 코디네이터에는 ROUTER 워커가 등록할 수 없습니다(§9.13).
9.1 ROUTER 모드가 제공하는 것¶
- 클러스터 전체를 위한 단일 OpenAI 호환
/v1엔드포인트. 클라이언트는 URL이 아니라 요청 바디에서 모델을 이름으로 선택합니다. - 게시된 모델은 하나 이상의 배포 엔드포인트(각각 복제본 보유)의 모음입니다. 2단계 계층 라우팅(§9.2).
- API 키 기반 가시성: 클라이언트가 나열하고 호출할 수 있는 모델은 요청 헤더의 API 키가 결정합니다(§9.7).
- 고가용성: 라우터 워커 여러 대가 로드 밸런서 뒤에서 하나의 authority로 등록할 수 있고, 코디네이터가 이들을 동기화 상태로 유지합니다(§9.5).
- 라우터의 데이터 플레인은 Backend.AI 복제본에 헬스 체크, 가중 선택, 재시도, 폴백 체인, 프로토콜 변환을 적용합니다.
9.2 개념: 퍼블리케이션, 키, 2단계 라우팅¶
중심 개념은 게시된 모델입니다.
API key ──(visibility)──► model ──(level 1)──► deployment endpoint(s) ──(level 2)──► replica session
- 퍼블리케이션은 Backend.AI Manager에서 정의한 모델 이름(예:
llama-4-chat)을 하나 이상의 배포 엔드포인트에 바인딩합니다. 하나의 모델 이름 뒤에 여러 엔드포인트를 두면 리비전 간 A/B 테스트와 자원 그룹 간 부하 분산이 가능하고, 각 매핑은 분배ratio를 가집니다. 퍼블리케이션은 별칭(동일하게 라우팅되는 추가 이름)도 선언할 수 있어, 하나의 배포를 여러 모델 이름(예:gpt-4와gpt-4-internal)으로 노출할 수 있습니다. - 각 배포 엔드포인트는 하나 이상의 복제본 세션(서킷의
route_info)을 가지며traffic_ratio로 가중됩니다. ratio는 음이 아닌 상대 가중치입니다(모델의 엔드포인트 전체에서 합이 1.0일 필요 없음).ratio = 0은 엔드포인트를 드레인합니다. 매핑은 유지되지만 새 트래픽은 받지 않습니다(§9.6).- 모델 API 키는 보고 쓸 수 있는 모델 이름 집합을 가집니다. 기본 이름과 별칭은 독립적으로 게이트됩니다. 허용 목록이 비어 있으면 무제한 키로, 게시된 어떤 모델이든 사용할 수 있습니다(§9.7).
이 계층은 라우터의 기존 엔티티에 거의 1:1로 매핑됩니다.
| Backend.AI / AppProxy | Continuum Router |
|---|---|
| ROUTER 워커 (authority) | 라우터 인스턴스(또는 HA 복제본 집합) |
| 퍼블리케이션 (기본 이름 + 별칭) | BackendConfig.models에 나열되는 이름들. 각 이름이 라우팅과 가시성의 단위 |
| 배포 엔드포인트 ↔ 서킷 | 백엔드 그룹: appproxy-<circuit_id>-*로 명명된 백엔드 집합 |
| RouteInfo (복제본 세션) | BackendConfig {url: http://kernel_host:kernel_port, weight, models: [names…]} 하나 |
매핑 ratio × 라우트 traffic_ratio |
BackendConfig.weight (합성, §9.6) |
| 모델 API 키 | 키별 모델 허용 목록을 갖는 메모리 내 RouterKeyStore 항목 |
| RoutePool 가중 랜덤 + 헬스 체크 | SelectionStrategy + HealthChecker |
| (레거시) 슬롯 | (없음): 모델 이름이 주소 지정 키 |
개념적으로 선택은 계층적입니다. 모델을 고르고, 배포 엔드포인트를 고르고, 복제본을 고릅니다. 내부적으로 라우터는 이를 평탄화된 복제본 풀 위의 가중 선택 한 번으로 구현하고, 두 계층은 가중치에 합성됩니다.
- 1단계, 모델 선택. 요청의
model필드를find_backends_for_model로 조회하면models목록에 그 이름을 가진 모든 백엔드, 곧 그 모델에 매핑된 모든 배포 엔드포인트의 모든 복제본의 합집합이 반환됩니다. 리컨사일이 각 백엔드의models목록에 퍼블리케이션의 기본 이름과 모든 별칭을 채우므로(§9.6), 어떤 별칭으로 요청해도 별도의 별칭 조회 없이 같은 백엔드 집합으로 해석됩니다. API 키의 허용 목록은 이 단계에서 적용됩니다. 키 집합 밖의 이름은403으로 거부되고, 그 키의/v1/models에도 나타나지 않습니다. - 2단계, 복제본 선택. 설정된
SelectionStrategy(가중 라운드 로빈, 최소 지연, prefix 인지 해시 등)가 헬스 체커가 비정상 복제본을 제외한 후보 집합에서 백엔드 하나를 고릅니다. 각 후보의 가중치는endpoint_ratio × route.traffic_ratio의 합성이므로(§9.6), 트래픽이 엔드포인트 사이에서도, 각 엔드포인트 안의 복제본 사이에서도 올바르게 분배됩니다.
평탄화는 의도된 설계이고, 다중 엔드포인트 모델을 저렴하게 만드는 요인입니다.
- 지연·캐시 인지 전략이 어느 배포 엔드포인트 소속이든 한 모델의 모든 복제본을 한 번에 관찰합니다.
- 매핑된 엔드포인트 하나가 복제본을 모두 잃으면 그 가중치 몫이 사라지고 트래픽이 살아남은 엔드포인트로 이동합니다. A/B 암(arm)이 컨트롤 플레인 왕복 없이 우아하게 축소됩니다.
- 매핑된 모든 엔드포인트의 모든 복제본이 사라졌을 때에야 모델에 백엔드가 없어지고, 그 시점에
fallback.fallback_chains가 이어받을 수 있습니다(§9.7).
9.3 구성 요소 개요¶
Backend.AI Manager ──(key–model–service mappings)──► AppProxy Coordinator
│ user creates deployments, │ persists desired state
│ defines models & API keys │ (PostgreSQL); schedules
▼ │ circuits; broadcasts
deployment endpoints (replica sessions) │ events (Redis Pub/Sub)
│
external client │
POST https://router.example.com/v1/chat/completions │
Authorization: Bearer <api-key> │
{"model": "llama-4-chat", …} │
│ ▼
▼ single socket (host:port)
┌─────────────────────────────────────────────────────────────────────────┐
│ continuum-router (one worker, frontend_mode = router) │
│ │
│ appproxy::router module (feature = "appproxy-router") │
│ ├── coordinator client (REST: register/heartbeat/pull snapshots) │
│ ├── worker service (lifecycle loops) ── RouterReconcileState │
│ ├── reconcile (circuits × publications → BackendConfig → config_sender)│
│ ├── events overlay (Redis Pub/Sub subscribe + applied-ack) │
│ └── RouterKeyStore (key hashes, visibility, per-node rate limits) │
│ ▼ │
│ existing pipeline: API-key gate → model router → backend pool ◄─ hot │
│ (health, retry, fallback) reload │
└─────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
replica1:port replica2:port replica3:port (LLM serving containers)
인그레스 미들웨어도, Host 기반 범위 지정도 없습니다. 서킷과 매핑 상태는 기존 핫 리로드 메커니즘을 거쳐 백엔드와 키 상태가 되고, 요청 라우팅은 기존 모델 라우터를 재사용하며, API 키 게이트는 동적 인증 미들웨어에서 동작합니다(§9.7).
9.4 라이프사이클¶
api_keys.mode = blocking고정: 트래픽이 들어오기 전, 시작 시점에 전역 API 키 게이트를 블로킹 모드로 강제 전환합니다.api_keys섹션이 없으면 빈 상태로 생성하고 기존 운영자 설정 키는 그대로 유지합니다. 초기 키 집합이 로드되기 전 짧은 시간 동안 인증되지 않은 요청이 데이터 플레인에 도달하는 것을 막는 조치입니다.- 등록 (
PUT /api/worker): 프로세스별 임시node_idUUID를 담아 코디네이터에 등록합니다. 인증은 레거시 워커와 동일한X-BackendAI-Token을 사용하며, 일시적 실패 시 셧다운 신호가 올 때까지 지수 백오프로 재시도합니다. 응답의id가 이후 모든 REST 호출에 사용되는worker_id입니다. - 초기 풀 및 리컨사일 (
pull_state): 서킷 스냅샷과router-config스냅샷(모델 퍼블리케이션 + API 키 해시)을 한 번에 가져옵니다.RouterReconcileState가 서킷 × 퍼블리케이션을 결합해 원하는appproxy-*백엔드 집합을 도출하고 핫 리로드 채널로 적용하며, 데이터 플레인 게이트가 참조하는 메모리 내RouterKeyStore에 키 집합을 미러링합니다. - 하트비트 루프 (
heartbeat_period, 기본10s마다):node_id를 담아PATCH /api/worker/{id}를 전송합니다. 코디네이터는node_id로 노드별 생존 항목을 갱신하며, 이는 공유authority수준의worker_id와 별개입니다. - 리비전 기반 리컨사일 루프 (
reconcile_interval, 기본15s마다): 마지막으로 적용된known_revision을 넘겨 다시 풀합니다.304응답이면 적용을 건너뛰고,200응답이면 새 데이터와 새 리비전을 받습니다. 놓친 이벤트를 보완하는 안전망입니다. - 이벤트 오버레이 (레거시 워커와 동일한 Redis Pub/Sub 구독자 코덱 사용): 모델 업데이트·제거, 키 업데이트·제거의 인바운드 ROUTER 모드 이벤트 네 종이 각각 풀 경로와 동일한 리컨사일 뮤텍스를 통해 처리되어 일관성 없는 인터리빙을 방지합니다.
events_enabled: false로 비활성화할 수 있으며, 풀 루프만으로도 완전히 올바르게 동작합니다. - 정상 종료: 루프가 멈추고 이벤트 구독자 태스크가 중단된 다음,
DELETE /api/worker/{id}로 노드를 등록 해제합니다.
9.5 등록, HA, 노드별 정체성¶
라우터는 단일 공개 주소에 슬롯 공간 없이 ROUTER 모드 추론 워커로 등록합니다.
frontend_mode = router
hostname = <advertised host> # 예: router.example.com (LB가 있으면 LB)
api_port = 8080 # 라우터의 소켓 (데이터 플레인 + 워커 API)
traffic_port = 443 # 공개 데이터 플레인 포트. 기본값은 api_port
node_id = <uuid4 per process> # 노드별 생존 추적 정체성
accepted_traffics = [inference]
port_range = null # 슬롯 공간 없음
wildcard_domain = null
코디네이터는 슬롯 계산을 전부 생략합니다. ROUTER 워커의 슬롯 용량은 무제한이라(와일드카드처럼 항상 유효한 추론 후보) 서킷은 port도 subdomain도 없이 생성됩니다. 배포의 서킷이 이 워커에 도달하는 방식은 코디네이터의 소관입니다(BEP-1053). 스케일링 그룹은 ScalingGroupProxyTarget으로 코디네이터를 선택하고, 여러 스케일링 그룹이 이 authority를 호스팅하는 코디네이터를 가리키게 하면 라우터 하나가 하나의 모델 표면 뒤에서 여러 스케일링 그룹을 서빙합니다.
고가용성과 로드 밸런싱¶
라우터 워커 여러 대가 같은 논리 라우터를 서빙할 수 있습니다. 이들은 같은 authority로 등록하며, 보통 공개 hostname이 가리키는 외부 L4 로드 밸런서 뒤에 놓입니다. 코디네이터가 authority의 모든 노드에 같은 서킷 집합과 같은 키-모델 매핑을 전달하므로 어느 노드든 어떤 요청이든 처리할 수 있습니다. 노드 간 동기화는 별도 서브시스템이 아니라 구조에 내재합니다. 모든 노드가 코디네이터 측 워커 정체성 하나를 공유하고, Pub/Sub 이벤트가 모든 노드로 퍼지며, authority 범위의 풀 스냅샷이 각 노드를 동일한 원하는 상태로 수렴시킵니다(늦게 합류하거나 재시작한 노드는 등록 시의 전체 풀로 따라잡음). 서로 다른 authority 여러 개를 한 코디네이터에 등록해 배포를 독립 라우터들로 분할할 수도 있습니다.
노드별 정체성과 생존 추적. 각 워커 프로세스는 시작 시 임시 node_id(UUID4)를 생성해 등록과 모든 하트비트에서 반복 전송합니다. 코디네이터는 이를 노드별 생존 추적(TTL 만료)과 노드별 헬스 노출에 사용하고, 죽은 노드에서 트래픽을 돌리는 일은 LB/VIP의 책임으로 남습니다. node_id는 설정 적용 ack에도 포함되어(§9.10) 코디네이터의 엄격 폐기 모드가 노드별 ack를 셀 수 있게 합니다. 영속화되지 않으므로 재시작하면 새 node_id가 생기고 이전 것은 만료됩니다.
HA에서의 선택 전략 동작. 각 노드가 같은 백엔드 집합 위에서 자체 메모리 내 선택을 수행하므로, 결정적 전략(예: prefix 인지 해싱)은 모든 노드에서 독립적으로 같은 복제본을 고릅니다. 노드 간 조율 없이 캐시 친화성이 유지됩니다. 지역 관측 신호에 의존하는 상태 기반 전략(예: 최소 지연)은 각 노드가 자기 관점에서 결정합니다. 허용 가능하지만 전역 조율은 아닙니다. 키별 rate_limit도 마찬가지로 노드 단위로 적용됩니다(§9.7).
9.6 리컨사일: 서킷 × 퍼블리케이션 → 백엔드¶
이 authority에 할당된 각 추론 서킷은 모델 퍼블리케이션과 조인되어 RouteInfo 복제본마다 BackendConfig 하나로 변환됩니다.
for each inference circuit with an endpoint_id:
referencing = publications whose mappings name circuit.endpoint_id
if referencing is empty: continue # 추적은 하되 백엔드는 없음
models = union of all_names(p) for p in referencing # 기본 이름 + 별칭, 중복 제거
endpoint_ratio = max(sanitize(m.ratio) for every referencing mapping of this endpoint)
for each route in circuit.route_info:
weight = clamp(round(100 × endpoint_ratio × sanitize(route.traffic_ratio)), 1, 1000)
if the composed ratio is 0: skip # 드레인된 복제본 (백엔드 미생성)
BackendConfig {
name: "appproxy-<circuit_id>-r<route_id | index>",
backend_type: runtime_variant == "vllm"이면 Vllm, 아니면 Generic,
url: "http://{route.kernel_host | localhost}:{route.kernel_port}",
weight,
models, # find_backends_for_model이 대조하는 목록
..Default
}
운영상 중요한 순서대로 동작을 정리하면 다음과 같습니다.
- 보류되는 서킷과 순서 무관 조인. 엔드포인트가 아직 어떤 퍼블리케이션에도 매핑되지 않은 서킷은 추적은 되지만 백엔드를 만들지 않고, 퍼블리케이션이 이름을 붙이는 순간 라우팅 가능해집니다. 반대로 이 authority에 할당되지 않은 서킷을 참조하는 퍼블리케이션은 서킷이 도착할 때까지 보류됩니다. 코디네이터가 보통 순서를 맞춰 주지만 리컨사일은 순서에 의존하지 않습니다.
- 빈 퍼블리케이션은 폴백으로 해석.
mappings가 빈 게시 모델(예: 마지막 엔드포인트가 파괴된 후. Manager는 정리는 하지만 자동 게시 해제는 하지 않음, BEP-1053)은 백엔드를 만들지 않고 전 복제본 다운 모델처럼 동작합니다.fallback_chains가 있으면 그리로, 없으면 모델 사용 불가로 해석됩니다(§9.7). 이름은 운영자가 게시 해제할 때까지/v1/models에 남습니다. - 공유 엔드포인트는 최대 ratio 적용. 여러 퍼블리케이션이 같은 엔드포인트를 매핑하면 유효
endpoint_ratio는 그중 가장 큰 정제(sanitize)된 ratio입니다. 드레인 의미론은 유지하면서(전부 0이면 모든 복제본 드레인) 한 퍼블리케이션의 드레인이 같은 물리 복제본으로 트래픽을 보내는 다른 퍼블리케이션을 굶기지 않게 합니다. - ratio 정제. 코디네이터에서 온 비유한(non-finite)·음수
ratio/traffic_ratio는 전파하지 않고0(드레인)으로 강제합니다. 그대로 두면 최대 가중치 트래픽 자석이 되거나 float 변환에서 패닉을 일으킬 수 있습니다. - 가중치 합성. 합성된 ratio에 100을 곱해 백엔드 풀의
weight범위(1–1000)로 클램프합니다. 합성 ratio가 정확히0이면 백엔드를 아예 만들지 않습니다(드레인).
적용 단계는 두 모드가 공유합니다. config_modification_lock 아래에서 appproxy-* 백엔드 집합을 재구성해 config_sender로 후보 설정을 보내면 HotReloadService가 비교, 제거 백엔드 드레인, 헬스 체커 동기화, 모델 캐시 무효화를 수행합니다. 같은 패스에서 RouterKeyStore의 키 집합과 /v1/models 가시성에 쓰이는 게시 모델 목록도 함께 교체됩니다.
9.7 요청 경로: 키 게이트, 모델 라우팅, 폴백¶
단일 엔드포인트로 들어온 요청은 라우터의 일반 파이프라인을 흐릅니다.
-
API 키 게이트 (동적 인증 미들웨어,
src/http/middleware/auth.rs). 키는Authorization: Bearer <key>또는 Anthropic 스타일x-api-key헤더에서 읽습니다. 워커는 각 키의 SHA-256 해시만 보관하므로(§9.8) 제시된 베어러를 해시해RouterKeyStore에서 조회하는 방식으로 인증합니다. 이 저장소는 워커 소유이고 레거시ApiKeyStore와 분리되어 있어, 리컨사일된 키는api_keys설정 섹션을 건드리지 않고 곧바로 효력을 가집니다. 결과는 다음과 같습니다.- 키 누락, 미등록, 만료 →
401 - 키에
allowed_client_ips가 있는데 피어 IP가 어떤 항목(개별 IP 또는 CIDR)과도 일치하지 않음 →403 - 키별
rate_limit(분당 요청 수, 노드 단위 적용) 초과 →429 - 그 외에는 키의 모델 허용 목록을 담은
AuthContext가 첨부되어 하류 게이트에 전달
- 키 누락, 미등록, 만료 →
-
모델 가시성과 라우팅.
/v1/models는 정확히allowed_models ∩ 현재 게시된 이름을 나열합니다.allowed_models가 비어 있으면 무제한으로 모든 게시 이름이 나열됩니다. 기본 이름과 별칭은 독립적으로 게이트됩니다. 바디의model필드가 §9.2(1단계)대로 백엔드 후보 집합을 선택하고, 키 허용 목록 밖의 게시 이름은403으로 거부되며(enforce_model_access), 선택 전략이 복제본을 고릅니다(2단계). - 폴백 참여 (범위 제한). 게시된 모델은
fallback.fallback_chains에 참여합니다. 복제본이 모두 다운되면select_backend_with_retry가 백엔드를 찾지 못하고FallbackService가 이어받아, 모델을 키로 하는 체인(예:llama-4-chat → gpt-4o-mini)에 도달합니다. '배포가 다운되면 트래픽이 OpenAI로 간다'는 동작입니다. 키 게이트는 이 폴스루 이전에 실행되므로 폴백이 인증 우회 통로가 되는 일은 없습니다. 알 수 없는 모델 이름(퍼블리케이션 없음)은 그냥404 model_not_found이고 폴백 경로에 진입하지 않습니다.
서킷별 JWT 베어러 토큰(레거시 워커의 open_to_public == false 인증)은 ROUTER 모드에서 사용되지 않습니다. 모델 API 키가 데이터 플레인 자격 증명을 대체하고, 서킷의 open_to_public 플래그는 무시됩니다. 소스 IP 허용 목록 역시 서킷이 아니라 키 단위(ModelApiKey.allowed_client_ips)로 표현됩니다. Host 기반 서킷 범위 지정이 없으므로 인그레스 시점에 서킷 수준 CIDR 목록을 붙일 서킷 정체성 자체가 없기 때문입니다.
9.8 컨트롤 플레인: 무엇을 누가 정의하고, 키는 누가 보관하는가¶
Backend.AI Manager는 router authority 범위의 두 객체를 위한 관리 표면(GraphQL/CLI/WebUI)을 추가합니다(BEP-1053).
- 퍼블리케이션:
기본 이름 + 별칭 → [{endpoint_id, ratio}]와control_mode. 어느 배포 엔드포인트가 어떤 이름으로 게시 모델을 서빙하고, 트래픽을 어떻게 나누며, 그 비율의 소유자가 누구인지(manual대strategy_managed. 라우터는 어느 쪽이든 동일하게 라우팅하고 구분은 도구용으로 보존)를 정의합니다. - 모델 API 키:
key_id → {token_hash, allowed_models, expires_at, rate_limit, allowed_client_ips}. 소비자가 라우터에 제시하는 자격 증명으로, 라우터는 해시만 저장합니다.
모델 API 키는 Backend.AI의 배포 액세스 토큰(코디네이터가 발급하는 배포별 JWT)과 구분됩니다. WILDCARD/PORT 프런트엔드는 배포 액세스 토큰을 사용합니다.
매핑이나 키가 끝에서 끝까지 흐르는 과정:
user/admin Manager Coordinator Router worker(s)
│ define model / │ │ │
│ issue key │ │ │
├──────────────────────► │ PUT /v2/routers/{authority}/… │
│ ├─────────────────────► │ 1. persist (PostgreSQL) │
│ │ │ 2. broadcast event ───► │ apply mapping payload /
│ │ │ (Redis Pub/Sub) │ pull key hash (REST)
│ │ │ ◄── ack (first node) ───┤ → hot reload
│ key shown to user │ ◄── 200 + ack status │ 3. wait ≤15 s for ack │
│ ◄──────────────────────┤ │ │
책임을 정확히 나누면 다음과 같습니다.
- Manager가 키를 생성하고, 누구도 평문을 저장하지 않습니다. 키 자료(불투명한
sk-…스타일 토큰)는 Manager가 생성해 사용자에게 정확히 한 번 보여 주고, 소유자·허용 모델 집합·만료와 함께 SHA-256 해시(및 마스킹된 표시 힌트)로만 영속화합니다. Manager는 키 발급, (마스킹된) 목록 조회, 회전, 폐기의 유일한 사용자 대면 표면입니다. 무중단 회전은 두 번째key_id를 발급한 뒤 첫 번째를 폐기하는 방식입니다. - 코디네이터가 원하는 상태를 영속화하고 브로드캐스트합니다. Manager는 라우터와 직접 통신하지 않고 코디네이터를 호출하며, 코디네이터는 각 변경을 먼저 PostgreSQL(원하는 상태)에 적용한 다음 authority의 워커들에게 Pub/Sub 이벤트로 알립니다(§9.10). 이 호출은 선제 배포(proactive-deploy) 방식입니다. 코디네이터는 첫 번째 워커 ack를 최대 15초까지 기다린 뒤(
initialize_legacy_circuit과 같은 패턴) 반환합니다. 모든 노드를 기다리지 않고, ack 타임아웃이어도 호출은 성공합니다. 영속화된 상태가 권위이고 풀 리컨사일이 수렴을 보장하기 때문입니다. 코디네이터 측 영속화는 선택이 아니라 필수입니다. 라우터 런타임 상태는 설계상 메모리 전용이라 노드가 (재)등록할 때마다 재생 가능해야 합니다. - 라우터는 키 해시를 메모리에만 보관하고(
RouterKeyStore.api_keys.persistence_file은 ROUTER 모드에서 미사용) §9.7대로 데이터 플레인에서 집행합니다.
복구 가능한 시크릿은 이벤트 버스에 실리지 않고, 발급 후에는 아예 이동하지 않습니다. 퍼블리케이션 이벤트는 전체 페이로드를 담지만(퍼블리케이션에는 시크릿이 없음) 키 이벤트는 (authority, key_id)뿐인 알림 전용이고, 워커는 인증된 REST 스냅샷 풀로 키의 해시를 가져옵니다(§9.9). Redis 버스는 공유 인프라입니다. 서킷 이벤트는 이미 이 버스를 지나지만 커널 주소만 담을 뿐 자격 증명은 담지 않으며, 이 설계는 그 성질을 보존합니다. 해시 전용 보관 덕에 평문 토큰은 발급 후 사용자 곁을 떠나지 않습니다. 두 지점 간 HTTP 홉(Manager → 코디네이터, 코디네이터 → 라우터 풀)은 해시만 나르지만 그래도 TLS나 신뢰 네트워크 위에서 운용하고 절대 로그에 남기지 않아야 합니다. 유출된 해시는 베어러와 등가가 아니므로(라우터는 클라이언트가 제시한 값을 해시함) 코디네이터/Manager DB가 침해되어도 직접 쓸 수 있는 것은 새어 나가지 않습니다.
폐기는 2단계입니다. 명시적 키 폐기(DELETE …/api-keys/{key_id})는 id 전용 제거 이벤트로 즉시 전파되고 수신 즉시 적용됩니다(추가 조회 불필요). 알림을 놓친 노드는 다음 풀에서 수렴하므로 그 노드의 최악 지연은 reconcile_interval입니다. RBAC에 따른 암묵적 폐기(소유자가 접근을 잃어 Manager가 키에서 모델을 제거)는 Manager의 주기적 리컨사일 간격만큼 추가로 지연될 수 있습니다. 즉시 강제 폐기가 필요하면 DELETE …?strict=true로 코디네이터가 살아 있는 모든 노드의 ack(각 ack에 node_id 포함, §9.10)를 기다려 미확인 노드를 보고하게 합니다. 죽은 노드를 트래픽에서 제거하는 것은 여전히 LB/VIP의 몫입니다.
9.9 업데이트 전송¶
워커는 표준 워커와 같은 두 메커니즘으로 서킷 집합, 퍼블리케이션, 키를 최신으로 유지합니다. 세 번째 채널은 없습니다.
- 풀 리컨사일(기본, 항상 켜짐). 등록 후 워커는
/api/worker/{id}/circuits와 ROUTER 모드 원하는 상태 스냅샷(/api/worker/{id}/router-config)을GET해 메모리 내 상태와 집합 비교(set-diff)로 리컨사일하고, 타이머(reconcile_interval)로 반복합니다. 누락 이벤트의 안전망이자 재시작 후 전체 메모리 상태를 복원하는 수단입니다. authority당revision하나가 퍼블리케이션과 키를 모두 커버합니다. 워커가 마지막 적용 값을GET /api/worker/{id}/router-config?known_revision={r}로 넘기면 리비전이 그대로일 때 저렴한304 Not Modified가 반환됩니다(코디네이터 REST 레이어에 조건부 GET 미들웨어가 없어 HTTPETag대신 명시적 쿼리 파라미터 사용). 변경이 있으면 코디네이터가 결합된 전체 스냅샷을 반환하고 워커가 전체를 집합 비교합니다. - Redis Pub/Sub 오버레이(낮은 지연). 워커는
events_all-appproxy를 구독해 델타를 약 1초 안에 적용합니다. 서킷 이벤트는 표준 워커와 정확히 같게 동작합니다(§5.5). ROUTER 모드는 같은 버스에 퍼블리케이션과 키 이벤트를 추가합니다(§9.10). 모든 인바운드 이벤트는 엔티티 하나의 전체 새 상태(또는 삭제)를 담으므로 적용이 멱등이고 최종 기록자 승리(last-writer-wins)입니다.appproxy_circuit_route_updated_event가 서킷의 라우트 테이블 전체를 교체하는 것과 정확히 같은 방식이라 추적할 버전 체인이 없습니다. 워커에서 모든 이벤트는 풀 경로와 같은 리컨사일 뮤텍스를 거치므로 이벤트와 풀 리컨사일이 일관성 없이 뒤섞이는 일이 없습니다.
이 이벤트들이 모두 브로드캐스트(Pub/Sub)이므로 워커에는 SUBSCRIBE와 PUBLISH(ack)만 있으면 됩니다. Redis Streams나 컨슈머 그룹은 필요하지 않습니다.
9.10 와이어 프로토콜 추가분¶
워커 범위 REST (§6.1 대비 델타)¶
| 메서드와 경로 | 용도 | 비고 |
|---|---|---|
PUT /api/worker |
등록 (아래 ROUTER 바디) | 레거시와 같은 라우트. frontend_mode로 구분 |
PATCH /api/worker/{id} |
하트비트 | {"node_id": …} 바디 포함(레거시는 바디 없음). 노드 생존 항목 갱신 |
GET /api/worker/{id}/router-config?known_revision={r} |
ROUTER 원하는 상태 스냅샷 | 퍼블리케이션 + API 키 해시. r이 현재 리비전과 같으면 304, 다르면 200 전체 스냅샷 |
ROUTER 모드의 등록 요청 바디:
{
"authority": "continuum-router-1",
"frontend_mode": "router",
"protocol": "http",
"hostname": "router.example.com",
"tls_listen": false,
"tls_advertised": true,
"api_port": 8080,
"traffic_port": 443,
"node_id": "b3f1c0de-…",
"accepted_traffics": ["inference"],
"filtered_apps_only": false,
"app_filters": [],
"port_range": null,
"wildcard_domain": null,
"wildcard_traffic_port": null,
"traefik_last_used_marker_path": null
}
traffic_port는 공개 데이터 플레인 포트로(앞에 LB가 있을 수 있음), 설정에서 지정하지 않으면 api_port가 기본값입니다. node_id는 프로세스별 임시 UUID(§9.5)로, 등록과 모든 하트비트에서 같은 값을 보냅니다. 슬롯 필드들은 레거시 등록 바디의 관례대로 명시적 null로 내보냅니다. 응답은 authority의 모든 노드가 공유하는 {"id": <worker uuid>}입니다.
router-config 스냅샷 응답(200):
{
"revision": "42",
"models": [
{
"model": "llama-4-chat",
"aliases": ["llama-4"],
"mappings": [
{"endpoint_id": "3333…", "ratio": 0.7},
{"endpoint_id": "4444…", "ratio": 0.3}
],
"control_mode": "manual"
}
],
"api_keys": [
{
"key_id": "key-abc",
"token_hash": "<sha256 hex>",
"allowed_models": ["llama-4-chat"],
"expires_at": "2026-12-31T23:59:59Z",
"rate_limit": 600,
"allowed_client_ips": ["10.0.0.0/8"]
}
]
}
serde 규칙은 §6.2와 같습니다. 출력은 snake_case, 입력은 kebab-case 별칭 허용(models는 publications도, api_keys는 keys/api-keys도 허용), 알 수 없는 필드 무시. revision은 워커에게 불투명합니다. JSON 문자열이든 숫자든 받아 문자열로 저장하고, 비교와 반환만 합니다. 키별로 allowed_models가 비어 있으면 무제한이고, expires_at, rate_limit(분당 요청 수, 노드 단위), allowed_client_ips(IP 또는 CIDR)는 선택 사항입니다. control_mode ∈ manual(기본) / strategy_managed, ratio 기본값은 1.0입니다.
Manager 범위 REST (코디네이터 측, BEP-1053)¶
Manager는 코디네이터를 통해 퍼블리케이션과 키를 관리하고, 코디네이터가 이를 영속화·브로드캐스트합니다(§9.8). 라우터는 이 API들을 호출하지 않으며 맥락을 위해 나열합니다. 모든 라우트는 공유 X-BackendAI-Token 시크릿으로 인증합니다.
| 메서드와 경로 | 용도 |
|---|---|
GET /v2/routers/{authority}/models |
퍼블리케이션 목록 |
PUT /v2/routers/{authority}/models/{model} |
퍼블리케이션 업서트: {aliases, mappings: [{endpoint_id, ratio}], control_mode} |
DELETE /v2/routers/{authority}/models/{model} |
모델 게시 해제 (별칭 포함) |
GET /v2/routers/{authority}/api-keys |
키 목록 (표시 힌트로 마스킹) |
PUT /v2/routers/{authority}/api-keys/{key_id} |
키 업서트: {token_hash, allowed_models, expires_at, rate_limit, allowed_client_ips} |
DELETE /v2/routers/{authority}/api-keys/{key_id}?strict={bool} |
키 폐기 (strict는 살아 있는 전 노드 ack 대기) |
이벤트 (§6.3 대비 델타)¶
같은 events_all-appproxy 엔벨로프에 이벤트 다섯 종이 추가됩니다.
name |
방향 | args 튜플 |
|---|---|---|
appproxy_router_model_updated_event |
인바운드 | (authority, model_json): 퍼블리케이션의 전체 새 상태 |
appproxy_router_model_removed_event |
인바운드 | (authority, model_name): 퍼블리케이션과 모든 별칭 제거 |
appproxy_router_key_updated_event |
인바운드 | (authority, key_id): 알림 전용, 토큰·해시 없음. 워커는 router-config 스냅샷으로 키 해시를 가져옴 |
appproxy_router_key_removed_event |
인바운드 | (authority, key_id): 즉시 적용, 추가 조회 불필요 |
appproxy_worker_router_config_applied_event |
아웃바운드 (ack) | (authority, node_id, kind, id): node_id로 코디네이터가 노드별 ack를 집계. 첫 ack가 기본 변경 호출을 해제하고, 엄격 폐기는 살아 있는 전 노드를 대기(§9.8) |
9.11 설정과 모드 선택¶
ROUTER 워커는 appproxy-router 피처로 게이트되는 전용 appproxy_router 섹션으로 설정합니다.
appproxy_router:
enabled: true
coordinator_url: "http://coordinator:10200"
api_secret: "${APPPROXY_API_SECRET}" # X-BackendAI-Token (라우터 → 코디네이터)
redis_url: "redis://valkey:6379/4" # 이벤트 버스 DB (stream 역할)
authority: "continuum-router-1"
hostname: "router.example.com" # 공개 호스트 (LB가 있으면 LB)
traffic_port: 443 # 공개 데이터 플레인 포트. 0이면 api_port 사용
tls_advertised: true
heartbeat_period: "10s"
reconcile_interval: "15s"
events_enabled: true # Redis Pub/Sub 오버레이 on/off
api_secret과 redis_url은 backends[].api_key와 동일하게 ${ENV_VAR} 보간을 지원합니다. frontend_mode 설정은 없고(항상 router), jwt_secret도, wildcard_domain도, 집약 호스트 목록도 없습니다. 단일 엔드포인트 자체가 집약 표면이기 때문입니다. 이 섹션의 알 수 없는 키는 하드 파싱 오류입니다(관용적인 와이어 페이로드와 달리 운영자가 작성하는 YAML이므로).
어떤 섹션이 존재하고 활성화되어 있느냐에 따라 워커 모드가 결정됩니다.
| 설정 | 모드 |
|---|---|
appproxy 섹션 존재, enabled: true |
레거시 와일드카드/포트 워커 (appproxy-legacy 피처) |
appproxy_router 섹션 존재, enabled: true |
ROUTER 워커 (appproxy-router 피처) |
| 둘 다 없거나 모두 비활성화 | AppProxy 워커 없음 |
상호 배타성. appproxy-legacy와 appproxy-router를 모두 포함해 빌드한 바이너리에서 같은 설정 파일 안에 appproxy.enabled: true와 appproxy_router.enabled: true를 동시에 지정하면 로드 시 설정 유효성 검사 오류로 거부됩니다. 두 워커가 같은 authority로 각각 등록되면 매 리컨사일 패스마다 서로의 appproxy-* 백엔드를 덮어쓰게 됩니다.
ROUTER 모드에서는 전역 설정 두 가지가 고정됩니다.
api_keys.mode = blocking: 요청을 처리하기 전 시작 시점에 강제로 설정됩니다. 코디네이터에서 공급되는 키 집합은 첫 번째 리컨사일 때 도착하므로, 블로킹 모드는 그 사이 인증되지 않은 요청이 통과하는 것을 막습니다.api_keys.persistence_file: 사용되지 않습니다. 키 집합은 재시작 때마다 코디네이터의router-config스냅샷에서 다시 구성되므로, ROUTER 모드에서 영속 파일을 설정해도 효과가 없습니다.
9.12 보안 참고 사항¶
- 코디네이터 인증. 모든 REST 호출은
X-BackendAI-Token: <api_secret>을 보냅니다. 시크릿은 환경 변수/시크릿 저장소에 보관하고 절대 로그에 남기지 않으며, AppProxy 클러스터 전체에서 동일해야 합니다. - 데이터 플레인 인증. 모든 추론 요청은 Manager가 발급한 모델 API 키(
Authorization: Bearer또는x-api-key)를 제시해야 합니다. 키가 모델 가시성과 사용을 게이트합니다(§9.7). 서킷별 JWT(배포 액세스 토큰)는 사용되지 않으므로 ROUTER 모드에는jwt_secret이 필요 없습니다. - 키 보관 (해시 전용, SHA-256). 복구 가능한 키 자료는 어디에도 저장되지 않습니다. Manager는 발급 시 평문을 한 번 보여 주고, 코디네이터와 라우터는 해시만 보관하며, 라우터는 제시된 베어러를 해시해 인증합니다. Redis 버스의 키 이벤트는 id 전용 알림이고, 인증된 HTTP 홉으로는 해시만 이동합니다(§9.8). 두 홉은 TLS나 신뢰 네트워크 위에서 운용하고, 키 값은 절대 로그에 남기지 않습니다(라우터는 디버그 출력에서
token_hash와api_secret을 리댁팅). - 클라이언트 IP 허용 목록은 키 단위(
allowed_client_ips, 개별 IP 또는 CIDR)이며, 키 게이트가 불일치 시403으로 집행합니다.
9.13 제약 사항¶
- 코디네이터 지원이 필요합니다. ROUTER 모드는 Backend.AI 측의
FrontendMode.ROUTER열거형 값, 슬롯을 생략하는 등록,/v2/routers/*관리 API, router-config 이벤트,router-config스냅샷 엔드포인트(BEP-1053 명세)를 요구하며 이 모든 코디네이터 기능을 사용할 수 있어야 합니다. - 폐기 전파 한도 (2단계). 명시적 키 폐기는 Pub/Sub 알림으로 워커에 도달합니다. 알림을 놓친 노드는 다음 풀에서 수렴하므로 최악 지연은
reconcile_interval입니다(?strict=true는 살아 있는 전 노드 적용까지 조입니다). RBAC에 따른 암묵적 폐기는 Manager의 리컨사일 간격만큼 추가로 지연됩니다(§9.8). - 노드 단위 속도 제한.
rate_limit은 공유 카운터 없이 노드 단위로 적용되므로, HA에서는 클러스터 유효 상한이 약rate_limit × 살아 있는 노드 수가 됩니다. 전역 쿼터가 아니라 대략적인 남용 방지 수단입니다. - WILDCARD/PORT 슬롯. ROUTER 워커는
frontend_mode = router로만 등록합니다. 배포별 슬롯 프런트엔드는 레거시 워커의 몫입니다(프로세스당 둘 중 하나만 실행, §9.11). - 인터랙티브 앱. 서비스하지 않습니다. 라우터는
accepted_traffics = [inference]로만 등록하므로 인터랙티브 서킷은 기존 표준 워커에 남습니다. - 헬스/부하 보고. AppProxy 하트비트는 단순 keepalive입니다. 라우터는 서킷별 부하나 헬스 메트릭을 코디네이터로 내보내지 않습니다(라우터 자체 Prometheus 메트릭은 라우터에서 계속 제공).
10. 보안¶
(레거시 워커 기준. ROUTER 모드는 §9.12 참고.)
- 코디네이터 인증. 모든 REST 호출은
X-BackendAI-Token: <api_secret>을 보냅니다. 시크릿은 환경 변수/시크릿 저장소에 보관하고 절대 로그에 남기지 않습니다. - 데이터 플레인 인증. 비공개 추론 서킷은 디코딩된
id가 서킷 id와 일치하는Authorization: Bearer <jwt>를 요구합니다(HS256,jwt_secret). 공개 서킷(open_to_public == true)은 생략합니다.jsonwebtoken을Validation::new(Algorithm::HS256)과 함께 사용해야 하며, 트리의 다른 곳에 있는 미검증 페이로드 디코딩은 절대 사용하면 안 됩니다. - 클라이언트 IP 허용 목록. 서킷에
allowed_client_ips(쉼표로 구분한 CIDR)가 있으면 이를 적용합니다. - 공유 시크릿.
api_secret과jwt_secret은 AppProxy 클러스터 전체(코디네이터 + 워커)에서 동일해야 합니다.
11. 제약 사항¶
(레거시 워커 기준. ROUTER 모드는 §9.13 참고.)
- 모델 이름 출처. 서빙되는 모델 이름은 각 복제본의
/v1/models에서 자동 발견됩니다. 코디네이터는 모델 이름을 제공하지 않습니다(결정 2에 따라 통합은 Continuum Router 측에서만 이루어짐). - PORT 모드. 지원하지 않습니다. 포트별 동적 리스너가 필요하기 때문이며, 라우터는 와일드카드 워커로만 등록합니다.
- 인터랙티브 앱. 서비스하지 않습니다. 라우터는
accepted_traffics = [inference]로만 등록하므로 인터랙티브 서킷은 기존 표준 워커에 남습니다. - 헬스/부하 보고. AppProxy 하트비트는 단순 keepalive입니다. 라우터는 서킷별 부하나 헬스 메트릭을 코디네이터로 내보내지 않습니다.