Dynamo 워커 탐색¶
Continuum Router는 워커가 등록된 etcd 클러스터나 Kubernetes 리소스를 감시하여 NVIDIA Dynamo 워커 플릿을 탐색합니다. 어떤 prefill·decode 인스턴스가 실행 중인지, 각 워커가 어떤 모델과 용량을 제공하는지 파악하고 watch 이벤트로 최신 상태를 유지합니다.
이 문서에서 다루는 주요 구성 요소는 다음과 같습니다.
- 워커 탐색 (
etcd,kubernetes,kubernetes_pods): etcd 키, EndpointSlice와 결합된DynamoWorkerMetadata커스텀 리소스, 또는 오퍼레이터 라벨이 붙은 Pod를 감시합니다. - 부하 신호 (
kv_metrics): 워커별 ZMQ 발행자(직접 연결 또는 이벤트 브로커 경유)를 구독하여 활성 decode 블록과 KV 캐시 점유율을 추적합니다. - Direct 모드 shim 라우팅: 요청마다 prefill·decode 워커를 선택한 뒤
dynamo.frontend --router-mode directshim을 통해 디스패치합니다. - KV 오버랩 인지 prefill 선택 (
dynamo-kv):kv-events블록 스트림을 인덱싱하고 핀된 토크나이저로 프롬프트를 렌더링해, prefill 워커(모델에 prefill/decode 풀이 없으면 aggregated 워커)에 프리픽스 캐시 오버랩 크레딧을 반영합니다. - 클러스터 내부 이벤트 브로커 리스너 (
dynamo-event-bus): 라우터가 GPU 클러스터 외부에서 실행될 때 클러스터 내부의continuum-event-broker및continuum-kv-listener가 연결해 오는 HTTP/2 over TLS 터널을 수신합니다.
읽기 전용 계약¶
라우터는 Dynamo 탐색 프로토콜에서 읽기 전용 관찰자로만 동작합니다. range get과 watch만 수행하며 그 외의 쓰기 작업은 일절 수행하지 않습니다.
- lease를 획득하거나 갱신하지 않습니다.
- 키를 기록하지 않습니다.
- 키를 삭제하지 않습니다.
워커의 활성 상태 관리는 전적으로 각 워커가 담당합니다. 워커마다 자체 etcd lease(기본 10초)를 유지하며, lease가 만료되면 키가 자동으로 삭제됩니다. 라우터는 만료를 일반 delete 이벤트로 수신해 레지스트리에서 해당 인스턴스를 제거합니다. 따라서 라우터가 오동작하거나 잘못 설정되더라도 정상 워커의 등록을 해제하거나 종료된 워커의 수명을 연장할 수 없으며, 다른 Dynamo 구성 요소가 참조하는 레지스트리를 변경하지 않습니다.
src/dynamo/watcher.rs 모듈 어디에도 put, delete, lease_grant, lease_keep_alive 호출이 존재하지 않습니다.
etcd를 사용하는 이유¶
베어메탈 환경의 Dynamo 워커는 etcd에만 등록하므로 라우터는 etcd를 읽어 플릿을 파악합니다(dynamo.discovery.source: etcd, 기본값). 단일 노드 환경에서는 파일 스토어도 사용할 수 있습니다.
Kubernetes 환경에서는 같은 정보를 DynamoWorkerMetadata 리소스와 EndpointSlice로 게시하며 kubernetes 소스가 이를 읽습니다(Kubernetes 메타데이터 소스 참고). 라우터에 해당 커스텀 리소스 읽기 권한이 없고 Pod 읽기 권한만 있는 환경에서는 kubernetes_pods 소스로 오퍼레이터 라벨이 붙은 Pod를 탐색합니다(Kubernetes Pod 소스 참고).
etcd 키 레이아웃¶
설정된 네임스페이스(Dynamo의 DYN_NAMESPACE, 기본값 dynamo)를 기준으로 세 가지 프리픽스를 감시합니다. 앞의 두 프리픽스는 워커 정보를 담고, 세 번째 프리픽스는 부하 및 KV 이벤트 발행자 정보를 담습니다.
| 프리픽스 | 레코드 | 용도 |
|---|---|---|
v1/instances/<ns>/ | {"type": "Endpoint", ...} | 워커 인스턴스 및 엔드포인트별 레코드 |
v1/mdc/<ns>/ | {"type": "Model", ..., "card_json": {...}} | 워커가 게시한 모델 배포 카드 |
v1/event_channels/namespace/<ns>/ | {"type": "EventChannel", ...} | 워커가 kv_metrics 및 kv-events 스트림을 발행하는 주소 |
Endpoint 레코드 예시:
{
"type": "Endpoint",
"namespace": "dynamo",
"component": "prefill",
"endpoint": "generate",
"instance_id": 7465265302745285,
"transport": { "tcp": "10.0.0.4:31000/1a7f2b/dynamo/prefill/generate" },
"device_type": "cuda",
"request_plane_codec": "msgpack"
}
Model 레코드는 중첩 JSON 형태로 모델 카드를 포함합니다.
{
"type": "Model",
"namespace": "dynamo",
"component": "prefill",
"endpoint": "generate",
"instance_id": 7465265302745285,
"card_json": {
"display_name": "Qwen/Qwen3-32B",
"worker_type": "Prefill",
"kv_cache_block_size": 64,
"runtime_config": {
"total_kv_blocks": 12000,
"max_num_batched_tokens": 8192,
"max_num_seqs": 256,
"context_length": 32768,
"data_parallel_start_rank": 0,
"data_parallel_size": 1,
"disaggregated_endpoint": "dynamo/decode/generate"
}
}
}
두 레코드는 (component, endpoint, instance_id)를 기준으로 조인됩니다.
- 워커의 모델 카드는 해당 워커의
generate레코드와 동일한component및endpoint로 등록된 기본 카드입니다. instance_id가 JSON 숫자(etcd lease ID)로 기록되든 16진수 문자열로 기록되든 동일한 키로 정규화되어 조인됩니다.- 구버전 작성자가
component와endpoint를 비워 둔 레거시 카드는instance_id만으로 조인되며, 같은 인스턴스에 범위가 지정된 기본 카드가 있으면 범위 지정 카드가 우선합니다. model_suffix가 비어 있지 않은 카드(LoRA 어댑터 카드)는 조인 대상에서 제외됩니다.
무엇이 워커가 되는가¶
레코드가 레지스트리의 워커 항목으로 구체화되는 기준은 네 가지입니다.
generate엔드포인트만 포함: 워커는generate,load_metrics,clear_kv_blocks등 여러 엔드포인트를 등록합니다. 라우팅 대상인generate추론 엔드포인트만 반영하고 나머지는 건너뜁니다.- 컴포넌트와 카드의 역할 일치:
prefill또는decode컴포넌트 이름과 카드의worker_type이 서로 다른 역할을 가리키면 해당 워커를 제외하고 양쪽 역할을 로그에 남깁니다. 컴포넌트 이름이 역할을 특정하지 않으면 카드가 결정하며, 아직 카드가 없는 워커는 컴포넌트 이름의 역할을 따릅니다. - 단일 레코드 파싱 실패 격리: 유효한 JSON이 아니거나
instance_id가 없는Endpoint레코드는warn로그를 남기고 해당 레코드만 건너뜁니다. 다른 레코드와 watcher 동작에는 영향을 주지 않습니다. - 식별자당 단일 레코드: 업스트림에서 단일 키를 도출하는 식별자를 여러 레코드가 동시에 주장하면 키 순서로 임의 선택하지 않고 모두 거부합니다.
중복 식별자¶
reducer는 레코드를 etcd 키로 저장하지만 값에서 읽은 식별자로 조인하고 게시하므로, 서로 다른 두 키가 같은 식별자를 주장할 수 있습니다. 허용되는 다중성 기준은 ai-dynamo/dynamo 커밋 ff3ac59e(lib/runtime/src/discovery/kv_store.rs, lib/runtime/src/discovery/mod.rs, lib/runtime/src/storage/kv/etcd.rs, lib/llm/src/local_model.rs, lib/runtime/src/transports/event_plane/mod.rs)를 따릅니다.
| 키스페이스 | 업스트림 키 및 식별자 | 허용되는 다중성 | 거부 대상 |
|---|---|---|---|
v1/instances/ | {ns}/{component}/{endpoint}/{instance_id:x} (instance_id는 프로세스의 etcd lease ID) | 한 인스턴스가 서비스하는 엔드포인트(generate, clear_kv_blocks, load_metrics 등)마다 키 하나씩 등록 | 동일한 instance_id를 가진 generate 레코드가 2개 이상인 경우 |
v1/mdc/ | {ns}/{component}/{endpoint}/{instance_id:x}[/{model_suffix}] | 엔드포인트당 기본 카드 1개 및 LoRA 어댑터(model_suffix 포함)당 카드 1개 | 동일한 (component, endpoint, instance_id)의 기본 카드가 2개 이상이거나, 동일한 instance_id의 레거시 카드가 2개 이상이거나, 레거시 카드와 범위 지정 기본 카드가 공존하는 경우 |
v1/event_channels/ | .../topic/{topic}/{publisher_id:x} (publisher_id는 발행자 실행 시마다 발급되는 53비트 난수) | 없음 (프로세스 내 여러 발행자는 각기 다른 ID를 사용하며 재시작 시 새 ID 발급) | 동일한 publisher_id를 가진 사용 가능한 채널이 2개 이상인 경우 |
라우터는 정상 레코드와 중복·위조 레코드를 임의로 구분할 수 없으므로 거부 대상 집합의 레코드를 모두 제외합니다.
- 거부된
generate인스턴스 ID: 워커를 생성하지 않습니다. - 거부된 기본 카드: 워커를 제외하지 않고 카드가 없는 상태로 구체화합니다.
- 거부된 발행자 ID: 구독을 생성하지 않으므로, 해당 워커는 최선 노력 계약에 따라 항상 조용한(quiet) 워커로 취급됩니다.
충돌이 발생하면 continuum_dynamo_discovery_events_total{kind="duplicate"}를 증가시키고 레코드 종류, 식별자, 충돌 개수, 최대 4개의 키를 warn으로 기록합니다. 동일한 충돌 키 집합은 한 번만 기록되며 중복 레코드가 삭제되면 다음 패스부터 정상 반영됩니다.
get-then-watch와 재동기화¶
한 번의 탐색 패스는 네 단계로 실행됩니다.
- 읽기 전용 etcd 클라이언트를 연결합니다.
v1/instances/<ns>/,v1/mdc/<ns>/,v1/event_channels/namespace/<ns>/를 range get하고 응답의 스토어 리비전을 기록합니다.- 각 프리픽스에 대해
리비전 + 1부터 시작하는 watch를 엽니다. - 스트림이 종료될 때까지 모든 이벤트를 레지스트리에 반영합니다.
리비전 커서를 통해 range get 시점까지의 스냅샷과 그 이후의 watch 이벤트가 빈틈없이 이어집니다.
연결 종료, watch 취소, 또는 시작 리비전 압축(compaction)으로 패스가 끝나면 유계 백오프 후 새 패스를 처음부터 다시 시작합니다. 보유 레코드를 비우고 새 range get으로 전체 재동기화를 수행하므로, etcd 압축으로 인해 누락된 이벤트가 삭제된 경우에도 일관성을 회복합니다.
- 백오프 초기화 규칙: 반복 실패 시 지연 시간이 두 배씩 증가하며, 세 watch가 모두 열린 시점부터 최소
watch_reconnect_backoff.max이상 유지된 세션이 끝났을 때만initial하한으로 초기화됩니다(src/dynamo/backoff.rs). - 최초 관찰 시각 보존: reducer는 watcher당 한 번 생성되어 재연결 사이에 재사용되므로 재동기화 후에도 워커의
first_seen타임스탬프가 유지됩니다.namespace나 연결 설정 변경으로 watcher가 교체될 때는 reducer와 레지스트리가 함께 초기화되며, 백오프 설정만 변경된 경우에는 새 watcher의 첫 패스가 완료될 때까지 기존 스냅샷을 유지합니다. - 시드 타임아웃: 각 패스의 연결 및 초기 시드 단계(접속, 선택적
AuthenticateRPC, range get)에는 30초 고정 타임아웃을 적용하고, 이후 장기 유지되는 watch 스트림에는 타임아웃을 두지 않습니다.
와이어 호환성 (N-2)¶
Dynamo는 N-2 와이어 호환성 범위를 보장하므로 파서는 현재 스키마와 몇 릴리스 차이 나는 워커 레코드도 안전하게 처리합니다.
- 모든 필드는 선택 사항이며 누락 시 기본값을 사용합니다.
- 알 수 없는 필드는 오류 없이 무시합니다.
- 인식되지 않는
worker_type은unknown(역할 주장 없음)으로 파싱합니다.prefill또는decode컴포넌트와 함께 있으면 컴포넌트가 역할을 결정하고, 컴포넌트도 역할을 지정하지 않으면unknown상태로 두어 운영자 뷰에만 표시하고 라우팅 후보에서는 제외합니다. - 카드의 일부 타입 필드 역직렬화가 실패하더라도 원본 카드를 보존한 채 워커를 생성합니다.
- 파싱된 필드와 함께 모델 카드 전체를 불투명(opaque) JSON으로 보관하므로 추후 스키마 마이그레이션 없이도 새 필드를 읽을 수 있습니다.
카드에 릴리스 버전 문자열(dynamo_version, runtime_version, version, release)이 있으면 인스턴스당 한 번 debug로 기록합니다.
메모리 상한¶
감시 대상 프리픽스의 모든 etcd 값은 플릿에서 작성한 비신뢰 입력이므로 reducer는 세 가지 컴파일 타임 상수(src/dynamo/apply.rs)로 메모리 사용량을 제한합니다.
MAX_RECORD_BYTES(16 MiB): 이 크기를 초과하는 원본 etcd 값은 파싱 전에 거부합니다(etcd 기본--max-request-bytes1.5 MiB의 10배 이상).MAX_RETAINED_CARD_BYTES(1 MiB): 타입 필드와 함께 보관하는 불투명 모델 카드 JSON의 상한입니다. 이 크기를 넘는 카드는 워커를 제외하지 않고 불투명 JSON 블롭만 버리며, 핵심 타입 필드(worker_type,kv_cache_block_size,runtime_config)와 워커 자체는 정상 게시합니다.MAX_RECORDS_PER_KEYSPACE(16384): 키스페이스(인스턴스 및 모델 카드 각각)당 보유할 수 있는 고유 etcd 키 수의 상한입니다. 상한 도달 시 기존 키 업데이트(하트비트)는 계속 허용하고 새로운 키 추가만 거부하며 키스페이스당 한 번 로그를 남깁니다.
게시되는 card_json은 파싱 시점에 한 번 Arc<serde_json::Value>로 감싸므로 materialize()가 호출될 때마다 깊은 복사를 수행하지 않습니다.
설정¶
dynamo:
enabled: true
discovery:
etcd:
endpoints: ["http://etcd-0.dynamo.svc:2379", "http://etcd-1.dynamo.svc:2379"]
namespace: "dynamo" # 워커의 DYN_NAMESPACE와 일치해야 함
# namespaces: ["prod", "canary"] # 또는 여러 그래프, 각각 아래 shim 풀 하나씩
# tls:
# ca_cert: "/etc/etcd/ca.pem"
# client_cert: "/etc/etcd/client.pem"
# client_key: "/etc/etcd/client-key.pem"
# server_name: "etcd.internal"
# auth:
# username: "router"
# password: "${ETCD_PASSWORD}"
watch_reconnect_backoff:
initial: "500ms"
max: "30s"
load:
max_staleness: "3s" # 워커의 마지막 ActiveLoad 샘플을 신선하다고 볼 최대 나이
direct_allow_cidrs: # 워커 Pod 네트워크. 생략하면 기존의 전체 허용 동작 유지
- "10.0.0.0/8"
shim:
backends: [dynamo-frontend] # `dynamo.frontend --router-mode direct` 복제본인 OpenAI 와이어 백엔드
# pools: # 네임스페이스가 여럿이면 `backends` 대신 네임스페이스마다 풀 하나
# - {namespace: "prod", backends: [dynamo-frontend-prod]}
# - {namespace: "canary", backends: [dynamo-frontend-canary]}
model_map: # 선택 사항: 클라이언트 모델 id -> Dynamo display_name (서로 다를 때)
qwen3-32b: "Qwen/Qwen3-32B"
탐색을 사용하려면 dynamo Cargo 기능(공식 릴리스 바이너리에 기본 포함)과 dynamo.enabled: true 설정이 모두 필요합니다. 해당 기능 없이 빌드된 바이너리도 설정 섹션을 동일하게 파싱·검증하며 시작 시 탐색이 비활성화되어 있다는 경고를 남깁니다.
검증 규칙:
endpoints:http(s)://host:portURL 또는host:port(IPv6는[::1]:2379) 형식을 받으며enabled: true일 때 최소 1개에서 최대 16개까지 지정해야 합니다. URL 내 자격 증명(user:pass@host또는user@host), 쿼리 문자열, 프래그먼트, 경로(빈?나#포함)는 모두 거부됩니다.- 스킴 일관성:
endpoints에http://와https://를 혼용할 수 없으며,tls블록과 평문http://엔드포인트를 함께 사용할 수 없습니다. namespace/namespaces: 비어 있지 않아야 하며/, 공백, 제어 문자를 포함할 수 없습니다.namespaces는 중복 없이 최대 16개(MAX_DYNAMO_NAMESPACES)까지 지정할 수 있고 기본값이 아닌namespace와 동시에 사용할 수 없습니다. 네임스페이스가 2개 이상이면shim.backends대신shim.pools를 사용해야 합니다.- TLS 및 인증:
client_cert와client_key는 함께 지정해야 합니다.client_key는 설정 조회(config show, Admin GET, MCP) 시 마스킹되며ca_cert와client_cert는 그대로 표시됩니다. 평문 연결(tls없음,http://)에auth를 설정하면 자격 증명과 세션 토큰이 평문으로 전송되므로 검증 경고가 기록됩니다. - 타이밍 및 shim 한도:
watch_reconnect_backoff.max는initial이상이어야 하고load.max_staleness는100ms~5m범위여야 합니다.shim.backends는 설정된backends[].name을 중복 없이 최대 64개,shim.model_map은 비어 있지 않은 키/값 쌍을 최대 1024개까지 허용합니다.dynamo.enabled나disaggregated_serving.enabled가 꺼진 상태에서 shim 풀을 지정하거나internal: true/enabled: false백엔드를 포함하면 검증 경고를 출력합니다. - 직접 연결 경계:
load.direct_allow_cidrs에는 IPv4 또는 IPv6 CIDR을 최대 64개 지정할 수 있습니다. 직접 모드 커넥터는 발행자 호스트 이름을 한 번만 해석하고 허용 범위 안의 소켓 주소에만 연결하므로, 정책 검사와 실제 연결 사이에 DNS가 주소를 바꿀 수 없습니다. 목록을 생략하면 기존 동작을 유지하며config validate는 워커 Pod CIDR을 추가하라고 제안합니다. 직접 모드는 클러스터 내부 배치를 전제로 합니다. etcd 엔드포인트가 외부로 보이면 시작 시 경고합니다. 라우터가 클러스터 밖에 있으면 브로커 모드를 사용하십시오.
리로드 등급¶
dynamo 섹션의 모든 필드는 프로세스 재시작 없이 반영됩니다.
- 즉시 반영 (watcher 재시작 없음):
load.max_staleness,shim.backends,shim.pools,shim.model_map은 탐색 watcher나 레지스트리를 건드리지 않고 다음 신선도 검사나 요청 선택부터 즉시 적용됩니다.load.transport또는load.direct_allow_cidrs를 바꾸면 새 연결 정책으로 부하 및 KV 구독 태스크만 다시 시작합니다. - 실시간 watcher 교체:
enabled,discovery.etcd.namespace, 연결 정보(endpoints,tls,auth),watch_reconnect_backoff가 바뀌면 실행 중인 watcher를 중지하고 새 watcher를 시작하며, 사유(enabled,namespace,connection,backoff,other)를info로 기록하고continuum_dynamo_discovery_events_total{kind="restart"}를 증가시킵니다. 탐색 비활성화 시에는 재시작 카운터를 올리지 않습니다. - 백오프 단독 변경 예외:
watch_reconnect_backoff만 변경된 경우에는 새 watcher가 첫 전체 재동기화를 마칠 때까지 기존 레지스트리 스냅샷을 유지합니다. - 다중 네임스페이스 가지치기 (
namespaces): 연결 설정이 같고 유지되는 네임스페이스의models가 그대로인 상태에서discovery.etcd.namespaces나discovery.kubernetes.dynamo_namespaces의 항목을 추가·제거하면 레지스트리 전체를 비우지 않고 제거된 네임스페이스의 워커와 발행자만 정리합니다(reason="namespaces"). 유지된 그래프는 중단 없이 계속 라우팅되며, shim 경로도 실시간 설정에 포함된 네임스페이스와 소스의 워커만 고정(pin)합니다. - 시작 및 종료 유예: 부팅 중 발생한 리로드도 수퍼바이저 구독 즉시 반영됩니다. watcher 중지는 최대 5초 유예 후 abort되므로 종료 시 탐색 정리에 최대 10초(진행 중인 전환 5초 + 최종 중지 5초)가 소요될 수 있습니다.
Admin 설정 가져오기(import)에서 auth.password를 마스킹한 채 endpoints를 변경하면 기존 비밀번호가 새 호스트의 Authenticate RPC로 전송되므로, 고정된 ca_cert가 있는 tls 연결과 신뢰할 수 있는 클러스터에만 사용하십시오.
여러 Dynamo 네임스페이스¶
라우터 하나가 프로덕션·카나리 그래프나 동일한 모델 이름을 서빙하는 여러 DynamoGraphDeployment를 동시에 중계할 수 있습니다. 각 그래프는 고유한 Dynamo 네임스페이스(DYN_NAMESPACE)를 가지며, 각 그래프의 dynamo.frontend --router-mode direct는 자기 그래프에 속한 인스턴스 ID만 해석할 수 있으므로 고정된 요청은 반드시 해당 네임스페이스의 프론트엔드 풀로 전달되어야 합니다.
다중 네임스페이스 설정 및 라우팅 규칙:
- 네임스페이스 목록: etcd에서는
discovery.etcd.namespaces, Kubernetes 소스에서는discovery.kubernetes.dynamo_namespaces에 최대 16개의 고유 네임스페이스를 지정합니다(단일 스칼라 필드와 상호 배타적이며 Kubernetesnamespace자체는 하나로 유지). - 네임스페이스별 shim 풀: 네임스페이스가 2개 이상이면
shim.backends대신dynamo.shim.pools([{namespace, backends}])가 필수입니다. 설정된 모든 네임스페이스마다 풀이 정확히 하나씩 있어야 하며, 한 백엔드 복제본은 하나의 풀에만 속할 수 있습니다.kubernetes_pods소스에서models를 사용할 때도 각 항목에dynamo_namespace를 명시해야 합니다. - 워커 식별자: 모든 워커는
(namespace, instance_id)로 식별됩니다. 네임스페이스는 페이로드 내부 값이 아니라 라우터가 감시한 etcd 키 프리픽스, 메타데이터 경로 첫 세그먼트, 또는 Pod의nvidia.com/dynamo-namespace라벨에서 결정됩니다. - 그래프 내부 디스패치: 라우터는 가시성 및 키별 정책을 통과한 복제본이 남아 있는 네임스페이스만 picker 후보로 전달하며, prefill/decode 쌍은 항상 동일한 네임스페이스 안에서 선택합니다. 부하 전용 picker는 후보 네임스페이스 전체에서 decode 후보를 먼저 비교한 뒤 선택된 네임스페이스 안에서 prefill 워커를 고르고, KV 인지 picker는 decode 후보가 존재하는 네임스페이스들에서 prefill 후보를 채점한 뒤 선택된 네임스페이스 안에서 decode 워커를 고릅니다.
Kubernetes Pod 소스¶
Kubernetes에서 Dynamo 오퍼레이터는 DYN_DISCOVERY_BACKEND=kubernetes를 사용하므로 etcd가 없습니다. 라우터의 ServiceAccount가 pods(get, list, watch)는 읽을 수 있지만 dynamoworkermetadatas와 endpointslices에 대한 RBAC 권한이 없을 때는 dynamo.discovery.source: kubernetes_pods를 사용합니다.
dynamo:
enabled: true
discovery:
source: kubernetes_pods
kubernetes:
namespace: "dynamo" # 플릿 Pod가 있는 Kubernetes 네임스페이스
dynamo_namespace: "dynamo-solar-pro4" # 워커의 DYN_NAMESPACE 라벨 값
model: "solar-pro4" # 프론트엔드가 광고하는 서빙 모델 이름
watch_reconnect_backoff:
initial: "500ms"
max: "30s"
shim:
backends: [dynamo-frontend]
소스가 읽는 것¶
- 멤버십:
namespace내nvidia.com/dynamo-discovery-backend=kubernetes,nvidia.com/dynamo-discovery-enabled=true및nvidia.com/dynamo-namespace=<dynamo_namespace>(다중 설정 시in (a,b)) 라벨이 일치하는 Pod를 읽습니다. - 역할:
nvidia.com/dynamo-component-type이prefill또는decode이면 해당 역할을 사용하고,worker이면nvidia.com/dynamo-sub-component-type(prefill또는decode, 그 외에는aggregated)을 따릅니다.frontend,planner,epp,default등은 건너뜁니다. - 준비 상태:
status.phase == "Running",Ready == "True", 그리고metadata.deletionTimestamp가 없는 동안에만 활성 워커로 유지합니다. - 인스턴스 ID 및 모델: Pod 이름에서
instance_id를 도출하고(아래 참고), 설정된model또는 컴포넌트별models매핑을 부여합니다.
Pod에는 모델 카드와 이벤트 채널이 없으므로 용량 필드(total_kv_blocks, max_num_batched_tokens, kv_cache_block_size), dp_rank(x-dynamo-dp-rank 미전송), kv_metrics 부하 신호를 얻을 수 없습니다. 따라서 picker는 후보들을 라운드 로빈으로 순환하며, config validate는 경고를 출력하고 GET /admin/dynamo/workers는 "signals": {"load": false, "capacity": false}를 보고합니다.
한 Dynamo 네임스페이스의 여러 모델¶
한 Dynamo 네임스페이스 안에 서로 다른 모델을 서빙하는 여러 워커 풀이 있을 때는 model 대신 models를 사용해 각 풀의 nvidia.com/dynamo-component 라벨을 서빙 모델 이름에 매핑합니다.
dynamo:
discovery:
source: kubernetes_pods
kubernetes:
namespace: "kbm-g-np-kt-a"
dynamo_namespace: "kbm-g-np-kt-a-solar-pro4-4tp-agg"
models:
- component: VllmWorker-decodeheavy
model: solar-pro4
- component: VllmWorker-prefillheavy
model: solar-pro4-longcontext
컴포넌트 매칭 및 예외 처리 규칙:
- 준비된 각 워커 Pod는 대소문자까지 일치하는
nvidia.com/dynamo-component라벨의 모델로 게시됩니다. 목록에 없거나 라벨이 없는 Pod는 게시하지 않고 메모리에만 보유합니다(준비 시점에debug로그 1회 기록). - 다중 모델 풀을 단일
model로 묶으면 서로 다른 모델 풀이 한 로테이션에 섞이게 되며,dynamo.shim.model_map으로는 하나의 Pod 집합을 두 모델로 분리할 수 없습니다. nvidia.com/dynamo-kube-discovery-mode: container어노테이션이 있는 Pod는 거부하고continuum_dynamo_discovery_events_total{kind="refused"}로 집계합니다.
인스턴스 ID 도출¶
업스트림은 hash_pod_name(lib/runtime/src/discovery/kube/utils.rs, Dynamo 1.4.2 및 1.5.0 공통)을 통해 Pod 이름 문자열의 DefaultHasher::new() 해시 하위 53비트(키가 모두 0인 SipHash-1-3에 UTF-8 바이트와 0xFF를 넣은 뒤 & 0x001F_FFFF_FFFF_FFFF)로 인스턴스 ID를 도출합니다. 라우터는 src/dynamo/kube/hash.rs에 SipHash-1-3을 직접 구현하고 실제 1.4.2 플릿의 GET /health 응답 및 업스트림 테스트 벡터(worker-0 -> 1186445869714687)로 검증합니다.
Rust 표준 라이브러리의 DefaultHasher 알고리즘은 컴파일러 버전 간 고정이 보장되지 않으므로, 매 빌드 시 로컬 구현과 라우터 툴체인의 DefaultHasher를 대조하는 테스트를 실행합니다. 향후 Dynamo가 다른 DefaultHasher를 쓰는 컴파일러로 빌드되면 프론트엔드가 알 수 없는 ID에 대해 500 Failed to generate completions를 반환합니다. 레지스트리는 소문자 16진수로 저장하고, shim은 10진수로 전송하며, Admin API는 두 형식을 모두 표시합니다.
list, watch, 재 list¶
각 패스는 Tokio 블로킹 풀에서 Kubernetes 클라이언트를 생성하고(interactiveMode: Never를 강제해 응답 없는 kubeconfig exec 플러그인이 비동기 워커를 차단하지 않도록 보호), 클러스터 내부 자격 증명을 KUBECONFIG / ~/.kube/config보다 먼저 시도합니다. 조건에 맞는 Pod를 500개 단위 페이지(최대 16384개 상한)로 list해 게시한 뒤 해당 resourceVersion부터 watch를 열며, 연결·list·watch 열기 단계 전체에 30초 타임아웃을 적용합니다.
watch 수명 주기 및 백오프 규칙:
- watch는 서버에 290초 후 종료를 요청하고 이벤트마다 320초 유휴 데드라인을 적용하여 half-open 연결이
connected: 1상태로 멈추는 현상을 방지합니다. 410 Gone, 전송 오류, 조기 EOF, 또는 290초 정상 종료로 watch가 끝나면 마지막resourceVersion에서 이어받지 않고 공유 백오프 후 전체 재 list를 수행합니다.- 세션이
min(watch_reconnect_backoff.max, 290s)이상 유지되면 백오프가 하한으로 초기화되므로 290초 주기의 정상 재연결은 백오프를 늘리지 않으며continuum_dynamo_discovery_connected도1을 유지합니다. - 최대 16384개의 Pod를 보유하며, 서로 다른 두 Pod 이름이 동일한 해시 ID로 충돌하면 둘 다
duplicate로 거부합니다.
RBAC¶
라우터에는 플릿 네임스페이스의 pods에 대한 get, list, watch 권한만 필요합니다(default 대신 전용 ServiceAccount 사용 권장). Helm 차트에서는 dynamoPodDiscovery.enabled: true로 설정하면 dynamoPodDiscovery.namespace에 Role과 RoleBinding을 생성하고 토큰 마운트 및 dynamoPodDiscovery.apiServerPorts(443, 6443) 송신 NetworkPolicy를 활성화하며, Kustomize에서는 deploy/kubernetes/overlays/dynamo-pod-discovery/ 오버레이를 사용합니다.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: continuum-router-dynamo-pod-reader
namespace: dynamo # 플릿의 네임스페이스
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
메타데이터 기반 소스를 선호해야 할 때¶
kubernetes_pods는 RBAC 제약으로 dynamoworkermetadatas나 endpointslices를 읽을 수 없을 때만 사용하십시오. 해당 권한을 부여할 수 있는 환경에서는 모델 카드와 이벤트 채널을 읽어 용량·부하 기반 선택을 수행하는 dynamo.discovery.source: kubernetes(다음 절)를 권장합니다.
리로드¶
dynamo.discovery.source와 dynamo.discovery.kubernetes의 모든 필드는 실시간 리로드됩니다.
source전환 시 실행 중인 watcher를 멈추고 레지스트리를 비운 뒤 새 소스를 시작합니다(reason="source").namespace·dynamo_namespace변경은reason="namespace",model·models변경은reason="other"로 레지스트리를 초기화하며, 유지되는 네임스페이스의 모델 매핑이 동일한dynamo_namespaces변경은 제거된 네임스페이스만 정리합니다(reason="namespaces").- 검증 시
namespace는 DNS-1123 라벨,dynamo_namespace는 비어 있지 않은 라벨 값이어야 하며,source: kubernetes_pods에서는model과models(최대 256개 고유component) 중 정확히 하나만 지정해야 합니다.
Kubernetes 메타데이터 소스¶
dynamo.discovery.source: kubernetes는 Dynamo 탐색 데몬과 동일하게 DynamoWorkerMetadata 커스텀 리소스를 EndpointSlice 준비 상태와 조인하여 읽습니다. 리소스 내부 레코드가 etcd 플릿의 레코드와 동일하므로 etcd reducer에 그대로 전달되며, 별도의 kubernetes.model 설정 없이도 전체 모델 카드, 용량, kv_metrics 및 kv-events 발행자를 모두 확보합니다.
dynamo:
enabled: true
discovery:
source: kubernetes
kubernetes:
namespace: "dynamo" # 플릿의 Kubernetes 네임스페이스
dynamo_namespace: "dynamo-solar-pro4" # 워커의 DYN_NAMESPACE
watch_reconnect_backoff:
initial: "500ms"
max: "30s"
shim:
backends: [dynamo-frontend]
소스가 읽는 것¶
와이어 규칙은 ai-dynamo/dynamo v1.4.2 및 v1.5.0(src/dynamo/kube/metadata/)을 따릅니다.
- 리소스:
namespace의dynamoworkermetadatas.nvidia.com(v1alpha1)을 라벨 셀렉터 없이 list 및 watch합니다. 각 리소스는 워커 Pod와 이름이 같고 해당 Pod가 소유하며,spec.data에 etcd 키 접미사를 키로 하는endpoints,model_cards,event_channels,event_sources맵을 담습니다(null맵은 빈 맵으로 처리). - 준비 상태:
nvidia.com/dynamo-discovery-backend=kubernetes,nvidia.com/dynamo-discovery-enabled=true라벨이 있는 EndpointSlice를 감시합니다. 선택된 슬라이스의 엔드포인트 중conditions.ready: true이고targetRef.name이 리소스 이름(Pod 이름)과 일치하는 동안에만 해당 리소스를 reducer에 반영합니다. - 레코드: 각 맵 항목을
v1/instances/,v1/mdc/,v1/event_channels/아래의 etcd reducer 이벤트로 변환하되 첫 경로 세그먼트가dynamo_namespace인 항목만 허용합니다. 1 MiB를 초과하는card_json은 분류 시점에 핵심 타입 필드로 축소하여 메모리를 아끼며,GET /admin/dynamo/workers는"signals": {"load": true, "capacity": true}를 보고합니다. - 인스턴스 ID: 모든 엔드포인트 및 모델 카드 레코드의 경로와 값에
hash_pod_name으로 도출한 ID가 일치해야 하며, 불일치 시 리소스 전체를 거부하고continuum_dynamo_discovery_events_total{kind="mismatch"}로 집계합니다(이벤트 채널의instance_id는 발행자 ID이므로 제외). - 모델: 각 워커 카드의
display_name(없으면name)을 사용합니다.kubernetes.model및kubernetes.models는 무시되며 설정 시 검증 경고를 출력합니다.
container 모드¶
업스트림의 container 탐색 모드(DYN_KUBE_DISCOVERY_MODE=container)는 컨테이너마다 <pod>-<container> 리소스를 생성하고 Pod에서 컨테이너별 준비 상태를 읽습니다. 이 소스는 pod 모드만 지원하므로 소유자 Pod 이름과 리소스 이름이 다른 리소스는 거부하고 kind="refused"로 집계합니다(main 컨테이너는 Pod 이름과 동일하므로 허용).
reducer 공급¶
watcher 수명 동안 유지되는 단일 etcd reducer에 변경 차분(diff)만 전달합니다.
- 리소스가 준비 상태가 되면 레코드를 put하고, 준비 상태가 해제되거나 삭제되면 키를 delete합니다.
- 리소스 수정 시에는 변경된 레코드만 다시 전달하고, EndpointSlice 이벤트 시에는 해당 슬라이스의 엔드포인트만 갱신하며, 전체 재 list 시에는 etcd 재동기화처럼 전체 레코드를 다시 put합니다.
- 여러 리소스가 동일한 이벤트 채널 키를 공급할 때는 참조 횟수를 추적하여 마지막 공급 리소스가 사라질 때만 키를 삭제합니다.
spec이나spec.data가 유효한 객체가 아닌 리소스는malformed로 한 번 집계하고, 일시적 오류로 워커가 사라지지 않도록 동일metadata.uid에서 마지막으로 읽은 유효 레코드를 유지합니다.
전송 주소¶
이 모드에서 엔드포인트 레코드의 transport는 워커의 Pod IP와 포트입니다. etcd 소스의 host:port와 마찬가지로 내부에만 보관되며 GET /admin/dynamo/workers나 메트릭 라벨로 노출되지 않습니다.
list, watch, 재 list, 상한¶
한 패스는 30초 시드 타임아웃 안에 연결, 리소스 페이지 조회(32개 단위, 최대 16384개), 슬라이스 페이지 조회(500개 단위, 최대 16384개), 그리고 두 watch 열기를 모두 마친 뒤에야 reducer에 반영하고 스냅샷을 게시합니다.
두 watch 모두 서버에 290초 후 종료를 요청하고 스트림별 독립적인 320초 절대 유휴 데드라인을 유지하므로 한쪽 스트림만 멈춰도 즉시 감지합니다. 어느 한쪽이라도 종료되면 공유 백오프 후 두 스트림 모두 전체 재 list를 수행합니다. 보유 리소스는 최대 16384개 및 총 레코드 크기 256 MiB로 제한되며(초과 시 새 리소스를 거부하고 malformed로 집계), 그 위에 reducer의 키스페이스별 16384개, 레코드 16 MiB, 보존 카드 1 MiB 상한이 적용됩니다.
RBAC¶
플릿 네임스페이스에서 dynamoworkermetadatas.nvidia.com 및 endpointslices.discovery.k8s.io(공유 Role의 pods 포함)에 대한 get, list, watch 권한이 필요합니다. Helm에서는 dynamoPodDiscovery.enabled: true와 dynamoPodDiscovery.metadata: true를 함께 켜고, Kustomize에서는 deploy/kubernetes/overlays/dynamo-metadata-discovery/를 적용합니다. NetworkPolicy 사용 시 워커의 kv_metrics 발행자 포트로 나가는 트래픽도 허용해야 합니다.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: continuum-router-dynamo-pod-reader
namespace: dynamo # 플릿의 네임스페이스
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: ["nvidia.com"]
resources: ["dynamoworkermetadatas"]
verbs: ["get", "list", "watch"]
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
리로드¶
리로드 규칙은 kubernetes_pods와 동일하되, source: kubernetes에서는 kubernetes.model 및 kubernetes.models를 사용하지 않으므로 해당 필드만 수정한 경우에는 watcher 재시작이나 레지스트리 초기화를 수행하지 않습니다.
픽스처¶
src/dynamo/kube/metadata/state_tests.rs 단위 테스트는 실제 Pod 이름 4개를 기반으로 합성한 픽스처(src/dynamo/kube/fixtures/*_synthesized.json)를 사용하여 동등한 etcd 키/값 픽스처와 동일한 플릿 뷰가 생성되는지 검증합니다.
부하 신호¶
탐색이 어떤 워커가 존재하는지 알려 준다면, 두 번째 채널은 각 워커의 실시간 부하를 전달합니다. 모든 dynamo.vllm 워커는 부하가 변할 때마다 Dynamo 이벤트 플레인에 ActiveLoad 샘플을 발행하며, 라우터는 ZMQ(또는 클러스터 외부 실행 시 클러스터 내부 이벤트 브로커)를 통해 각 발행자를 구독하고 워커 랭크별 최신 샘플을 유지합니다(src/dynamo/load/, ai-dynamo/dynamo 커밋 ff3ac59e83c73e03b98a5d0ec192ec28847130f7 기준).
이벤트 채널 키¶
발행자는 세 번째 감시 프리픽스 아래에 등록됩니다.
| 프리픽스 | 레코드 | 용도 |
|---|---|---|
v1/event_channels/namespace/<ns>/ | {"type": "EventChannel", ...} | 이벤트 플레인 발행자당 레코드 하나 |
엔드포인트 범위 발행자(EventPublisher::for_endpoint)는 v1/event_channels/namespace/<ns>/component/<comp>/endpoint/<ep>/topic/kv_metrics/<16진수 발행자 ID> 키를 사용합니다(컴포넌트·네임스페이스 범위 키도 허용). 각 세그먼트는 ASCII 영숫자와 -, _만 유지하고 나머지를 %XX로 인코딩합니다(예: ns.one -> ns%2Eone).
{
"type": "EventChannel",
"scope": {
"kind": "endpoint",
"endpoint": { "namespace": "dynamo", "component": "decode", "name": "generate" }
},
"topic": "kv_metrics",
"instance_id": 2046539,
"transport": { "kind": "Zmq", "config": { "endpoint": "tcp://10.0.0.7:41000" } }
}
채널 필터링 규칙:
instance_id는 워커 ID가 아니라 발행자 인카네이션 ID(publisher_id)입니다.tcp://엔드포인트를 가진 직접Zmq전송 채널만 구독 대상이 됩니다.ZmqBroker,Nats, 알 수 없는 전송·범위 종류, ID 누락,tcp://가 아닌 엔드포인트는 키당 한 번 로그를 남기고unsupported또는malformed로 집계한 뒤 건너뜁니다.- 지원하지 않는 토픽(
active_sequences_events)은 조용히 건너뛰며, 동일한publisher_id를 가진 두 채널은 모두duplicate로 거부합니다.
토픽 필터, 프레임, 봉투¶
직접 ZMQ 모드에서 SUB 소켓은 ZMQ 프레임 0 및 봉투의 topic 필드와 일치하는 순수 토픽 kv_metrics를 구독합니다. 점(.)으로 연결된 EventScope::subject(topic)(namespace.<ns>.component.<comp>.endpoint.<ep>.kv_metrics)는 업스트림에서 NATS 및 XSUB 브로커 모드에만 쓰이므로 라우터는 진단용으로만 보관하고 ZMQ SUB 프리픽스 필터로는 사용하지 않습니다.
ZMQ 메시지 하나는 4개의 프레임으로 구성됩니다.
[ 토픽 바이트 ][ publisher_id: u64 BE ][ sequence: u64 BE ][ frame ]
frame = version: u8 (1) | payload_len: u32 BE | payload (payload_len 바이트)
페이로드는 msgpack EventEnvelope { publisher_id, sequence, published_at (Unix ms), topic, payload: bytes }이며 내부 payload는 ActiveLoad 구조체입니다(N-2 호환을 위해 문자열 키 맵과 위치 기반 배열 형식을 모두 허용).
| 필드 | 타입 | 의미 |
|---|---|---|
worker_id | u64 | 워커의 탐색 인스턴스 ID (Endpoint.instance_id) |
dp_rank | u32 | 워커 내 데이터 병렬 랭크 (생략 시 0) |
active_decode_blocks | u64 또는 nil | 스케줄러가 보고한 decode 블록 부하 |
active_prefill_tokens | u64 또는 nil | 스케줄러가 보고한 prefill 토큰 부하 (워커에서는 항상 nil) |
kv_used_blocks | u64 또는 nil | 워커에서 사용 중인 KV 블록 수 (점유율 기준값) |
유효하지 않은 프레임은 구독을 끊지 않고 폐기하며 continuum_dynamo_load_events_total{result}에 기록합니다.
malformed: 프레임이 4개가 아니거나 ID 프레임이 8바이트가 아니거나 msgpack 디코딩에 실패한 경우.unknown_version: 프레임 헤더 버전이1이 아닌 경우.topic_mismatch: 프레임 0 또는 봉투의topic이kv_metrics가 아닌 경우.oversized: 선언된 페이로드 길이가 16 KiB를 초과한 경우(파싱 전 거부).identity_mismatch: 프레임과 봉투의 발행자 ID가 다르거나 탐색 레코드의 발행자 ID와 다르거나worker_id가 레지스트리 스냅샷에 없는 경우.over_capacity: 부하 스토어가 65536개 항목 상한에 도달한 경우.
중복 제거와 순서¶
메시지는 구독별로 sequence 번호를 기준으로 중복 제거됩니다. 순서가 보장되는 단일 TCP 스트림에서 이미 적용된 최대 시퀀스 이하의 메시지는 duplicate로 폐기합니다. 스토어는 (worker_id, dp_rank) 키별로도 동일한 시퀀스 검사를 수행하되, 발행자가 재시작되어 다른 publisher_id로 보낸 샘플은 시퀀스 번호와 무관하게 기존 값을 대체하도록 허용합니다.
신선도와 최선 노력 계약¶
스토어는 (worker_id, dp_rank)별 마지막 샘플과 수신 시각을 보관합니다. fresh(worker_id, dp_rank, max_age)는 샘플이 max_age 이내일 때만 반환하며, fresh_default는 dynamo.load.max_staleness(기본값 3s, 허용 범위 100ms~5m, 재시작 없이 즉시 핫 리로드)를 사용합니다.
업스트림 발행자는 주기적이 아니라 변경 기반(1ms 디바운스, 부하가 일정하면 침묵)이며 최선 노력 방식으로 동작하므로, picker는 신선한 샘플이 없는 워커를 다음과 같이 취급합니다.
- 조용한(quiet) 워커 (발행한 적이 없거나 마지막 샘플의 백로그가 0): 유휴 상태가 된 워커는 0을 발행한 뒤 침묵하므로, 백로그 0인 유휴 워커로 보고 샘플이 있는 워커와 같은 비용으로 경쟁합니다.
- 부하를 가진 채 침묵한 워커 (마지막 샘플에 백로그가 있음): 엔진이 멈춘 상태일 수 있으므로 비용을 정의하지 않으며, 비용이 정의된 다른 워커가 있으면 선택하지 않습니다.
- 신선한 샘플이 있는 워커가 하나도 없을 때: 부하 스트림 자체가 침묵했거나 끊긴 상태이므로, 단계별 라운드 로빈 커서가 모든 워커 중에서 결정합니다(
round_robin_stale).
라우터는 각 워커의 마지막 샘플 이후 그 워커로 보낸 요청도 함께 셉니다.
- 응답 없는 디스패치: shim으로 선택된 요청은 해당 워커에서 샘플이 올 때까지, 최대 30초 동안 프롬프트 토큰(prefill)과 블록(decode)을 그 워커의 백로그에 더합니다. 따라서 두 샘플 사이에 몰린 요청이나, 발행하지 않는 조용한 워커(자기 구독만 끊긴 경우 등)도 다른 워커의 부하만큼만 채워지고 모든 요청을 받지 않습니다.
- 멈춘 워커: 2블록 이상인 요청을 받고 5초 동안 샘플을 보내지 않은 워커는 멈춘 것일 수 있습니다. 샘플이 오거나 그 요청이 만료될 때까지, 비용이 있는 다른 워커가 있으면 선택하지 않습니다. 두 가지 요청은 판정에 넣지 않습니다. prefill/decode 쌍의 decode 워커에 보낸 요청은 prefill이 끝난 뒤에야 전달되고, 이미 끝난 요청(프런트엔드가 거부했거나 실패했거나 취소된 요청)은 엔진에 닿지 않았을 수 있기 때문입니다. 신선한 샘플을 가진 워커가 하나도 없으면 어떤 요청에도 응답할 샘플이 올 수 없으므로, 두 picker 모두 멈춤 판정을 무시합니다.
- 놓친 메시지:
kv_metrics구독은 변화를 놓쳤을 수 있을 때마다 그 발행자에게서 받아 둔 샘플을 버립니다. 놓친 변화(바쁜 워커가 유휴가 된 경우 등) 때문에 워커가 계속 제외되는 것을 막기 위해서입니다.- 연결할 때마다 버립니다. transport 전환이나 발행자 레코드 변경으로 다시 시작된 구독의 첫 연결도 포함합니다.
- sequence 공백을 발견할 때마다 버립니다.
레지스트리 세대가 바뀔 때마다 부하 수퍼바이저는 스토어의 활성 워커 허용 게이트 갱신과 이탈 워커 샘플·게이지 시리즈 제거를 단일 호출(set_live_and_retain)로 원자적으로 수행합니다.
구독은 레지스트리를 따라갑니다¶
부하 수퍼바이저는 스냅샷의 발행자마다 SUB 태스크 하나를 관리합니다.
- 수명 주기: 새 발행자가 나타나면 태스크를 시작하고, 사라지거나 설정이 바뀐 발행자의 태스크는 최대 5초 유예 후 동시에 종료합니다. 연결·수신 실패 시에는 풀 지터 지수 백오프(
500ms~30s)로 재연결합니다. - 리소스 상한: 플릿이 작성한 레코드가 프로세스 메모리나 파일 디스크립터를 고갈시키지 않도록 부하 스토어는
(worker, dp_rank)키 65536개, 동시 구독은 소켓 1024개(publisher_id오름차순 허용)로 제한됩니다.
유계 전송 계층¶
플릿이 광고하는 tcp://ip:port 엔드포인트는 비신뢰 입력이고 릴리스 빌드는 panic = "abort"로 동작하므로, 라우터는 프로덕션에서 zeromq 0.6 크레이트(선언된 64비트 프레임 길이를 상한 없이 사전 할당함)를 쓰지 않고 tokio::net::TcpStream 위에 자체 유계 ZMTP 3.x SUB 클라이언트(src/dynamo/load/zmtp.rs)를 구현합니다.
버퍼를 할당하기 전에 검사하는 컴파일 타임 상한은 다음과 같습니다.
| 상한 | 값 | 거부 대상 |
|---|---|---|
MAX_FRAME_BYTES | 32 KiB | 32 KiB를 초과하는 메시지 프레임 선언 길이 (KV_EVENTS는 1 MiB) |
MAX_COMMAND_BYTES | 4 KiB | 4 KiB를 초과하는 커맨드 프레임 선언 길이 (READY만 처리하고 이후 커맨드는 건너뜀) |
MAX_FRAMES_PER_MESSAGE | 8 | 8프레임을 초과하는 more 플래그 연쇄 |
MAX_MESSAGE_BYTES | 64 KiB | 프레임 합계가 64 KiB를 초과하는 메시지 |
MAX_COMMANDS_PER_WINDOW | 256 / 1s | 핸드셰이크 이후 초당 256개를 초과하는 커맨드 프레임 전송 |
메모리 및 연결 처리 방식:
- 온디맨드 버퍼 확장: 프레임 본문 버퍼는 512바이트에서 시작해 선언 길이가 상한을 통과한 뒤에만 2의 거듭제곱으로 최대
MAX_FRAME_BYTES까지 늘어납니다. 2 KiB 읽기 버퍼 및 64바이트 쓰기 버퍼와 합쳐 유휴 구독당 2,624바이트(1024개 기준 약 2.6 MiB)만 사용합니다. - 프로토콜 범위: ZMTP 3.x NULL 보안, SUB
READY, ZMTP 3.0 단일 프레임 구독(0x01+ 토픽)만 지원합니다. 위반 시continuum_dynamo_load_events_total{result="transport_refused"}로 집계하고 백오프 재연결로 전환하며, 세션이 최소 30초 이상 유지된 뒤에만 백오프 하한으로 초기화합니다. - 호스트 이름 해석: IP 리터럴 대신 호스트 이름을 사용하는 엔드포인트는 10초
CONNECT_TIMEOUT아래 Tokio 블로킹 풀에서 DNS를 조회합니다.
부하 메트릭¶
| 메트릭 | 타입 | 라벨 | 의미 |
|---|---|---|---|
continuum_dynamo_load_subscriptions | gauge | state | 수명 주기 상태별 구독 수: connecting, connected, backoff (브로커 지원 시 pending 및 transport 라벨 포함) |
continuum_dynamo_load_events_total | counter | result | 결과별 메시지 수: decoded, duplicate, malformed, unknown_version, topic_mismatch, oversized, identity_mismatch, over_capacity, transport_refused |
continuum_dynamo_worker_active_decode_blocks | gauge | namespace, worker, dp_rank | 워커 랭크별 마지막 active_decode_blocks |
continuum_dynamo_worker_active_prefill_tokens | gauge | namespace, worker, dp_rank | 워커 랭크별 마지막 active_prefill_tokens |
continuum_dynamo_worker_kv_used_blocks | gauge | namespace, worker, dp_rank | 워커 랭크별 마지막 kv_used_blocks |
워커별 게이지 라벨은 플릿 크기로 제한되며(namespace는 최대 16개 설정값, worker는 소문자 16진수, dp_rank는 10진수), 샘플에 nil인 필드는 시리즈를 생성하지 않고 이탈한 워커의 시리즈는 즉시 삭제됩니다. GET /admin/dynamo/workers는 내부 tcp:// 주소 없이 load_publishers 개수만 노출합니다.
direct 모드 shim을 통한 라우팅¶
Dynamo 플릿으로 향하는 모든 chat completion 요청에 대해 라우터가 prefill 워커와 decode 워커를 직접 선택한 뒤, 해당 선택을 실행하는 dynamo.frontend --router-mode direct shim으로 디스패치합니다.
shim을 두는 이유¶
dynamo.vllm 워커는 토큰 입력 기반의 Dynamo request-plane 프로토콜만 수용하며 HTTP 텍스트 모드에서는 분리 서빙을 지원하지 않습니다. --router-mode direct로 실행된 프론트엔드는 Dynamo의 외부 picker 계약을 제공하여, x-dynamo-* 워커 고정 헤더가 담긴 일반 OpenAI HTTP 요청을 받아 토큰화, 1토큰 prefill 홉, disaggregated_params 주입, decode 홉, 디토크나이즈, SSE 스트리밍을 처리합니다.
각 shim 복제본은 dynamo.shim.backends(또는 dynamo.shim.pools)에 등록된 일반 OpenAI 호환 백엔드(role: unified)이므로 헬스 체크, 서킷 브레이커, 속도 제한, 허브 자격 증명 바인딩, 키별 allowed_backends 정책이 그대로 적용됩니다.
요청 흐름¶
- 분리 서빙 오케스트레이터(
disaggregated_serving.enabled: true)가 스트리밍(stream: true) 및 버퍼링 요청을 모두 검토합니다. dynamo기능이 활성화되어 있고 사용 가능한 shim 복제본이 하나 이상 있으면, 복제본이 남은 네임스페이스를 대상으로 picker가 실행됩니다. 클라이언트 모델 ID는dynamo.shim.model_map을 통해 후보 워커 매칭에만 변환되며, 요청 본문은 프론트엔드가 해석할 수 있도록 원래 모델 ID를 그대로 유지합니다.- picker는
decode·aggregated워커에서 decode(instance, dp_rank)를 선택하고, 선택된 워커가decode워커이면 같은 네임스페이스의prefill워커에서 prefill(instance, dp_rank)를 선택합니다. prefill 워커를 선택하지 않으면(decode 쪽이aggregated워커이거나 모델의 prefill 워커가 없는 경우) decode 워커만 고정합니다(aggregated 폴백). prefill/decode 풀 옆의aggregated워커 규칙은 혼합 네임스페이스를 참고하세요. 모델을 서빙하는 워커가 전혀 없으면 일반 분리 서빙 또는 통합 라우팅으로 넘어갑니다. - 클라이언트가 보낸
x-dynamo-*헤더를 모두 제거한 뒤 아웃바운드 요청에만 picker가 선택한 헤더를 기록하여 해당 네임스페이스의 shim 풀로 디스패치합니다. - shim의 버퍼링 또는 SSE 스트리밍 응답을 표준 파이프라인으로 중계하며 아래의 라우팅 응답 헤더를 추가합니다.
아웃바운드 헤더 계약¶
| 헤더 | 값 | 전송 조건 |
|---|---|---|
x-dynamo-prefill-instance-id | prefill 워커의 인스턴스 ID (10진수 lease ID) | prefill 워커가 선택되었을 때 |
x-dynamo-prefill-dp-rank | prefill 워커의 데이터 병렬 랭크 | 해당 워커 카드가 data_parallel_size를 선언했을 때 |
x-dynamo-worker-instance-id | decode(또는 aggregated) 워커의 인스턴스 ID (10진수 lease ID) | 항상 |
x-dynamo-dp-rank | decode 워커의 데이터 병렬 랭크 | 해당 워커 카드가 data_parallel_size를 선언했을 때 |
헤더 형식 및 보안 규칙:
- 10진수 lease ID: 프론트엔드는 16진수 ID를 헤더 없음으로 취급하므로 두 인스턴스 ID 헤더는 반드시 10진수 문자열로 전송합니다. 카드에
data_parallel_size가 선언되어 있으면 랭크0을 포함해 항상 랭크 헤더를 보냅니다. - 클라이언트 헤더 무조건 제거: 클라이언트가 보낸 모든
x-dynamo-*헤더는 빌드 기능이나 설정 여부와 무관하게 상시 컴파일 코드(전역 API 미들웨어src/http/middleware/dynamo_shim_headers.rs, chat 인그레스, shim 디스패치 직전)에서 모두 제거됩니다. - 응답 헤더 공개 정책:
x-dynamo-*헤더는 클라이언트로 반환되지 않으며, 워커 및 백엔드 식별자는 클라이언트가x-backend요청 헤더(빈 값 포함)를 보냈을 때만 응답에 포함됩니다.
| 헤더 | 값 | 전송 조건 |
|---|---|---|
x-continuum-routing-path | prefill_then_decode, aggregated 폴백에서는 unified | 항상 (백엔드가 아니라 라우팅 경로를 표시) |
x-continuum-prefill-backend | dynamo:<instance_id>[/dp<rank>] | 클라이언트가 x-backend를 보냈고 prefill 워커가 선택되었을 때 |
x-continuum-decode-backend | dynamo:<instance_id>[/dp<rank>] | 클라이언트가 x-backend를 보냈을 때 |
x-served-backend | 응답한 shim 복제본 이름 | 클라이언트가 x-backend를 보냈고 응답한 복제본을 특정할 수 있을 때 |
선택¶
기본 LoadBasedPicker(src/dynamo/picker.rs)는 KV 오버랩이 없을 때의 Dynamo 부하 전용 비용 공식을 따릅니다.
- prefill 비용 =
active_prefill_tokens / max_num_batched_tokens - decode 비용 =
(active_decode_blocks + ceil(estimated_prompt_tokens / block_size)) / total_kv_blocks
비용 평가 및 타이브레이킹 규칙:
estimated_prompt_tokens는 토크나이저 없이 문자/단어 휴리스틱으로 계산합니다.active_decode_blocks가 nil이면(실제 Dynamo 1.4.2dynamo.vllm워커 등)kv_used_blocks를 대신 사용하며,data_parallel_size > 1인 워커는dp_rank마다 독립 후보가 됩니다.- 비용을 계산하려면 모델 카드에 필수 용량 필드가 있어야 합니다. 백로그는
dynamo.load.max_staleness이내의 샘플에서 가져오며, 조용한 후보(신선도와 최선 노력 계약 참고)의 백로그는 0입니다. 그 밖에 해당 단계의 필드를 담은 신선한 샘플이 없는 후보(dynamo.vllm의 nilactive_prefill_tokens등)는 비용이 정의되지 않습니다. - 해당 단계의 필드를 담은 신선한 샘플이 있는 후보가 하나라도 있으면 정의된 비용이 가장 낮은 후보가 선택되고, 비용이 같으면 단계별 라운드 로빈 커서로 순환합니다. 비용이 정의되지 않은 후보는 선택되지 않습니다.
- 그런 후보가 하나도 없으면(마지막 샘플이
max_staleness를 넘긴 유휴 플릿, 끊긴 부하 스트림,dynamo.vllmprefill 풀 등) 단계별 라운드 로빈 커서가 모든 후보를 순환합니다.
각 선택은 continuum_dynamo_selection_total{role, reason}(role: prefill, decode)으로 집계됩니다.
| 사유 | 의미 |
|---|---|
load | 여러 후보 간 신선한 부하 비교로 결정 |
round_robin_stale | 신선한 샘플을 가진 후보가 없어 라운드 로빈 커서로 결정 |
single_candidate | 후보가 정확히 하나뿐인 경우 |
no_prefill_pool | decode 워커와 짝지은 prefill 워커가 없어(해당 네임스페이스에 모델의 prefill 워커가 없거나 decode 워커가 aggregated인 경우) x-dynamo-worker-instance-id만 전송 (prefill에만 집계) |
혼합 네임스페이스¶
Dynamo에는 prefill 워커와 aggregated 워커를 짝지어 처리하는 그래프가 없으므로, picker는 x-dynamo-prefill-instance-id를 aggregated 워커의 인스턴스 ID와 함께 보내지 않습니다. 두 picker 모두 네임스페이스와 모델 단위로 같은 규칙을 적용합니다.
prefill과decode워커가 모두 있는 네임스페이스: 해당 네임스페이스의aggregated워커는 decode 후보에서 제외되며, 요청은prefill워커와decode워커 쌍으로 처리됩니다.- 그 밖의 네임스페이스:
decode·aggregated워커가 모두 decode 후보입니다. aggregated워커가 decode로 선택된 경우: 네임스페이스에prefill워커가 있어도 prefill 워커를 선택하지 않으며,{role="prefill", reason="no_prefill_pool"}로 집계합니다.
발행된 플릿 뷰에서 한 네임스페이스가 처음으로 한 모델을 prefill 또는 decode 워커와 aggregated 워커로 함께 서빙하게 되면, 레지스트리는 네임스페이스·모델·역할별 워커 수를 담은 경고를 한 번 기록합니다. Dynamo 1.5.0 dynamo.frontend --router-mode direct는 요청마다 워커 세트 하나를 무작위로 고르고 다른 세트의 인스턴스 ID를 거부하므로 이런 플릿을 서빙할 수 없습니다. 네임스페이스가 혼합 상태로 유지되는 동안에는 경고를 반복하지 않습니다.
한 번의 발행에서는 (네임스페이스, 모델) 쌍을 최대 8개까지만 개별 경고로 기록합니다. 모델 이름은 etcd에서 오므로, 같은 발행에서 새로 혼합 상태가 된 나머지 쌍은 생략된 쌍과 기록된 쌍의 개수를 담은 경고 한 줄로 요약합니다.
DynamoWorkerPicker는 registry.picker() / set_picker()로 교체할 수 있어 AppState 변경 없이 KV 인지 picker가 기본 구현을 대체합니다.
실패 의미론¶
- 연결 재시도: shim 연결 실패 시 재시도 예산 안에서 동일한 워커 고정 헤더로 풀 내 다른 복제본에 재시도하며, 같은 요청에서 워커를 다시 선택하지는 않습니다.
- 스트림 도중 폴백: 스트리밍 시작 이후에는 워커 재선택 없이 기존 중간 스트림 폴백 규칙을 그대로 따릅니다.
- Direct 모드 prefill 거부 (
503): 버퍼링 요청에서 프론트엔드가Prefill worker ID required in Direct routing mode(src/proxy/disaggregated_dynamo.rs의DIRECT_MODE_PREFILL_ID_ERROR)로 거부하면 플릿 뷰가 어긋난 것이므로 재시도하지 않고503(disaggregated_prefill_decode_unavailable)을 반환하며disaggregated_errors_total{phase="orchestration"}을 증가시킵니다(일반 500 본문으로 감싸진 경우에는 아래 decode 실패 경로를 따릅니다). - 그 밖의 shim 실패:
disaggregated_errors_total{phase="decode"}로 집계한 뒤disaggregated_serving.fallback_to_unified가 켜져 있으면 인스턴스 ID 헤더 없이 통합 폴백을 수행하고, 꺼져 있으면disaggregated_decode_failed를 반환합니다. 스트리밍 요청에서는 스트림 시작 전의 전송 실패, 5xx, 429, 408만 폴백 대상이 되며 그 외 4xx는 그대로 전달됩니다. - 단계 히스토그램: prefill과 decode 단계가 shim 내부에서 실행되므로
disaggregated_prefill_duration_seconds등 단계별 히스토그램은 기록되지 않으며disaggregated_requests_total{routing_path}만 요청당 1회 집계됩니다.
Anthropic Messages 및 Responses 인그레스¶
Anthropic Messages 인그레스(/anthropic/v1/messages, /v1/messages)와 Responses 인그레스(/v1/responses)도 shim 백엔드로 전송할 때 동일하게 워커를 고정합니다(src/proxy/disaggregated_ingress.rs).
- 변환된 chat 전송 (
pin_converted_chat_dispatch):/v1/chat/completions본문으로 변환된 요청을 보내기 직전, 인그레스가 고른 단일 백엔드로 후보를 좁혀 최종 본문(global_prompts및 system 메시지 병합 반영 후)을 기준으로 워커를 선택하고x-dynamo-*헤더와nvext필드를 기록합니다. - 전송 단위 선택: 라우터가 관리하는
web_search/web_search_20250305루프의 각 라운드와 모든 재시도·폴백 시도마다 새로 pin을 선택하며, 클라이언트가 보낸x-dynamo-*헤더와nvext.token_data는 항상 제거합니다. - 네이티브
/v1/responses패스스루 (pin_responses_passthrough_dispatch):type: openai또는responses_api: native로 설정된 shim 복제본(또는responses_only모델)으로/v1/responses본문을 그대로 보낼 때는 모델 이름만으로 워커를 선택하여x-dynamo-*헤더만 부착하고nvext.token_data는 제거합니다. - 오류 매핑 및 제약: 버퍼링 전송과 비-2xx 스트리밍 헤더의 direct 모드 거부는 각 인그레스의 오류 형식(
disaggregated_prefill_decode_unavailable)으로503을 반환합니다. 다만 이미 열린 200 SSE 스트림 내부의 거부와 Unix 소켓 스트리밍 백엔드의 비-2xx 응답은 그대로 중계되며,POST /v1/responses/compact에는 pin이 적용되지 않습니다.
설정¶
disaggregated_serving:
enabled: true
fallback_to_unified: false
backends:
- name: dynamo-frontend
type: openai
url: "http://dynamo-frontend.dynamo.svc:8000"
role: unified
models: ["Qwen/Qwen3-32B"] # 프론트엔드가 해석할 수 있는 id
dynamo:
enabled: true
discovery:
etcd:
endpoints: ["http://etcd.dynamo.svc:2379"]
shim:
backends: [dynamo-frontend]
shim.backends, shim.pools, shim.model_map은 재시작이나 레지스트리 초기화 없이 즉시 반영됩니다. 실행 가능한 전체 템플릿은 docs/shared/llm-skills/config-templates/dynamo-direct-shim.yaml을 참고하십시오.
KV 오버랩 인지 prefill 선택¶
옵트인 빌드 기능 dynamo-kv(공식 릴리스 바이너리에 포함)와 dynamo.kv.enabled: true를 켜면 라우터가 워커의 블록 단위 kv-events 스트림을 인덱싱하여 요청의 프롬프트 프리픽스 블록을 이미 보유한 prefill 워커(모델에 prefill/decode 풀이 없으면 aggregated 워커)에 비용 크레딧을 부여합니다(src/dynamo/kv/, ai-dynamo/dynamo v1.5.0 기준).
panic = "abort" 환경에서 메모리를 안전하게 제한하면서 워커 캐시 상태를 바이트 단위로 일치시키기 위해 다음과 같이 의존성을 구성했습니다.
- 블록 해싱: 업스트림의
dynamo-kv-hashing1.6.1크레이트를 그대로 사용합니다. - Radix 인덱스 및 비용 공식: 메모리 상한이 없고 잘못된 이벤트에 패닉할 수 있는
dynamo-kv-router대신src/dynamo/kv/에 컴파일 타임 상한을 둔 로컬 구현을 사용합니다. - 토크나이저 및 템플릿: C/C++ 의존성(
onig_sys,esaxx-rs)과hf-hub를 끌어오는 래퍼 크레이트 대신 순수 Rust인tokenizers와minijinja를 직접 사용합니다.
무엇이 인덱싱되는가¶
--kv-events-config(dynamo.vllm) 또는 --publish-kv-events(TensorRT-LLM)로 시작된 워커는 v1/event_channels/… 아래에 kv-events 채널을 등록합니다(GET /admin/dynamo/workers의 kv_event_publishers). KV 수퍼바이저는 유계 ZMTP 클라이언트의 ZmtpLimits::KV_EVENTS(최대 128개 이벤트 또는 8192개 블록 배치를 수용하는 프레임당 1 MiB 상한)와 continuum_dynamo_kv_subscriptions{state} 게이지, 그리고 1024개 구독 상한 아래에서 각 발행자를 구독합니다.
디코딩된 각 RouterEvent { worker_id, storage_tier, event: { event_id, dp_rank, data } }는 device 계층 radix 인덱스(src/dynamo/kv/index.rs, 다른 계층은 other_tier로 집계)를 갱신합니다.
| 이벤트 | 인덱스 반영 동작 |
|---|---|
stored { parent_hash, blocks: [{block_hash, tokens_hash}] } | 체인을 루트(parent_hash nil) 또는 해당 rank의 조회 테이블이 parent_hash로 가리키는 노드 아래에 연결합니다. 인덱스에 없는 부모 해시이면 배치 전체를 orphan_parent로 거부합니다 |
removed { block_hashes } | 지정된 각 블록에서 해당 rank의 소유권을 해제하고(보유하지 않은 블록은 unknown_block), 소유자와 자식 노드가 모두 없어진 노드 및 조상 노드를 해제합니다 |
cleared | 해당 rank가 보유한 모든 블록의 소유권을 해제합니다 |
인덱스 구조 및 컴파일 타임 메모리 상한:
- 프리픽스 매칭: 루트부터
tokens_hash경로로 노드를 구성하고 rank별 엔진block_hash(ExternalSequenceBlockHash) 조회 테이블(동일 노드를 두 엔진 해시가 가리킬 수 있으므로 참조 횟수 관리)을 유지합니다.find_matches는 루트부터 연속으로 일치하는 가장 긴 프리픽스 블록 수를 rank별로 계산합니다. - 용량 상한:
MAX_INDEX_BLOCKS(1,048,576 노드),MAX_BLOCKS_PER_RANK(262,144),MAX_INDEX_OWNERSHIPS(총 8,388,608(rank, block)소유권),MAX_OWNERS_PER_NODE(노드당 4096 랭크), 구독당 4096개(worker, dp_rank)커서로 제한되며 상한을 넘는 블록은over_capacity로 거부합니다. - 활성 식별자 게이트: 레지스트리의 활성
worker_id와 모델 카드가 선언한dp_rank에 해당하는 이벤트만 허용하고(set_live_and_retain으로 이탈 워커 정리와 동시에 갱신), 그 외는identity_mismatch로 거부합니다.
갭, 그리고 스냅샷 복구가 없는 이유¶
event_id는 발행자마다 단조 증가합니다. 핸들러는 rank별 커서를 유지하여 커서 이하의 event_id는 재전송으로 건너뛰고, 번호가 건너뛰면 이벤트 유실로 판단해 해당 rank의 블록을 모두 비운 뒤 continuum_dynamo_kv_events_total{result="gap"}으로 집계합니다. 이후 루트에서 시작하는 체인이 들어오면 다시 인덱스가 채워집니다.
업스트림 라우터와 달리 갭 발생 시 Dynamo 내부 요청 플레인(worker_kv_query_source_<publisher id>)으로 스냅샷을 요청하지 않습니다. 비우고 다시 채우는 방식만으로도 일시적으로 부하 기반 선택으로 돌아갈 뿐 잘못된 워커를 선택하지 않으며, 라우터가 내부 요청 플레인 프로토콜을 구현할 필요가 없습니다.
토크나이저 의존성과 캐시¶
dynamo.kv.enabled가 켜지면 정적 설정이 아니라 워커가 등록한 모델 카드의 tokenizer, prompt_formatter, chat_template_file 슬롯(CheckedFile { path, checksum: "blake3:<64 hex>", size })에서 토크나이저와 채팅 템플릿을 가져옵니다.
- URI 해석 및 내용 주소화 캐시: 로컬 경로와
file://은 항상 읽고,http(s)://워커 메타데이터 URL은kv.allow_http_artifacts: true(기본값true)일 때 가져오며,hf://참조는kv.allow_hf_artifacts: true(기본값false)일 때만 Hugging Face Hub(HF_ENDPOINT/HF_TOKEN)에서 가져옵니다. 64 MiB를 초과하는 아티팩트는 거부하며, 다운로드 시점과kv.artifact_cache_dir/blobs/<checksum>(소유자 전용)에서 읽을 때마다 blake3 체크섬을 검증합니다. - 운영자 핀 고정 필수 (
kv.pinned_artifacts): 모델 카드와 채팅 템플릿은 플릿에서 온 코드이므로, 아티팩트의 blake3 체크섬이kv.pinned_artifacts(기본값 빈 목록)에 등록되어 있을 때만 다운로드·캐시·로드·렌더링을 수행합니다. 핀되지 않은 아티팩트는continuum_dynamo_kv_artifacts_total{result="unpinned"}및continuum_dynamo_kv_selection_total{result="artifact_unpinned"}로 집계하고 슬롯·URI 종류·선언된 체크섬을 모델당 한 번 로그에 남겨 운영자가b3sum으로 검토 후 핀할 수 있게 합니다. - 백그라운드 로드 및 플릿 합의: KV 수퍼바이저는 블로킹 풀에서 토크나이저를 로드합니다(
ArtifactKey기준 공유, 최대 16개 로드, 동시 로드 2개, 총 아티팩트 256 MiB 상한, 실패 시 5분 후 재시도). 모델을 서빙하는 모든 워커가 동일한 아티팩트에 합의하고 로드가 완료된 경우에만 KV 항을 계산하며, 롤링 업그레이드 등으로 아티팩트가 다르거나 아직 로드 중이면tokenizer_unavailable로 집계하고 부하 기반 선택으로 폴백합니다.
프롬프트 렌더링 및 블록 해싱 보호 한도:
- 블로킹 풀 유계 렌더링: 첫 shim 복제본의
global_prompts및requires_single_leading_system재작성을 반영한 본문에 대해 블로킹 풀에서 코어당 퍼밋(4~32개, 퍼밋 대기 20ms, 전체 2초 제한)을 얻어minijinja채팅 템플릿(명령 예산fuel5,000,000개, 파일 로더 없음, 출력 1 MiB 상한)과tokenizers인코딩(최대 1,048,576 토큰)을 실행합니다. 상한 초과 시prompt_too_large또는render_unavailable로 폴백합니다. - XXH3-64 블록 해싱:
dynamo-kv-hashing의Request::block_hashes(완전한 블록의 little-endianu32토큰 ID에 대한 XXH3-64, 시드는1337+lora_name+ 요청의cache_salt)를 모델 내 워커들이 사용하는 유효kv_cache_block_size(16..=65536, 요청당 최대 2개 크기)마다 계산합니다. 지원 범위 밖의 블록 크기는 모델당 1회 로그에 남기고continuum_dynamo_kv_artifacts_total{result="unsupported"}로 집계합니다.
공식¶
KvAwarePicker(src/dynamo/kv/picker.rs)는 블록 크기 bs인 후보 rank와 isl 토큰 길이의 요청에 대해 업스트림의 worker_logit 비용을 계산합니다.
overlap_blocks = rank가 보유한 요청 프리픽스 블록 수
cached_tokens = min(overlap_blocks * bs, isl)
raw_prefill_tokens = active_prefill_tokens + (isl - cached_tokens) + cached_tokens
raw_prefill_blocks = raw_prefill_tokens / bs
overlap_credit_blocks = overlap_score_credit * overlap_blocks
adjusted_prefill = max(0, raw_prefill_blocks - overlap_credit_blocks)
decode_cost_blocks = active_decode_blocks + max(0, ceil(isl / bs) - overlap_blocks)
cost = prefill_load_scale * adjusted_prefill + decode_cost_blocks
선택 규칙:
kv.router_temperature: 0.0에서는 최소 비용 후보가 선택되며(동점 시 단계별 결정적 커서),0.0보다 크면softmax_sample_index와 동일하게 소프트맥스 샘플링합니다.- 해당 단계의 백로그를 담은 신선한 부하 샘플(
dynamo.load.max_staleness이내)을 가진 rank가 하나라도 있으면, 그 rank들과 조용한 rank(백로그 0)가 전체 공식으로 경쟁하고, 나머지 rank는 제외됩니다. 신선한 prefill 백로그를 가진 rank가 하나도 없으면(active_prefill_tokens를 nil로 보내는dynamo.vllm플릿 등) 모든 rank가 백로그를 뺀 오버랩 및 요청 크기 공식으로 경쟁하며 동점일 때만round_robin_stale로 순환합니다. - prefill 워커가 있으면 decode 선택은 conditional disaggregation이 켜지지 않는 한 부하 전용 규칙을 유지합니다.
- 모델을 서빙하는 prefill 워커가 없으면(aggregated 플릿) 각 aggregated 워커가 자기 요청을 직접 prefill하므로, 위 공식으로 aggregated 후보를 평가합니다. 백로그는 decode 블록(
dynamo.vllm에서는kv_used_blocks)이 정하며, 샘플에active_prefill_tokens가 있으면 함께 더합니다. - prefill 워커는
decode역할의 decode 후보가 있는 네임스페이스에서만 후보가 되며, 쌍의 decode 쪽은 항상decode워커입니다(혼합 네임스페이스 참고).decode워커 없이aggregated워커와 함께 있는 prefill 워커는 후보가 되지 않으므로 해당 플릿은 aggregated 플릿으로 평가됩니다. - 각 선택은
continuum_dynamo_selection_total{reason="kv_aware"}및continuum_dynamo_kv_selection_total{result}(overlap,no_overlap,tokenizer_unavailable,prompt_too_large,render_unavailable,artifact_unpinned,bypass)로 집계됩니다.
nvext.token_data¶
kv.send_token_data: true(기본값 false)로 설정하면 렌더링된 토큰 ID를 아웃바운드 shim 요청 본문의 nvext.token_data에 담아 전송하여(단, shim 풀 복제본 간 본문 재작성 규칙이 다를 때는 제외) 프론트엔드가 자체 토큰화를 건너뛰게 합니다. 클라이언트가 보낸 nvext.token_data는 가드레일 우회를 막기 위해 모든 shim 디스패치에서 항상 제거되며, 일반 통합 폴백 본문에는 포함되지 않습니다.
Conditional disaggregation¶
kv.conditional_disaggregation은 업스트림의 --router-conditional-disagg를 구현하여 decode 워커를 max(0, decode_cost_blocks - overlap_credit_blocks)로 채점하고, 선택된 decode 워커가 원격 prefill 없이 로컬에서 직접 prefill할지 결정합니다.
| 정책 | 우회(bypass) 조건 |
|---|---|
isl_bounding (기본값) | 유효 ISL(isl - cached_tokens)이 eff_isl_threshold(기본값 2048) 미만 이고 전체 프롬프트 길이 대비 비율이 eff_isl_ratio_threshold(기본값 0.7) 미만일 때 |
prefill_load | 선택된 prefill 워커의 active_prefill_tokens가 max_num_batched_tokens * prefill_busy_threshold를 초과할 때 |
isl_or_load | 두 조건 중 하나라도 만족할 때 |
decode_busy_threshold가 설정되어 있으면 decode 워커의 예상 활성 블록이 total_kv_blocks의 해당 비율 이하일 때만 우회를 허용합니다(샘플이나 용량 정보가 없으면 우회 거부). 우회 시에는 x-dynamo-worker-instance-id만 보내고 x-continuum-routing-path: unified를 설정하며 reason="conditional_bypass" 및 result="bypass"로 집계합니다.
단, Dynamo 1.5.x의 dynamo.frontend --router-mode direct는 prefill 워커가 등록된 상태에서 prefill ID가 없는 요청을 거부하므로(503 disaggregated_prefill_decode_unavailable), conditional_disaggregation.enabled의 기본값은 false이며 활성화 시 config validate가 경고를 출력합니다.
설정¶
dynamo:
enabled: true
discovery:
etcd:
endpoints: ["http://etcd.dynamo.svc:2379"]
shim:
backends: [dynamo-frontend]
kv:
enabled: true
allow_http_artifacts: true # 워커 자체 메타데이터 URL(기본값)
allow_hf_artifacts: false # Hub: 공개 egress, 명시적으로 옵트인
pinned_artifacts: # 라우터가 로드해도 되는 파일; 핀 전에는 아무것도 로드하지 않음
- "blake3:<검토한 tokenizer.json의 64자리 hex>"
- "blake3:<검토한 tokenizer_config.json 또는 chat_template.jinja의 64자리 hex>"
overlap_score_credit: 1.0
prefill_load_scale: 1.0
router_temperature: 0.0
send_token_data: false
conditional_disaggregation:
enabled: false
dynamo.kv의 모든 필드는 탐색 재시작이나 레지스트리 초기화 없이 즉시 반영됩니다. 실행 가능한 템플릿은 docs/shared/llm-skills/config-templates/dynamo-kv-aware.yaml입니다.
KV 메트릭¶
| 메트릭 | 타입 | 라벨 | 의미 |
|---|---|---|---|
continuum_dynamo_kv_subscriptions | gauge | state | 수명 주기 상태별 kv-events 구독: connecting, connected, backoff (브로커 지원 시 pending 및 transport 라벨 포함) |
continuum_dynamo_kv_events_total | counter | result | 결과별 메시지: kv_metrics와 공유하는 9개 결과에 gap, orphan_parent, unknown_block, other_tier 추가 (배치당 가장 심각한 결과 하나로 1회 집계) |
continuum_dynamo_kv_index_blocks | gauge | 없음 | 현재 radix 인덱스가 보유한 블록 수 |
continuum_dynamo_kv_selection_total | counter | result | overlap, no_overlap, tokenizer_unavailable, prompt_too_large, render_unavailable, artifact_unpinned, bypass |
continuum_dynamo_kv_artifacts_total | counter | result | cached, downloaded, refused_policy, unpinned, checksum_mismatch, too_large, fetch_failed, unsupported, loaded, load_failed |
모든 라벨 집합은 닫혀 있으며 continuum_dynamo_selection_total{reason}에 kv_aware와 conditional_bypass가 추가됩니다.
클러스터 내부 이벤트 브로커 리스너¶
라우터가 Kubernetes 클러스터 외부에서 실행될 때는 API 서버로 워커를 탐색할 수 있더라도 워커 Pod IP의 kv_metrics나 kv-events 소켓에는 직접 접속할 수 없습니다. 세 번째 옵트인 빌드 기능 dynamo-event-bus(공식 릴리스 바이너리에 포함)와 dynamo.event_bus.enabled: true를 켜면 클러스터 내부의 continuum-event-broker 및 continuum-kv-listener가 아웃바운드로 접속해 오는 전용 HTTP/2 over TLS 리스너가 실행됩니다.
터널¶
공유 와이어 규약 크레이트 crates/continuum-event-bus를 기반으로 브로커 또는 리스너는 라우터 리스너에 두 종류의 HTTP/2 요청을 엽니다.
POST /bus/v1/session(제어 채널): 장기 유지되는 양방향 NDJSON 스트림입니다. 클라이언트가hello(역할,client_id, 도달 가능한 Dynamo 네임스페이스 또는 알릴 KV 소스, 지원 프로토콜 버전)를 보내면 라우터는 세션 ID(sess-<32 hex>)와 적용 한도를 담은welcome으로 응답합니다. 이후 라우터는subscribe와unsubscribe명령을 보내고 클라이언트는stream_closed와draining을 보고하며 양쪽이heartbeat/heartbeat_ack를 교환합니다.POST /bus/v1/streams/{stream_id}(데이터 스트림): 라우터의subscribe명령마다 클라이언트가 여는 스트림으로, 워커 발행자의 원시 ZMTP 바이트나 SSE 바이트를 전달합니다.
라우터가 브로커에 먼저 접속하는 일은 없으며 모든 연결은 리스너로 들어오는 인바운드 연결입니다.
리스너가 강제하는 것¶
리스너는 dynamo.event_bus.listen으로 설정된 전용 포트를 사용하며 어떤 TCP server.bind_address와도 포트가 겹치면 설정 로드 단계에서 거부합니다.
- TLS 1.3 및 HTTP/2 (
h2) 전용:rustls의ring프로바이더를 사용합니다. 모든 연결의 첫 바이트를 확인해 TLS 핸드셰이크 레코드(0x16)가 아니면tls_required로 거부하며, ASCII 문자로 시작하면 평문HTTP/1.1 403JSON 응답을 먼저 반환합니다. 핸드셰이크는 10초 안에 완료되어야 하고(handshake_timeout,handshake_failed), 20초마다 HTTP/2 keepalive PING을 보냅니다. - 인증:
Authorization: Bearer <token>헤더의 SHA-256 해시를brokers[].token_hash와 상수 시간에 비교하거나(평문 토큰 미보관),tls.client_ca로 체인이 검증되고 DNS SAN이 브로커id(소문자, 와일드카드 없음)와 일치하는 클라이언트 인증서로 신원을 확인합니다. 둘 다 제시되면 토큰이 우선하며 인증서가 다른 브로커를 가리키면forbidden으로 거부합니다. 미인증은401 unauthenticated, 리로드로 삭제된 자격 증명은 최근 256개 기록을 통해401 credential_revoked로 응답합니다. 인증서 신원은 해당 TLS 연결을 검증한 CA 세대에 귀속됩니다. hello검증:continuum-bus-version >= 1헤더(400 unsupported_protocol_version)와 10초 이내의hello메시지가 필요합니다. 브로커 자격 증명마다hello이전 핸드셰이크는 동시에 하나만 허용하며 추가 시도는too_many_sessions로 거부해 한 브로커가 전체 세션 슬롯을 점유하지 못하게 막습니다. 역할(broker또는kv_listener)과client_id는 해당brokers[]항목과 일치해야 합니다(forbidden).- 네임스페이스 및 백엔드 허용 목록:
broker역할의hello는 항목의namespaces허용 목록에 포함된 네임스페이스만 선언할 수 있습니다(forbidden; 빈 목록은 모든 네임스페이스를 허용하되 검증·시작 경고를 남김).kv_listener역할의hello는 최소 1개 이상의 소스를 알려야 하고dynamo_namespaces가 없어야 하며backends에 허용된 소스만 선언할 수 있습니다(forbidden). - 세션 및 연결 상한: 설정 항목당 활성 세션은 1개(새 세션이 오면 기존 세션은
session_superseded로 종료), 라우터 전체 세션은 최대max_sessions개(429 too_many_sessions), 동시 TCP 연결은4 * max_sessions개(최소 16,connection_limit)로 제한됩니다. TLS 연결은 HTTP/2 PING에 응답하더라도 수락 후 10초 안에 인증된2xx요청을 성립시켜야 하며(auth_timeout), 마지막 수락 요청 종료 30초 후 또는idle_timeout경과 시 닫힙니다. - 명령된 스트림만 수락: 데이터 스트림은 같은 세션에서 라우터가 명령했고 아직 열리지 않은 스트림 ID(
404 unknown_stream,409 duplicate_stream), 해당 세션을 소유한 브로커의 자격 증명(403 forbidden), 그리고 협상된 프로토콜 버전일 때만 수락합니다.
바이트 상한¶
세션당 메모리는 max_session_buffer_bytes(기본값 64 MiB)로 제한됩니다. 스트림당·방향별 버퍼는 max_session_buffer_bytes / (4 * max_streams_per_session)으로 계산해 16 KiB..=1 MiB 범위로 제한하며(기본값 16 KiB; max_streams_per_session * 64 KiB 미만의 예산은 설정에서 거부), 데이터 스트림마다 수신 윈도, 현재 수신 프레임, 아웃바운드 파이프(페이로드 및 큐 오버헤드), HTTP/2 송신 버퍼의 네 영역을 예산에 포함합니다. 리스너는 이 HTTP/2 흐름 제어 윈도를 직접 제어하기 위해 hyper로 직접 서빙합니다.
핫 리로드¶
수퍼바이저(spawn_event_bus_supervisor)는 탐색 watcher나 레지스트리를 건드리지 않고 설정을 실시간 반영합니다.
| 변경 항목 | 반영 효과 |
|---|---|
enabled | 리스너를 시작하거나, 모든 세션에 drain {reason: shutdown, deadline_ms: 2000}을 보낸 뒤 2초 후 종료하고 리스너를 중지합니다 |
listen | 새 주소로 리스너를 재시작합니다 |
tls.cert, tls.key, tls.client_ca | 경로 변경 시 파일을 다시 읽어 새 연결에 적용합니다. client_ca가 바뀌면 인증서 기반 활성 세션을 credential_revoked로 닫고 이전 CA로 검증된 연결의 새 요청도 거부합니다 |
브로커 제거 또는 token_hash 변경 | 해당 브로커의 활성 세션을 credential_revoked로 종료하고 이후 요청에 401 credential_revoked로 응답합니다 |
브로커 namespaces 축소 | 선언한 네임스페이스가 더 이상 허용되지 않는 활성 세션을 forbidden으로 종료합니다 |
kv_listener backends 축소 또는 role 변경 | 허용 범위를 벗어난 활성 세션을 forbidden으로 종료합니다 |
max_streams_per_session, max_session_buffer_bytes | 새 HTTP/2 윈도를 적용하기 위해 리스너를 드레인 후 재시작합니다 |
max_sessions, heartbeat_interval, idle_timeout | 새 세션과 연결부터 즉시 적용합니다 |
리스너 시작 실패(TLS 파일 누락, 포트 충돌 등) 시에는 event bus listener failed to start; retrying 로그를 남기고 30초마다 재시도합니다.
명령 API¶
continuum_router::dynamo::event_bus::EventBus는 리스너 수퍼바이저와 두 구독 수퍼바이저를 연결합니다.
EventBus::open_stream(target: ZmqTcpTarget, topic: Topic) -> Result<BusStream, BusStreamError>: 대상 Dynamo 네임스페이스를 선언한 활성 비-드레인broker세션 중 랑데부 해시 최순위이면서 스트림 상한 미만인 세션을 골라subscribe를 보내고 30초 안에 취소 안전한BusStream(AsyncRead + AsyncWrite + Unpin + Send)을 반환합니다(NoSession,TooManyStreams,Refused(code),OpenTimeout,SessionClosed,Busy,InvalidTarget).EventBus::open_announced_stream(backend: &str, last_event_id: Option<u64>) -> Result<BusStream, BusStreamError>:backend권한을 가진 활성 비-드레인kv_listener세션 중 스트림이 가장 적은 세션을 골라last_event_id와 함께subscribe를 보내고 SSE 바이트를 읽는BusStream을 반환합니다.- 스트림 종료 및 상태 조회:
BusStream을 drop하면unsubscribe가 전송됩니다.session_count(),covered_namespaces(),covers(namespace),local_addr(),is_running()및subscribe_sessions()watch 채널로 상태를 조회합니다.
이벤트 버스 메트릭¶
| 메트릭 | 타입 | 라벨 | 의미 |
|---|---|---|---|
continuum_dynamo_event_bus_connections_refused_total | counter | reason | HTTP 이전 또는 첫 요청 수락 전에 거부·종료된 연결: tls_required, handshake_failed, handshake_timeout, connection_limit, auth_timeout |
continuum_dynamo_event_bus_sessions | gauge | 없음 | 현재 연결된 welcome 완료 세션 수 |
continuum_dynamo_event_bus_session_refusals_total | counter | reason | 거부된 세션 요청: unauthenticated, credential_revoked, forbidden, unsupported_protocol_version, malformed_message, message_too_large, too_many_sessions, idle_timeout, draining, internal |
continuum_dynamo_event_bus_sessions_closed_total | counter | reason | 종료된 세션: closed, session_superseded, credential_revoked, forbidden, idle_timeout, malformed_message, message_too_large, draining, internal |
continuum_dynamo_event_bus_streams | gauge | 없음 | 열린 데이터 스트림 수 |
continuum_dynamo_event_bus_streams_opened_total | counter | 없음 | 누적 열린 데이터 스트림 수 |
continuum_dynamo_event_bus_stream_refusals_total | counter | reason | 거부된 스트림: unauthenticated, credential_revoked, forbidden, unknown_stream, duplicate_stream, unsupported_protocol_version, malformed_message, too_many_streams, no_session, target_not_advertised, target_not_allowed, unsupported_target, upstream_connect_failed, draining, open_timeout, session_closed, internal |
continuum_dynamo_event_bus_streams_closed_total | counter | reason | 종료된 스트림: unsubscribed, upstream_closed, buffer_overflow, idle_timeout, draining, session_closed, internal |
continuum_dynamo_event_bus_bytes_total | counter | direction | 데이터 스트림 전송 바이트: from_broker, to_broker |
모든 라벨 집합은 닫혀 있으며 스트림 ID, 세션 ID, 브로커 ID, 클라이언트 문자열은 라벨에 포함되지 않습니다.
설정¶
dynamo:
event_bus:
enabled: true
listen: "0.0.0.0:7443"
tls:
cert: "/etc/continuum-router/event-bus/tls.crt"
key: "/etc/continuum-router/event-bus/tls.key"
# client_ca: "/etc/continuum-router/event-bus/client-ca.crt"
brokers:
- id: "broker-a.dynamo"
token_hash: "sha256:<64 lowercase hex>" # printf %s "$TOKEN" | sha256sum; 토큰 자체는 금지
namespaces: ["dynamo"]
- id: "kv-listener-a" # role kv_listener: 네임스페이스가 아니라 backends를 허용받음
role: kv_listener
token_hash: "sha256:<64 lowercase hex>"
backends: ["vllm-1", "vllm-2"]
max_sessions: 64
max_streams_per_session: 1024
max_session_buffer_bytes: 67108864
heartbeat_interval: "10s"
idle_timeout: "30s"
자세한 필드 규칙은 설정 가이드에 있으며, 실행 가능한 템플릿은 docs/shared/llm-skills/config-templates/dynamo-event-bus.yaml입니다.
kv_listener 푸시 역할¶
role: kv_listener가 지정된 brokers[] 항목은 푸시 모드의 continuum-kv-listener를 처리합니다. 프로토콜 버전 1 규약을 그대로 사용하며 재개 위치인 AnnouncedSourceTarget.last_event_id(SSE Last-Event-ID에 대응)와 최소 1개 이상의 소스 선언을 요구하는 Hello::validate 규칙이 적용됩니다.
| 항목 필드 | 규칙 |
|---|---|
role | broker (기본값) 또는 kv_listener |
backends | kv_listener 전용: 필수, 1~64바이트 [A-Za-z0-9_-] 고유 이름 최대 256개 (broker 항목에 지정 시 로드 오류) |
namespaces | broker 전용 (kv_listener 항목에 지정 시 로드 오류) |
id | 두 역할 전체에서 고유해야 하며 리스너의 event_bus.client_id와 일치해야 함 |
kv_listener 항목의 런타임 동작:
- 백엔드 단위 권한 부여: 브로커와 동일한 TLS·인증·
max_sessions한도를 공유하되backends에 나열된 백엔드에 대해서만 스트림 명령을 허용합니다. 리로드로backends가 축소되거나role이 바뀌면 새 배치를 중단하고 해당 활성 세션을forbidden으로 닫습니다. - SSE 소비 및 드레인:
transport: event_bus인kv_cache_index소스는KvEventStreamOpener를 통해open_announced_stream을 호출하여 중계된 SSE 바이트를 파싱하고last_event_id부터 이어받습니다. 리스너가draining을 보내면 열린 스트림을 즉시 닫아 다른 세션으로 전환하게 합니다. - 검증 권고(advisory):
dynamo.event_bus.enabled가 꺼진 상태에서transport: event_bus소스가 있거나, 어떤kv_listener도 허용하지 않은 버스 소스가 있거나, 활성 버스 소스가 소비하지 않는kv_listener허용 항목이 있으면 경고를 출력합니다. 자세한 예시는 KV 캐시 최적화를 참고하십시오.
브로커를 통한 구독¶
dynamo.load.transport: broker로 설정하면 kv_metrics와 kv-events 구독은 발행자 Pod IP에 직접 접속하는 대신 이벤트 버스 스트림 위에서 라우터의 유계 ZMTP 클라이언트를 실행합니다.
커넥터¶
ZmtpConnector(src/dynamo/load/connector.rs)가 전송 연결을 추상화합니다.
DirectTcpConnector는tcp://host:port에 직접 접속합니다.BrokerConnector(src/dynamo/load/broker.rs,dynamo-event-bus기능)는 발행자 엔드포인트를 해당 Dynamo 네임스페이스의ZmqTcpTarget으로 변환해EventBus::open_stream(target, topic)을 호출합니다.- ZMTP 핸드셰이크, 프레임·메시지 크기 상한, msgpack 디코딩, 발행자 ID 검증, 시퀀스 중복 제거,
kv-events갭 처리는 모두 중계된 바이트 위에서 동일하게 실행되며, 30초 열기 타임아웃, 10초 연결 예산, 민감 정보를 제거한ZmtpError::Broker사유 단어, 1초CLOSE_TIMEOUT을 적용합니다.
Pending¶
BrokerConnector는 연결 시도 전 EventBus::covers(namespace)를 확인합니다. 해당 네임스페이스를 담당하는 활성 브로커 세션이 없으면 구독은 pending 상태로 대기하며(해당 워커에는 신선한 샘플이 없음), 폴링 없이 EventBus::subscribe_sessions() watch 채널을 기다립니다. 담당 브로커가 연결되면 pending에서 깨어난 구독들은 최대 500ms의 무작위 지터(PENDING_WAKE_JITTER)를 두고 연결을 시작하며, kv-events 구독은 재연결 후에도 rank별 커서를 유지합니다.
네임스페이스당 여러 브로커¶
여러 broker 세션이 하나의 Dynamo 네임스페이스를 함께 담당할 수 있습니다.
- 랑데부 해시 배치:
EventBus::open_stream은 해당 네임스페이스를 선언한 활성 비-드레인 브로커 세션들에 대해SHA-256(len(key) ‖ key ‖ client_id)의 상위 64비트 가중치(key는 네임스페이스·호스트·포트·토픽)를 계산하여max_streams미만인 최고 가중치 세션에 스트림을 배치합니다. 브로커가 추가되어도 기존 열린 스트림은 이동하지 않으며 N개의 브로커가 약1/N씩 구독을 분담합니다. - 재연결 1회 내 장애 조치: 브로커가 종료되면
ZmtpConnector::carrier_lost가 최대 500ms(SESSION_LOSS_GRACE) 안에 세션 종료를 감지하고 백오프 지연을 늘리지 않은 채 하한(RECONNECT_INITIAL, 최대 500ms)만 대기한 뒤 다음 순위 브로커로 즉시 재연결합니다. - 단절된
kv-events구독의 노화(ageing):kv-events구독이 60초(KV_EVENTS_STALE_AFTER) 동안 연결되지 못하거나pending에 머물면 커서가 가리키는 모든 랭크를 KV 인덱스에서 비우고warn으로 기록하여 오래된 캐시 정보가 프리픽스 점수를 왜곡하지 않게 합니다. - 미포함 네임스페이스 관측:
transport: broker에서는GET /admin/dynamo/workers에broker_coverage가 추가되며, 250ms 디바운스 리포터가 설정된 네임스페이스마다continuum_dynamo_broker_namespace_sessions{namespace}와continuum_dynamo_broker_pending_subscriptions{family, namespace}게이지를 갱신합니다.dynamo-event-bus없이 빌드된 바이너리는broker구독을pending에 머물게 하며 임의로 직접 접속으로 전환하지 않습니다.
전송 방식 전환¶
dynamo.load.transport는 즉시 핫 리로드됩니다. 각 수퍼바이저는 실행 중인 구독을 동시에 정리하고 새 전송 방식으로 다시 시작하며, 이때 탐색 재시작이나 부하 저장소·KV 인덱스 초기화는 발생하지 않습니다. 재시작된 kv-events 구독은 이전 구독의 rank별 event_id 커서를 이어받아 전환 도중 누락된 이벤트를 gap으로 처리하고, 완전히 종료된 구독은 자신이 관리하던 rank를 인덱스에서 비웁니다.
관측성¶
GET /admin/dynamo/workers는 실행 중인 모든 구독을 subscriptions 아래에 나열하며, 구독 게이지는 transport(direct, broker)와 state(pending, connecting, connected, backoff) 라벨을 제공합니다.
continuum_dynamo_load_subscriptions{state, transport}continuum_dynamo_kv_subscriptions{state, transport}continuum_dynamo_broker_subscriptions{family, broker}(dynamo.event_bus.brokers[].id설정값으로 제한)
종단 간 테스트¶
tests/dynamo_load_broker_test.rs는 프로세스 내 브로커 대역을 테스트하며, tests/dynamo_broker_tunnel_test.rs는 가짜 Kubernetes API 서버와 루프백 zeromq::PubSocket 발행자를 띄우고 실제 continuum-event-broker 자식 프로세스를 구동하여 모든 CI 실행에서 터널 전체를 검증합니다.
테스트는 세 가지 시나리오를 검증합니다.
- 직접 연결과의 상태 일치: 동일한
kv_metrics및kv-events스트림(지연 도착 시퀀스, 다른 발행자 ID, 블록 제거,event_id갭 포함)을transport: direct와transport: broker로 각각 재생했을 때 부하 저장소와 KV 인덱스 상태가 완전히 동일한지 확인합니다. - 브로커 강제 종료: 브로커에
SIGKILL을 보내 모든 구독이pending(round_robin_stale선택)으로 전환되는지 확인한 뒤, 새 브로커를 기동해 수퍼바이저 재시작 없이 구독 복구,load선택 복귀, 누락된kv-events의gap재구축이 이루어지는지 확인합니다. - 광고되지 않은 발행자 차단:
DynamoWorkerMetadata에 없는 발행자는 브로커가target_not_advertised로 거부하고 광고된 발행자만 연결되는지 확인합니다.
관측성¶
탐색 서브시스템은 닫힌 라벨 집합을 가진 세 가지 메트릭 패밀리를 내보냅니다.
| 메트릭 | 타입 | 라벨 | 의미 |
|---|---|---|---|
continuum_dynamo_workers | gauge | namespace, role | 설정된 Dynamo 네임스페이스 및 역할별(prefill, decode, aggregated, unknown) 탐색된 워커 수 |
continuum_dynamo_discovery_events_total | counter | kind | 처리된 이벤트: added, modified, deleted, resync, malformed, mismatch, restart, unsupported, duplicate, refused |
continuum_dynamo_discovery_connected | gauge | source | 활성 source(etcd, kubernetes_pods, kubernetes)와 연결 중이면 1, 아니면 0 (두 Kubernetes 소스의 290초 주기 정상 재 list 동안에는 1 유지) |
GET /admin/dynamo/workers¶
dynamo와 admin 기능이 모두 컴파일된 빌드에서 Admin 인증·감사 미들웨어 뒤에 마운트됩니다.
{
"enabled": true,
"connected": true,
"source": "etcd",
"signals": {"load": true, "capacity": true},
"namespace": "dynamo",
"namespaces": ["dynamo"],
"generation": 12,
"workers": [
{
"instance_id": "1a7f2b",
"namespace": "dynamo",
"worker_id": "1736491",
"role": "decode",
"model": "Qwen/Qwen3-32B",
"component": "decode",
"endpoint": "generate",
"dp_start_rank": 2,
"dp_size": 4,
"block_size": 64,
"capacity": {
"total_kv_blocks": 12000,
"max_num_batched_tokens": 8192,
"max_num_seqs": 256,
"context_length": 32768
},
"disaggregated_endpoint": "dynamo/prefill/generate",
"first_seen": "2026-09-23T04:11:02+00:00",
"last_updated": "2026-09-23T04:19:48+00:00"
}
],
"load_publishers": 1,
"kv_event_publishers": 1,
"subscriptions": [
{
"family": "kv_metrics",
"publisher_id": "2046539",
"namespace": "dynamo",
"component": "decode",
"state": "connected",
"transport": "broker",
"broker": "broker-a.dynamo"
},
{
"family": "kv-events",
"publisher_id": "2046540",
"namespace": "dynamo",
"component": "decode",
"state": "pending",
"transport": "broker"
}
]
}
응답 필드 및 내부 주소 비공개 보장:
broker_coverage및subscriptions:dynamo.load.transport: broker일 때 설정된 네임스페이스별covered,brokers,pending_subscriptions를 담은broker_coverage가 포함됩니다.subscriptions는 실행 중인 모든kv_metrics및kv-events구독의state,transport(direct또는broker), 전달 중인brokerID를 나열합니다.namespaces,source,signals,worker_id: 설정된 네임스페이스 목록(namespaces), 활성 탐색 소스(source)와 신호 지원 여부(signals), 그리고 프론트엔드GET /health및x-dynamo-worker-instance-id와 동일한 10진수 문자열worker_id를 제공합니다.- 전송 주소 및 원본 카드 비노출: 워커의 내부
transport주소, Pod IP, 노드 이름, UID, 발행자의tcp://엔드포인트, 원본 모델 카드는 응답에 절대 포함되지 않습니다. - 비활성 및 미포함 빌드 구분:
dynamo기능이 컴파일되었으나 현재 설정에서 꺼져 있으면"enabled": false와 빈 목록으로200을 반환하고, 기능 없이 빌드된 바이너리는 라우트를 마운트하지 않아404를 반환합니다.
모듈 구성¶
| 파일 | 책임 |
|---|---|
src/core/config/models/dynamo.rs | dynamo 설정 섹션 (항상 컴파일됨) |
src/dynamo/records.rs | 관대한 파싱을 지원하는 etcd 값 serde 타입 |
src/dynamo/apply.rs | 키 이벤트를 플릿 뷰로 변환하는 순수 reducer |
src/dynamo/registry.rs | arc-swap 스냅샷 및 변경 세대 채널 |
src/dynamo/watcher.rs | 리비전 커서 기반 get-then-watch, 재연결 시 전체 재동기화, 유계 백오프 |
src/dynamo/backoff.rs | 재연결 루프가 공유하는 백오프 하한 초기화 규칙 |
src/dynamo/supervisor.rs | 설정 리로드를 구독하여 활성 watcher를 교체하는 수퍼바이저 |
src/dynamo/config.rs | 설정에서 생성하는 읽기 전용 etcd 클라이언트 |
src/dynamo/admin.rs | GET /admin/dynamo/workers 핸들러 |
src/dynamo/picker.rs | DynamoWorkerPicker 트레이트 및 기본 LoadBasedPicker 구현 |
src/proxy/disaggregated_dynamo.rs | Direct 모드 shim 디스패치, x-dynamo-* 헤더 계약, 거부 응답 분류 |
src/core/config/models/dynamo_shim.rs | dynamo.shim 설정 섹션 (항상 컴파일됨) |
src/dynamo/apply_channels.rs | kv_metrics 및 kv-events 발행자용 이벤트 채널 키스페이스 reducer |
src/dynamo/apply_duplicates.rs | 세 키스페이스의 중복 식별자 감지 및 충돌 로그 관리 |
src/core/config/models/dynamo_kubernetes.rs | dynamo.discovery.source 및 dynamo.discovery.kubernetes 블록 (항상 컴파일됨) |
src/dynamo/supervisor_decide.rs | 세 탐색 소스 전체에 걸친 순수 리로드 전이 규칙 |
src/dynamo/kube/hash.rs | Pod 이름 기반 인스턴스 ID 도출 (키가 0인 SipHash-1-3) |
src/dynamo/kube/mapping.rs | Pod 역할, 준비 상태, container 모드 분류 |
src/dynamo/kube/state.rs | 레지스트리 상한과 중복 규칙을 포함한 Pod reducer |
src/dynamo/kube/client.rs | 클러스터 내부 자격 증명을 우선하는 Kubernetes 클라이언트 생성기 |
src/dynamo/kube/watcher.rs | Kubernetes Pod 페이지 조회, watch, 전체 재 list 루프 |
src/dynamo/kube/metadata/crd.rs | DynamoWorkerMetadata CRD 정의 및 etcd 키 레코드 분류 |
src/dynamo/kube/metadata/slices.rs | EndpointSlice 준비 상태(conditions.ready, targetRef.name) 추출 |
src/dynamo/kube/metadata/state.rs | 메타데이터 reducer 및 리소스 수명 주기(replace_all, apply_metadata) |
src/dynamo/kube/metadata/state_reconcile.rs | 슬라이스 준비 상태 관리 및 etcd reducer로의 증분 차분 반영 |
src/dynamo/kube/metadata/state_bounds.rs | 보유 메타데이터 리소스 개수 및 총 바이트 상한 관리 |
src/dynamo/kube/metadata/watcher.rs | DynamoWorkerMetadata와 EndpointSlice 이중 페이지 조회 및 watch 루프 |
src/dynamo/load/channel.rs | EventChannel 레코드, 세그먼트 퍼센트 인코더, 토픽·subject 레이아웃 |
src/dynamo/load/frame.rs | 멀티파트·프레임·봉투·ActiveLoad 디코더 및 테스트용 인코더 |
src/dynamo/load/store.rs | (worker, dp_rank)별 최신 샘플, 신선도, 상한, 게이지 반영 |
src/dynamo/load/zmtp.rs | 유계 ZMTP 3.x SUB 클라이언트 |
src/dynamo/load/connector.rs | 직접 TCP 및 브로커 스트림을 추상화하는 ZmtpConnector |
src/dynamo/load/broker.rs | BrokerConnector(dynamo-event-bus), 세션 손실 감지, 오류 마스킹 |
src/dynamo/load/coverage.rs | 네임스페이스별 브로커 포함 상태 및 디바운스 게이지 리포터 |
src/dynamo/load/ageing.rs | 단절된 kv-events 구독의 노화 추적기 |
src/dynamo/load/dialer.rs | dynamo.load.transport에 따라 커넥터를 선택하는 SubscriptionDialer |
src/dynamo/load/subscriber.rs | 발행자별 구독 태스크 루프 (지터 깨우기 및 백오프 재연결) |
src/dynamo/load/handler.rs | kv_metrics 메시지 핸들러 (디코딩, 발행자 ID 검증, 중복 제거, 스토어 게이트) |
src/dynamo/load/tracking.rs | 상태 게이지, 브로커 게이지, Admin 보드를 동기화하는 구독 상태 가드 |
src/dynamo/load/board.rs | GET /admin/dynamo/workers가 읽는 구독별 상태 보드 |
src/dynamo/load/supervisor.rs | 레지스트리와 설정을 따라가는 부하 구독 수퍼바이저 |
src/core/config/models/dynamo_kv.rs | dynamo.kv 섹션 (항상 컴파일됨) |
src/dynamo/kv/events.rs | 위치 허용적 RouterEvent 배치 디코더 및 테스트용 인코더 |
src/dynamo/kv/index.rs | 유계 radix KV 블록 인덱스 (rank별 소유권 및 활성 게이트) |
src/dynamo/kv/hashing.rs | dynamo-kv-hashing을 통한 요청 블록 해시 계산 |
src/dynamo/kv/artifacts.rs | 모델 카드 아티팩트 해석, 핀 확인, blake3 내용 주소화 캐시 |
src/dynamo/kv/tokenizer.rs | minijinja 채팅 템플릿 유계 렌더링 및 tokenizers 인코딩 |
src/dynamo/kv/cache.rs | 블로킹 풀 토크나이저 캐시 및 KvRequestPreparer |
src/dynamo/kv/picker.rs | KvAwarePicker 비용 공식, 소프트맥스 샘플링, decode 분기 채점 |
src/dynamo/kv/bypass.rs | Conditional disaggregation 우회 정책 및 decode-busy 게이트 |
src/dynamo/kv/subscriber.rs | kv-events 메시지 핸들러 (rank별 event_id 커서 및 갭 초기화) |
src/dynamo/kv/supervisor.rs | KV 구독, 인덱스 정리, 토크나이저 로드, picker 설치를 관리하는 수퍼바이저 |
src/dynamo/kv/supervisor_subscription.rs | 실행 중인 kv-events 구독 핸들 및 재시작 간 커서 보존 |
src/core/config/models/dynamo_event_bus.rs | dynamo.event_bus 섹션 (항상 컴파일됨) |
src/dynamo/event_bus/mod.rs | EventBus API (open_stream, open_announced_stream, 수명 주기 제어) |
src/dynamo/event_bus/supervisor.rs | 이벤트 버스 리스너 리로드 수퍼바이저 및 활성 세션 권한 회수 |
src/dynamo/event_bus/listener.rs | TLS 1.3 / HTTP/2 연결 수락 루프, 첫 바이트 검사, 흐름 제어 윈도 |
src/dynamo/event_bus/tls.rs | rustls ServerConfig, 클라이언트 인증서 검증기, DNS SAN 신원 추출 |
src/dynamo/event_bus/auth.rs | 상수 시간 SHA-256 토큰 비교 및 폐기된 자격 증명 링 버퍼 |
src/dynamo/event_bus/service.rs | /bus/v1/session 및 /bus/v1/streams/{stream_id} 엔드포인트 디스패치 |
src/dynamo/event_bus/session.rs | hello/welcome 핸드셰이크, 하트비트, 명령, 드레인 처리 |
src/dynamo/event_bus/registry.rs | 활성 세션 레지스트리 및 명령된 스트림 추적 |
src/dynamo/event_bus/placement.rs | zmq_tcp 랑데부 해시 배치 및 announced_source 최소 부하 배치 |
src/dynamo/event_bus/stream.rs | BusStream 및 유계 아웃바운드 파이프 |
src/dynamo/event_bus/settings.rs | 리스너 설정 해석 및 스트림별 버퍼 크기 도출 |
src/metrics/dynamo_event_bus.rs | continuum_dynamo_event_bus_* 메트릭 패밀리 |
crates/continuum-event-bus/ | 라우터 리스너와 에이전트가 공유하는 독립 와이어 규약 크레이트 |
crates/continuum-event-broker/ | 클러스터 내부 이벤트 브로커 바이너리 (클러스터 내부 이벤트 브로커 참고) |
탐색 reducer가 이벤트 스트림의 순수 함수이므로 src/dynamo/apply_tests.rs, apply_duplicates_tests.rs, apply_channels_tests.rs, src/dynamo/kube/metadata/state_tests.rs는 외부 etcd나 Kubernetes 서버 없이도 구체화 규칙, 중복 거부, Kubernetes-etcd 동등성을 검증합니다.
코드에서 레지스트리 읽기¶
AppState는 dynamo_registry: Option<Arc<DynamoWorkerRegistry>>를 보유하며, dynamo 기능이 컴파일된 빌드에서는 실시간 리로드로 언제든 탐색을 켤 수 있도록 항상 Some으로 생성됩니다. 런타임 활성화 여부는 설정 재조회 대신 registry.is_enabled()로 확인합니다.
registry.snapshot(): 잠금 없이Arc<DynamoSnapshot>을 반환합니다(snapshot.namespace가 스냅샷의 기본 네임스페이스).registry.subscribe(): 단조 증가하는 변경 세대 번호를 담은tokio::sync::watch::Receiver<u64>를 반환하므로 느린 구독자가 이전 스냅샷을 메모리에 붙잡아 두지 않습니다.registry.load_store():Arc<DynamoLoadStore>를 반환합니다(worker_id_of_instance로 변환한worker_id로fresh(worker_id, dp_rank, max_age)호출).registry.picker()/set_picker(): 현재 활성화된Arc<dyn DynamoWorkerPicker>를 읽거나 교체합니다.
스냅샷을 먼저 교체한 뒤 세대 번호를 게시하므로 깨어난 구독자는 항상 최신 상태를 보며, 플릿 뷰가 바뀌지 않은 멱등 재동기화는 세대 번호를 올리지 않습니다.
빌드 요구 사항¶
dynamo 기능은 etcd-client(tonic-prost-build를 통해 protoc 호출)를 컴파일하므로 dynamo, dynamo-kv, dynamo-event-bus를 켜는 모든 빌드(cargo clippy --all-features 포함)에는 PATH에 protoc가 있어야 합니다.
기본 빌드(default/full)와 embed 빌드는 세 기능을 모두 제외하므로 protoc가 필요 없으며, 공식 릴리스 바이너리는 세 기능을 모두 포함해 빌드됩니다.
호환성¶
와이어 규약은 업스트림 ai-dynamo/dynamo 릴리스를 기준으로 검증되었으며, 종단 간 하네스의 이미지 태그는 deploy/examples/dynamo-direct-shim/.env에 고정되어 있습니다.
| 구성 요소 | 시험 대상 | 비고 |
|---|---|---|
| Dynamo | 릴리스 라인 1.5.x | 탐색 레코드, kv_metrics, direct 모드 헤더 계약은 커밋 ff3ac59e83c73e03b98a5d0ec192ec28847130f7(2026-09-23) 기준이며 N-2 레코드 형태를 수용합니다 |
| Dynamo 이미지 | nvcr.io/nvidia/ai-dynamo/dynamo-planner | 멀티 아키텍처(linux/amd64, linux/arm64) 1.5.0 이미지를 프론트엔드와 dynamo.mocker 워커에 공통 사용합니다 |
| etcd | 3.5.x | 탐색 통합 테스트와 compose 하네스에서 사용하는 버전입니다 |
| 엔진 | dynamo.mocker, vLLM 방식 스케줄러 | dynamo.vllm이 사용하는 동기식 disaggregated_params 경로를 검증합니다(SGLang의 동시 bootstrap 경로는 미포함) |
실제 플릿 검증¶
아래 표는 읽기 전용 권한(pods, services에 대한 get/list/watch)으로 실제 Kubernetes 플릿(ai-dynamo==1.4.2)에 dynamo.discovery.source: kubernetes_pods를 연결해 검증한 결과입니다.
| 항목 | 시험 대상 | 결과 |
|---|---|---|
| Dynamo | 오퍼레이터, 프론트엔드, 워커 모두 1.4.2 (ai-dynamo==1.4.2) | 2026-09-29 및 2026-09-30 시험 완료 (mocker 하네스의 1.5.x보다 한 릴리스 이전 버전이므로 1.5.x 자체를 검증하지는 않습니다) |
| 탐색 백엔드 | kubernetes pod 모드 (클러스터 내 etcd 없음) | Pod 이름에서 도출한 워커 ID가 프론트엔드 GET /health가 보고하는 ID와 일치함 |
| 엔진 | dynamo.vllm을 통한 vLLM 0.25.0 (2026-09-29 단일 모델 4 x TP4 aggregated 그래프; 2026-09-30 한 네임스페이스 내 두 모델을 서빙하는 두 aggregated 풀) | shim을 통해 정상 서빙되었고 x-dynamo-worker-instance-id가 준비된 워커들 사이에서 라운드 로빈으로 순환함. 다중 풀 모델 분리 확인(차이점 (b) 참고) |
| 프론트엔드 | dynamo.frontend --router-mode direct --dyn-chat-processor vllm | 차이점 (a)의 chat processor 패치 적용 후 스트리밍·비스트리밍 /v1/chat/completions가 200으로 응답함 (/v1/completions는 패치 없이도 200) |
| 라우터 | dynamo.discovery.source: kubernetes_pods | dynamo 섹션 핫 리로드 및 탐색 소스 실시간 전환이 재시작 없이 적용됨 |
| 부하 신호 | 외부 ZMQ 구독자로 캡처한 kv_metrics 발행자 1개 | 메시지 6개 캡처 (src/dynamo/load/fixtures/kv_metrics_frame_captured*.bin) |
| 미시험 항목 | NIXL 기반 dynamo.vllm prefill/decode 쌍 및 SGLang 워커 | 대상 네임스페이스에 해당 그래프가 없어 mocker 하네스로만 검증됨 |
실제 1.4.2 플릿에서 확인된 차이점:
- (a) vLLM chat processor의 워커 고정 누락: 패치되지 않은 1.4.2
--dyn-chat-processor vllm은/v1/chat/completions에서x-dynamo-*헤더가 변환된nvext라우팅 힌트를 복사하지 않아 요청을 거부합니다(SGLang processor는ai-dynamo/dynamo#14696에서 수정되었으며 실제 검증에는 동일 수정을 적용한 vLLM 프론트엔드 이미지를 사용함). - (b) 한 Dynamo 네임스페이스의 다중 모델 서빙: 다른 모델 풀의 워커 ID로 고정하면 프론트엔드가
500 Failed to generate completions(instance_id=... not found)로 거부하므로kubernetes.models로 컴포넌트별 모델을 분리해야 합니다. - ©
ActiveLoad가kv_used_blocks만 포함: 1.4.2dynamo.vllm워커는active_decode_blocks와active_prefill_tokens를 모두nil로 보내므로 decode 비용은kv_used_blocks대체값을 쓰고 부하 전용 picker는 prefill 풀을 라운드 로빈으로 순환합니다(반면dynamo-kv의 KV 인지 picker는 오버랩과 요청 크기로 순위를 매깁니다). - (d) 동적 발행자 포트 및 10진수 헤더: 각 워커는 PUB 소켓을
tcp://0.0.0.0:0에 바인드하여 OS가 할당한 포트를 탐색 레코드에 기록하며, 프론트엔드는 10진수x-dynamo-worker-instance-id만 인식합니다(16진수는400으로 거부).
브로커 터널 실환경 점검¶
라우터를 클러스터 외부에 둔 상태에서 kbm-g-np-kt-a 클러스터의 브로커 터널을 점검하기 위한 절차입니다(실환경 실행 전까지 아래 결과 표는 비어 있으며, 동일한 동작은 위 종단 간 테스트가 클러스터 없이 검증합니다).
사전 준비 사항:
- 클러스터 외부에서
dynamo,dynamo-kv,dynamo-event-bus가 활성화된 라우터를dynamo.load.transport: broker,dynamo.discovery.source: kubernetes(또는etcd)로 실행하고, 클러스터에서 도달 가능한dynamo.event_bus리스너와 네임스페이스 허용 목록을 설정합니다. - 워커의 Kubernetes 네임스페이스에
deploy/helm/continuum-event-broker또는deploy/kubernetes/event-broker로 브로커를 배포하고dynamoworkermetadatas.nvidia.com에 대한get/list/watch권한을 부여합니다. - 아래 읽기 전용 점검 스크립트를 실행합니다(
--probe-image를 지정하면 일회용 비-root curl Pod를 띄워 TCP+TLS 지연을 측정하며, 생략하면 클러스터에 아무 리소스도 생성하지 않습니다).
ROUTER_ADMIN_TOKEN=... scripts/dynamo-broker-live-check.sh \
--context <context> --namespace <broker namespace> \
--router-url https://<router host>:<port> \
--window 300 --max-staleness-ms 3000 \
--probe-image curlimages/curl:8.11.1 --bus-url https://<bus host>:<bus port>
스크립트는 순서대로 세 가지를 확인합니다.
- Pod의 버스 연결 여부:
GET /admin/dynamo/workers의subscriptions모든 행이 브로커 ID와 함께transport: broker로connected인지 확인하고, 라우터의continuum_dynamo_event_bus_sessions와 브로커 Pod의 메트릭을 조회합니다. - 부하 샘플 신선도 유지:
--window동안 초당 1회/metrics를 스크레이프하여 디코딩된kv_metrics·kv-events프레임 수,dynamo.load.max_staleness대비 최대 공백 구간, 그리고 트래픽 처리 중 신선한 부하로 선택된 비율(reason="load"대reason="round_robin_stale")을 보고합니다. - 릴레이 지연 시간:
--probe-image사용 시 브로커 네임스페이스에서 버스 주소로 TCP+TLS 핸드셰이크를 반복 측정하여 중앙값의 절반을 단방향 버스 지연 추정치로 보고합니다.
| 날짜 | 플릿 | 라우터 빌드 | 브로커 | Pod가 버스에 닿는지 | 신선도(가장 긴 공백, 신선한 선택 비율) | 릴레이 지연(단방향 추정, 연결 중앙값) |
|---|---|---|---|---|---|---|
| 아직 실행하지 않음 | kbm-g-np-kt-a |
Dynamo가 계속 소유하는 것¶
라우터는 prefill 및 decode 워커 선택만 담당하며, 아래 작업은 모두 Dynamo 내부에서 수행됩니다.
- 토크나이즈 및 디토크나이즈:
dynamo.frontend가 소유합니다(단,kv.send_token_data: true일 때는 라우터가 렌더링한 토큰 ID를 전달). - prefill/decode 핸드오프: 1토큰 prefill 홉,
disaggregated_params주입, decode 홉은 프론트엔드가 워커의 요청 플레인을 대상으로 수행합니다. - KV 캐시 전송: prefill 워커에서 decode 워커로의 KV 블록 전송(NIXL 등)은 두 워커 사이에서 직접 이루어집니다.
- 워커 활성 상태: 각 워커의 자체 etcd lease 또는 Kubernetes 준비 상태가 결정합니다.
- OpenAI 응답 생성 및 엔진 스트리밍: 프론트엔드가 생성하고 라우터가 중계합니다.
prefill과 decode 단계가 shim 내부에서 일어나므로 disaggregated_prefill_duration_seconds를 비롯한 단계별 히스토그램은 라우터에서 관측할 수 없으며 기록되지 않습니다.
실제 클러스터로 테스트하기¶
tests/dynamo_etcd_discovery_test.rs 통합 테스트는 ETCD_ENDPOINTS가 설정되어 있을 때만 실행되며, 프로세스 고유 네임스페이스 아래에만 쓰고 종료 시 모두 정리합니다.
docker run --rm -d -p 2379:2379 --name etcd-test quay.io/coreos/etcd:v3.5.17 \
etcd --advertise-client-urls http://0.0.0.0:2379 --listen-client-urls http://0.0.0.0:2379
ETCD_ENDPOINTS=http://127.0.0.1:2379 \
cargo test --features dynamo --test dynamo_etcd_discovery_test
외부 클러스터 없이 매 CI마다 실행되는 테스트:
- Watch 세션 단위 테스트 (
src/dynamo/watcher_tests.rs): 스크립트 기반WatchSource로drive_session을 구동하여 put/delete 반영, compaction 취소 후Resync복구,first_seen보존을 검증합니다. - ZMQ 부하 구독자 (
tests/dynamo_load_zmq_test.rs): 루프백zeromq::PubSocket을 바인드하여 시퀀스 중복 제거,max_staleness만료, 구독 해제, 그리고 실제 Dynamo 1.4.2 캡처 픽스처(src/dynamo/load/fixtures/kv_metrics_frame_captured*.bin)의 바이트 단위 디코딩·인코딩 일치를 검증합니다. - Shim 디스패치 (
tests/dynamo_shim_picker_test.rs): wiremock shim을 띄워 10진수x-dynamo-*헤더 기록, 클라이언트 고정 헤더 제거, aggregated 폴백, SSE 스트리밍 중계,503 disaggregated_prefill_decode_unavailable응답 분류를 검증합니다.
실제 Dynamo 프로세스를 구동하는 GPU 없는 종단 간 하네스 scripts/e2e-dynamo-shim.sh(deploy/examples/dynamo-direct-shim/, .github/workflows/dynamo-e2e.yml 수동 디스패치)는 etcd, prefill·decode·agg 모드의 dynamo.mocker 워커, dynamo.frontend --router-mode direct, 라우터를 함께 실행합니다.
Dynamo 1.5.0 프론트엔드는 decode 워커가 남아 있는 상태에서 마지막 prefill 워커가 사라지면 모델을 철회하고(needs: [["prefill"]]), decode 워커와 aggregated 워커가 동시에 등록되어 있으면 요청마다 세트를 무작위로 골라 절반가량이 400으로 실패하므로, 하네스는 분리형 풀이 모두 비워진 뒤에 aggregated 워커로 전환되는 시나리오를 검증합니다.