docs: move PRIVATE_SERVER.md and NATS_REPORT.md to nats-docker submodule
This commit is contained in:
-176
@@ -1,176 +0,0 @@
|
||||
# 📊 MAM 메시징 백플레인 아키텍처 심층 분석 보고서: MQTT vs NATS
|
||||
|
||||
- **문서 버전**: Rev.2 Final Synthesis (`f1956d2e` / `5ac88ca0`)
|
||||
- **작성/검토 주체**: MAM Multi-Agent Orchestration Team (`claude`, `agy`)
|
||||
- **기준 커밋**: `ac82f9b` (`refactor`, 276/276 tests passing)
|
||||
- **문서 목적**: MAM 프레임워크의 메시징 인프라(MQTT)를 NATS로 전면 전환할 것인지 여부에 대한 종합적인 기술·운영·보안 타당성 분석 및 실행 로드맵 확정.
|
||||
|
||||
---
|
||||
|
||||
## 0. 최종 판정 (Executive Verdict)
|
||||
|
||||
> ### 🎯 **[VERDICT: DO NOT MIGRATE CLIENT PROTOCOL — ADOPT `nats-server` AS DEDICATED BROKER]**
|
||||
>
|
||||
> **클라이언트 전송 프로토콜(MQTT)은 유지하고, 전용 브로커로서 `nats-server`의 내장 MQTT 3.1.1 어댑터를 채택합니다.**
|
||||
|
||||
### 0.1 3대 핵심 근거 요약
|
||||
|
||||
```
|
||||
[MAM Control Plane] ────> run_loop.sh (wait_for_job: 3s Local Disk Polling) ──> 100% Broker-Independent
|
||||
[Observability Plane] ────> publish_event.py ──(MQTT 3.1.1)──> nats-server (JetStream + nkeys)
|
||||
```
|
||||
|
||||
1. **제어 평면과 관측 평면의 분리**:
|
||||
MAM의 핵심 루프(`run_loop.sh`)는 MQTT 메시지를 구독하지 않으며, 로컬 파일시스템(`.mam/jobs/<id>.json`)을 3초 주기로 폴링(`wait_for_job`)하여 작업 완료를 판정합니다. 브로커는 **비동기 관측(observability) 사이드카**이며 제어 평면을 차단하지 않습니다.
|
||||
2. **NATS의 실질적 이점은 '서버'에 존재**:
|
||||
NATS의 핵심 강점(단일 무의존 Go 바이너리, JetStream 영속성, nkeys/JWT 계정·Subject별 ACL)은 서버 계층의 속성입니다. `nats-server`는 **MQTT 3.1.1 프로토콜을 네이티브로 수용**하므로, 클라이언트 코드를 한 줄도 바꾸지 않고 서버의 모든 운영·보안 이점을 100% 확보할 수 있습니다.
|
||||
3. **네이티브 NATS(`nats-py`) 전환의 비용 대비 무익함**:
|
||||
`nats-py`는 asyncio 전용 라이브러리로, bash 기반의 단명(short-lived) 동기 CLI 도구들(`publish_event.py` 등)과 심각한 구조적 마찰을 일으키며, 최소 46건의 테스트 재작성 및 276건 green 베이스라인 훼손 위험을 초래합니다. 반면 NATS 고유 기능(Req/Reply, 초당 수백만 처리량, 클러스터링)은 MAM 워크로드(단일 워크스페이스, 잡당 수 개 이벤트)에서 전혀 사용되지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 3대 전략적 선택지 비교 분석
|
||||
|
||||
| 평가 항목 | (A) 현행 유지 (공개 HiveMQ) | (B) 네이티브 NATS 전환 (`nats-py`) | (C) `nats-server` + MQTT 프로토콜 유지 (채택안) |
|
||||
|---|---|---|---|
|
||||
| **클라이언트 코드 변경** | 0줄 | 4개 호출부 전면 비동기 재작성 | **0줄** (환경변수만 구성) |
|
||||
| **테스트 코드 재작성** | 0건 | 최소 46건 재작성 (276건 베이스라인 위험) | **0건** (기존 276건 100% 보존) |
|
||||
| **A-2 보안 결함 해소** | ❌ 불가 (공개 브로커) | ✅ 완전 해소 | ✅ **완전 해소** (nkeys/JWT subject ACL) |
|
||||
| **단일 정적 바이너리 배포** | ❌ 불가 | ✅ 지원 | ✅ **지원** (`nats-server` 바이너리 1개) |
|
||||
| **이벤트 영속성 (JetStream)** | ❌ 미지원 | ✅ 지원 | ✅ **지원** (내장 JetStream 엔진) |
|
||||
| **동기 CLI 호환성** | ✅ 우수 (paho-mqtt) | ❌ 심각 (asyncio 강제) | ✅ **우수** (기존 동기 핫패스 유지) |
|
||||
| **되돌리기(Rollback) 비용** | — | 🔴 높음 (비가역 코드 재작성) | 🟢 **0 (가역적 환경변수 스위치)** |
|
||||
| **최종 평가** | **기각 (보안 위험)** | **기각 (비용 대비 실익 전무)** | 🏆 **최종 채택** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 현행 아키텍처 실측 및 기술적 진단 (Ground Truth)
|
||||
|
||||
### 2.1 제어 경로 상의 MQTT 의존도 실측
|
||||
- `run_loop.sh` (899줄, 메인 오케스트레이터) 내 MQTT 직접 참조는 `:889`의 임시 구독자 로그 파일 삭제 1건뿐입니다.
|
||||
- 작업 완료 감지는 11개 호출 지점(전체 12개 참조) 전체가 `wait_for_job()` 함수를 통해 `.mam/jobs/<id>.json` 파일의 `status` 필드를 3초 간격으로 검사합니다.
|
||||
- 따라서 브로커가 다운되어도 제어 평면 자체는 독립적으로 완주할 수 있는 구조입니다.
|
||||
|
||||
### 2.2 paho-mqtt 결합도 (Blast Radius)
|
||||
- `import paho`는 `mqtt_common.py:32` 단 1곳에 캡슐화되어 있습니다.
|
||||
- 그러나 `make_client()`가 raw `mqtt.Client` 인스턴스를 반환하여 다음 4개 지점에서 구동됩니다:
|
||||
1. `mqtt_common.py:250-276` (`make_client`)
|
||||
2. `publish_event.py:102-122` (발행 및 ACK 대기)
|
||||
3. `job_subscriber.py:172-251` (이벤트 큐잉 및 구독)
|
||||
4. `reconcile.sh:245-292` (내장 python 이벤트 수신)
|
||||
|
||||
---
|
||||
|
||||
## 3. 코드베이스 잠복 결함 분석 (F-1 ~ F-5)
|
||||
|
||||
브로커 제품 선택과 무관하게 현행 코드에 잠복해 있는 5가지 구조적 결함이 발굴되었습니다.
|
||||
|
||||
```
|
||||
[발굴된 결함 체인]
|
||||
F-1: publish 실패 시 return 2 ──> 레지스트리 상태 동기화 누락 ──> run_loop 3900초(65분) 정지
|
||||
F-4: subscriber 미포착 예외 rc=1 ──> loop/discuss 경로에서 job_status="error" 오판정
|
||||
F-2/F-3: 전역 토픽 + auth_token 조건부 ──> 워크스페이스 격리 및 HMAC 검증 사각지대 (A-2)
|
||||
F-5: 매 실행 랜덤 client_id ──> 문서가 주장하는 durable session 구성 불가
|
||||
```
|
||||
|
||||
### 3.1 F-1 (Critical): 발행 실패 시 레지스트리 갱신 누락 (65분 루프 정지)
|
||||
- `publish_event.py:195-199`에서 브로커 네트워크 오류 발생 시 `return 2`로 조기 종료됩니다.
|
||||
- 이로 인해 뒤따르는 `append_event`, `registry.append_event`, `update_job_status(status=completed)`가 실행되지 못합니다.
|
||||
- `wait_for_job`은 `status=running` 상태에서 `max_wait=3900s`를 소진할 때까지 **65분간 정지**합니다.
|
||||
|
||||
### 3.2 F-2 (High): 워크스페이스 지문 토픽 미발행 (A-2)
|
||||
- `reconcile.sh:236`은 지문 토픽(`mam/<fp>/jobs/+/events`)을 구독하지만, `mqtt_common.py:119`는 전역 토픽(`python/mqtt/jobs`)으로만 발행합니다.
|
||||
- 워크스페이스 간 메시지 격리가 실질적으로 비활성화되어 있습니다.
|
||||
|
||||
### 3.3 F-3 (High): HMAC 인증 조건부 공허화 (A-2)
|
||||
- `registry.py:75-79`는 TLS나 사용자 인증이 켜진 보안 브로커 감지 시 `secrets.token_urlsafe(32)`를 자동 생성하나, 기본 공개 브로커(또는 평문 TCP 브로커) 환경에서는 토큰이 발급되지 않아 `auth_token=None`으로 남습니다.
|
||||
- 이로 인해 `verify_hmac`의 `if not auth_token: return True` 분기가 무조건 참이 되어 공개 브로커 환경에서 HMAC 검증이 무력화됩니다 (Track 2에서 전 브로커 대상 무조건 발급으로 심층 방어 적용 필요).
|
||||
|
||||
### 3.4 F-4 (Critical): `loop`/`discuss` 위임 경로의 오판정 결함
|
||||
- `multi-agent-mux-delegate-job:331-341`에서 `wait "$sub_pid"`의 `sub_rc`를 직접 `job_status`로 매핑(`rc=1` -> `job_status="error"`).
|
||||
- 브로커 연결 실패 시 `job_subscriber.py`가 미포착 예외로 `rc=1`을 내므로, **브로커 접속 실패가 작업 에러로 둔갑**합니다.
|
||||
|
||||
### 3.5 F-5 (Medium): 영속 세션(Durable Session) 구성 불가
|
||||
- `make_client()`가 매 실행마다 `uuid.uuid4().hex[:8]`로 랜덤 `client_id`를 생성하므로, 브로커가 재연결 세션을 식별할 수 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 리뷰 및 챌린지 검증 (Challenge Resolution)
|
||||
|
||||
### 4.1 C1 챌린지 분석 및 판정
|
||||
Reviewer (`agy`)가 제기한 `job_subscriber.py`의 제어 경로 블로킹 및 디스크 폴백 누락 지적을 실측 검증하였습니다.
|
||||
|
||||
1. **C1-a (위임 대기 경로 실재)**: `multi-agent-mux-delegate-job:227`에 `wait "$sub_pid"`가 존재하며, `run_loop.sh`의 모든 호출부가 `--type direct`로 이 경로를 통과함을 확인 (수용).
|
||||
2. **C1-b (디스크 폴백 부재)**: `job_subscriber.py`는 오직 `watcher.events.get()`만 대기하므로 브로커 단절 시 이벤트를 수신하지 못함 (수용).
|
||||
3. **C1-c (메커니즘 선후관계)**: C1은 F-1이 해결되어 디스크에 완료 상태가 쓰여진 이후에 드러나는 연쇄 결함임 (정정 및 반영).
|
||||
4. **C1-d (지연 시간 실측)**: 브로커 도달 불가 시 구독자는 15~40초 내 `rc=1`로 조기 종료되어 실제 추가 블로킹은 0초임 (지연 영향 기각, 그러나 감사 로그 오염 및 거짓 실패 판정의 심각성으로 채택).
|
||||
5. **C1-e (해결책 채택)**: `job_subscriber.py`의 대기 루프에 로컬 디스크(`load_job` / `read_logged_status`) 폴백을 도입하여 브로커 단절 시에도 즉시 정상 종료하도록 보강.
|
||||
|
||||
---
|
||||
|
||||
## 5. 단계별 실행 계획 (Actionable Roadmap)
|
||||
|
||||
```
|
||||
[Track 0: 결함 교정] ──> [Track 1: nats-server 스파이크] ──> [Track 2: A-2 보안/격리 해소] ──> [Track 3: 문서화]
|
||||
(F-1, C1, F-4 해결) (S-1 ~ S-9 매트릭스 검증) (F-2, F-3, Token, ACL) (MESSAGING, VERSIONS)
|
||||
```
|
||||
|
||||
### 5.1 Track 0 — 가용성 및 결함 교정 (최우선 과제, 브로커 무관)
|
||||
|
||||
#### Step 순서 의존성 (Strict Ordering)
|
||||
1. **Step 1 (`publish_event.py`)**: 발행 실패 시에도 레지스트리 상태 동기화 및 감사 로그 작성을 완수하고 `return 2` 반환.
|
||||
2. **Step 2 (`job_subscriber.py`)**: `queue.Empty` 시 3초 스로틀로 디스크 터미널 상태를 확인하여 `source: disk-fallback` 합성 이벤트 출력 후 `rc=0` 조기 종료.
|
||||
3. **Step 3 (`multi-agent-mux-delegate-job`)**: 인프라 예외에 전용 `rc=3`을 부여하고 `job_status="broker_unavailable"` 분기 처리.
|
||||
|
||||
#### 회귀 가드 매트릭스 (11종 신설 — G-1 ~ G-11, 목표 287/287 PASS)
|
||||
- **G-1**: 브로커 도달 불가 발행 시 `rc=2`이면서 레지스트리 `status=completed` 확인.
|
||||
- **G-2**: 감사 로그에 `published: false` 및 `publish_error` 필드 기록 확인.
|
||||
- **G-3**: 정상 브로커 발행 시 `rc=0` 및 `published: true` 무회귀 확인.
|
||||
- **G-4**: 발행 실패 시 단조 `last_seq` 증가 및 후속 발행 seq 보존 확인.
|
||||
- **G-5**: 디스크 `status=completed` 선작성 시 브로커 다운 상태에서도 `job_subscriber.py`가 3초 내 `rc=0` 종료.
|
||||
- **G-6**: 디스크 폴백 종료 시 stdout에 `disk-fallback` 명시 확인.
|
||||
- **G-7**: 디스크 `status=error` 시 폴백 `rc=1` 반환 확인.
|
||||
- **G-8**: 다중 잡 감시 시 전체 완료 전까지 조기 종료 방지.
|
||||
- **G-9**: 디스크 터미널 부재 + 브로커 실패 시 `rc=3` 반환 확인 (F-4 방어).
|
||||
- **G-10**: `loop` 위임 경로에서 `rc=3` 수신 시 `job_status`가 `"error"`로 오판되지 않음을 확인.
|
||||
- **G-11**: `registry.register_job()` 호출 시 `auth_token`이 항상 비어있지 않게 생성됨을 단언 (`secrets.token_urlsafe(32)` 유지).
|
||||
|
||||
### 5.2 Track 1 — `nats-server` 스파이크 검증 매트릭스 (S-1 ~ S-9)
|
||||
|
||||
격리 클론(`$SCRATCH/nats-spike`)에서 검증 수행:
|
||||
- **S-1**: `nats-server -js` MQTT 리스너 기본 구동 및 `started/progress/completed` 발행 수용 (`rc=0`).
|
||||
- **S-2**: paho-mqtt 2.x `CallbackAPIVersion.VERSION2` CONNACK 호환성 검증.
|
||||
- **S-3 (핵심 관문)**: **Retained terminal event 정상 전달 검증** (늦은 구독자의 즉시 최종 상태 수신). *실패 시 mosquitto로 회귀*.
|
||||
- **S-4**: QoS 1 `wait_for_publish` ACK 동작 검증.
|
||||
- **S-5**: 와일드카드 토픽(`mam/<fp>/jobs/+/events`) 구독 및 라우팅 검증.
|
||||
- **S-6**: TLS 암호화 및 유저 인증 접근 제어 검증.
|
||||
- **S-7**: Subject/Topic 레벨 권한 분리(Publisher write-only / Subscriber read-only) 검증.
|
||||
- **S-8**: 전체 287건 회귀 테스트 100% PASS 검증.
|
||||
- **S-9**: Track 0 디스크 폴백이 `nats-server` 장애 상황에서도 정상 동작함을 통합 검증.
|
||||
|
||||
### 5.3 Track 2 — A-2 보안 및 워크스페이스 격리 해소
|
||||
1. **F-3 해소**: `registry.register_job()`에서 브로커 설정(TLS/인증 유무)과 무관하게 `secrets.token_urlsafe(32)` 기반 `auth_token`을 **무조건 항상 발급**.
|
||||
2. **F-2 해소**: `DEFAULT_TOPIC_ROOT`를 `mam/<sha256[:12]>/jobs`로 전환. 발행측 전환 후 `reconcile.sh`의 레거시 구독 단계적 제거.
|
||||
3. 배포 설정에 nkeys 기반 계정 분리 적용.
|
||||
|
||||
### 5.4 Track 3 — 문서 및 설정 동기화
|
||||
- `MESSAGING.md`: 브로커 사양을 `nats-server`로 갱신, F-1/C1 해소 기록, F-5 실측에 맞춘 영속 세션 설명 정정.
|
||||
- `IMPROVEMENTS.md` & `VERSIONS.md`: A-2 완료 전환, F-1/F-4/F-5 백로그 이력 반영.
|
||||
- `.mam.env`: `nats-server` 포트(1883/8883) 및 인증 템플릿 갱신.
|
||||
|
||||
---
|
||||
|
||||
## 6. 비-목표 (Explicit Non-Goals)
|
||||
|
||||
1. ❌ **`nats-py` 라이브러리 도입 및 클라이언트 비동기 재작성**: 불필요한 복잡도 및 장애 유발.
|
||||
2. ❌ **`.mam/jobs/*.json`의 JetStream KV 대체**: 파일시스템 폴링 제어 계약을 훼손하므로 상태 계층 변경 제외.
|
||||
3. ❌ **Durable Session 강제 도입을 위한 `client_id` 고정**: 동시성 충돌 위험이 크며, Track 0 디스크 폴백이 동일 복원력을 무비용으로 제공함.
|
||||
4. ❌ **`requirements.txt` 내 `paho-mqtt>=2.0.0` 제거**: 현행 종속성 유지.
|
||||
|
||||
---
|
||||
|
||||
## 7. 결론
|
||||
|
||||
MAM 프레임워크의 메시징 백플레인은 **클라이언트 프로토콜(MQTT)을 100% 보존한 상태에서 `nats-server`를 전용 브로커로 채택(Option C)**하는 것이 기술적·운영적·보안적 최적해입니다.
|
||||
|
||||
선행 필수 과제인 **Track 0(F-1 + C1 + F-4 가용성 결함 교정)**을 우선 완수한 후, 스파이크 검증(Track 1) 및 A-2 보안 강화(Track 2)를 순차 전개합니다.
|
||||
@@ -1,584 +0,0 @@
|
||||
# 🔒 MAM 개인 전용 브로커(Private Broker) 구축 및 연동 가이드 (`PRIVATE_SERVER.md`)
|
||||
|
||||
- **작성일**: 2026-08-20 (Rev.2)
|
||||
- **문서 목적**: MAM(Multi-Agent Mux)의 공개 브로커 의존성 및 보안 결함(A-2)을 해소하기 위한 개인 전용 브로커(NATS / Mosquitto) 구축, 운영, 다능성 활용 및 MAM 클라이언트 연동 가이드.
|
||||
- **연계 문서**: [`NATS_REPORT.md`](NATS_REPORT.md), [`implementation_plan.md`](implementation_plan.md), [`IMPROVEMENTS.md`](IMPROVEMENTS.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 도입 배경
|
||||
|
||||
현재 MAM 프레임워크의 기본 메시징 브로커는 공개 서버(`broker.hivemq.com:1883`)로 설정되어 있습니다. 개인 전용 브로커(Private Broker)를 구축하여 연결하면 **클라이언트 코드 변경 없이(0줄 변경)** 보안 위험을 원천 차단하고 네트워크 안정성을 대폭 향상시킬 수 있습니다.
|
||||
|
||||
```
|
||||
[MAM Orchestrator / Agents]
|
||||
│
|
||||
▼ (MQTT 3.1.1 / TLS)
|
||||
[Private Dedicated Broker] ───> 사설망/개인 서버 (NATS Server / Mosquitto)
|
||||
• 외부 불법 트래픽 100% 차단 (A-2 보안 해소)
|
||||
• JetStream 영속성 및 NKey/JWT ACL 지원
|
||||
• 초저지연 (<1ms) 및 무제한 대역폭
|
||||
```
|
||||
|
||||
MAM의 제어 평면(`run_loop.sh`의 `wait_for_job` 파일시스템 폴링)은 브로커와 100% 독립적으로 작동하므로, 브로커는 **비동기 관측(observability) 사이드카** 역할을 수행합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 해결 영역 매트릭스 (브로커 전환 vs 코드 패치)
|
||||
|
||||
전용 브로커 구축으로 즉시 해결되는 영역과, 로컬 코드 패치(Track 0)가 병행되어야 하는 영역의 명확한 구분입니다.
|
||||
|
||||
| 구분 | 당면 과제 | 개인 브로커 구축 시 | 로컬 코드 패치 필요 여부 (Track 0) |
|
||||
|---|---|:---:|:---:|
|
||||
| **보안 (A-2)** | 공개 브로커 노출 및 외부 악의적 이벤트 수신 위협 | 🟢 **100% 즉시 해소** (사설망/ACL 격리) | Track 2에서 토큰 발급 강제 |
|
||||
| **안정성** | 공개 브로커의 예고 없는 순단 및 속도 제한(Rate-limit) | 🟢 **100% 즉시 해소** (전용 리소스) | — |
|
||||
| **내결함성 (B-14)** | 브로커 일시 장애 시 65분 루프 정지(Hang) 결함 | ⚠️ 브로커 점검/순단 시 여전히 위험 | 🔴 **필수 (Track 0 Step 1 선행 패치)** |
|
||||
| **지연/오판 (B-15)** | 브로커 다운 시 120초 지연 및 정상 작업의 에러 오판정 | ⚠️ 브로커 점검/순단 시 여전히 위험 | 🔴 **필수 (Track 0 Step 2 & 3 선행 패치)** |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **방탄 아키텍처 원칙:**
|
||||
> "Track 0(`B-14`, `B-15`) 패치를 통해 브로커가 다운되어도 루프가 100% 정상 완주하도록 로컬 디스크 내결함성을 먼저 확보하고, 개인 브로커를 연결하여 A-2 보안과 성능을 완결합니다."
|
||||
|
||||
---
|
||||
|
||||
## 3. 전용 브로커 추천 및 비교
|
||||
|
||||
MAM 클라이언트는 표준 `paho-mqtt`를 사용하므로, MQTT 3.1.1을 지원하는 모든 브로커와 100% 호환됩니다.
|
||||
|
||||
| 비교 항목 | 🏆 `nats-server` (강력 권장) | `eclipse-mosquitto` (대안) |
|
||||
|---|---|---|
|
||||
| **아키텍처** | Go 단일 정적 바이너리 (Zero Dependency) | C 기반 경량 오픈소스 브로커 |
|
||||
| **주요 특징** | • 내장 MQTT 3.1.1 리스너 (`mqtt { port: 1883 }`)<br>• JetStream 엔진 내장 (이벤트 영속화 및 복구)<br>• NKey/JWT 기반 계정 및 Subject별 세분화된 ACL<br>• WebSocket 및 NATS 네이티브 프로토콜 동시 서빙 | • 가장 널리 쓰이는 표준 경량 MQTT 브로커<br>• 낮은 메모리 점유율 (~10MB) |
|
||||
| **추천 용도** | 모던 인프라, 확장성, 감사 로그 영속화, 홈랩 통합 | 정통 초경량 임베디드/단일 목적 환경 |
|
||||
| **배포 난이도** | 🟢 바이너리 1개 실행 또는 Docker 1줄 | 🟢 패키지 매니저 (`apt`, `brew`) 또는 Docker |
|
||||
|
||||
---
|
||||
|
||||
## 4. 개인 서버 브로커 배포 가이드
|
||||
|
||||
### 4.1 `nats-server` 배포 (권장)
|
||||
|
||||
`nats-server`에서 MQTT를 활성화하려면 설정 파일(`nats.conf`)에 `mqtt { port: 1883 }` 블록과 `jetstream { }` 블록이 반드시 포함되어야 합니다.
|
||||
|
||||
> [!NOTE]
|
||||
> `nats-server`의 `-m` 플래그는 HTTP 모니터링 포트(`--http_port`)를 지정하는 옵션이며, MQTT를 켜는 플래그가 아닙니다. MQTT 활성화는 반드시 `-c nats.conf` 설정 파일을 통해 구성해야 합니다.
|
||||
|
||||
#### 1) 공통 설정 파일 (`nats.conf`)
|
||||
```conf
|
||||
# nats.conf
|
||||
server_name: mam-hub
|
||||
|
||||
# JetStream 영속 스토리지 (MQTT QoS 1 및 Retained 메시지 처리에 필수)
|
||||
jetstream {
|
||||
store_dir: "/data" # Docker 환경 기본 스토리지 (네이티브 실행 시 $HOME 전개)
|
||||
max_file: 10G # 홈랩 디스크 상한 설정
|
||||
}
|
||||
|
||||
# HTTP 모니터링 엔드포인트 (/varz, /jsz 대시보드)
|
||||
http_port: 8222
|
||||
|
||||
# 평면 A: MAM MQTT 3.1.1 프로토콜 리스너
|
||||
mqtt {
|
||||
port: 1883
|
||||
}
|
||||
|
||||
# 평면 B: 홈랩/웹 브라우저 대시보드용 WebSocket 리스너 (선택 사항)
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 내부 사설망 한정
|
||||
}
|
||||
```
|
||||
|
||||
#### 2) 배포 방법 A. Docker / Docker Compose (권장)
|
||||
|
||||
**단일 Docker 실행:**
|
||||
```bash
|
||||
# 호스트에 nats.conf 생성 후 실행 (D-2: alpine 고정 핀, D-3: 루프백/바인드 한정)
|
||||
docker run -d \
|
||||
--name mam-nats \
|
||||
--restart unless-stopped \
|
||||
-p "${MQTT_BIND:-127.0.0.1}:1883:1883" \
|
||||
-p "${NATS_BIND:-127.0.0.1}:4222:4222" \
|
||||
-p "127.0.0.1:8222:8222" \
|
||||
-p "${WS_BIND:-127.0.0.1}:8080:8080" \
|
||||
-v ./nats.conf:/etc/nats/nats.conf:ro \
|
||||
-v nats-data:/data \
|
||||
nats:2.12-alpine \
|
||||
-c /etc/nats/nats.conf
|
||||
```
|
||||
|
||||
**Docker Compose (`docker-compose.yml`):**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
ports:
|
||||
- "${MQTT_BIND:-127.0.0.1}:1883:1883" # MQTT 3.1.1 포트 (평면 A: MAM)
|
||||
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 기본 포트 (평면 B)
|
||||
- "127.0.0.1:8222:8222" # HTTP 모니터링 (/varz, /jsz, /healthz)
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (평면 B)
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data
|
||||
|
||||
volumes:
|
||||
nats-data:
|
||||
```
|
||||
|
||||
#### 3) 배포 방법 B. 네이티브 바이너리 설치 (macOS / Linux — 비루트 사용자 공간)
|
||||
|
||||
macOS의 sealed APFS 루트 볼륨(`/data`) 권한 문제를 방지하기 위해 사용자 홈 디렉터리(`~/.config/nats/`, `~/.local/share/nats/data`)를 기본 스토리지로 사용합니다.
|
||||
|
||||
```bash
|
||||
# 설정 및 데이터 디렉터리 생성 (sudo 불필요)
|
||||
mkdir -p ~/.config/nats ~/.local/share/nats/data
|
||||
|
||||
# 설정 파일 작성 (D-1: 비인용 heredoc <<EOF 으로 $HOME 을 파일 생성 시점에 절대경로로 고정)
|
||||
cat <<EOF > ~/.config/nats/nats.conf
|
||||
server_name: mam-hub
|
||||
jetstream {
|
||||
store_dir: "$HOME/.local/share/nats/data"
|
||||
max_file: 10G
|
||||
}
|
||||
http_port: 8222
|
||||
mqtt {
|
||||
port: 1883
|
||||
}
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true
|
||||
}
|
||||
EOF
|
||||
|
||||
# macOS (Homebrew 설치 및 실행)
|
||||
brew install nats-server
|
||||
nats-server -c ~/.config/nats/nats.conf &
|
||||
|
||||
# Linux (x86_64 단일 바이너리 설치 및 실행)
|
||||
curl -L https://github.com/nats-io/nats-server/releases/download/v2.10.20/nats-server-v2.10.20-linux-amd64.tar.gz | tar xz
|
||||
sudo mv nats-server-v2.10.20-linux-amd64/nats-server /usr/local/bin/
|
||||
nats-server -c ~/.config/nats/nats.conf &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `mosquitto` 배포 (대안)
|
||||
|
||||
#### Docker 실행:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name mam-mosquitto \
|
||||
--restart unless-stopped \
|
||||
-p 1883:1883 \
|
||||
-v ./mosquitto.conf:/mosquitto/config/mosquitto.conf \
|
||||
eclipse-mosquitto:latest
|
||||
```
|
||||
|
||||
**기본 `mosquitto.conf` 설정 파일 예시:**
|
||||
```conf
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 하나의 서버로 여러 프로젝트 — `nats-server` 다능성 (Versatility)
|
||||
|
||||
`nats-server`의 다능성은 **MAM을 네이티브 NATS로 이관할 이유가 아니라, MAM 코드를 한 줄도 바꾸지 않고도 얻을 수 있는 부가적 이득**입니다.
|
||||
|
||||
### 5.1 두 개의 소비 평면 및 세 가지 접속 경로 (Consumption Planes & Transport Paths)
|
||||
|
||||
`nats-server`는 단일 프로세스 내에서 여러 프로토콜 리스너를 동시에 구동하므로, MAM의 단순성과 개인 홈랩의 확장성을 완벽히 양립시킵니다.
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ nats-server (단일 인스턴스) │
|
||||
├─────────────────────────────┬─────────────────────────────┤
|
||||
│ 평면 A: MAM 워크로드 │ 평면 B: 홈랩/개인 프로젝트 │
|
||||
├─────────────────────────────┼─────────────────────────────┤
|
||||
프로토콜 │ MQTT 3.1.1 (포트 1883) │ NATS(4222), WebSocket(8080) │
|
||||
클라이언트 │ paho-mqtt (코드 변경 0줄) │ nats-py, nats.js, MQTT.js 등│
|
||||
사용 기능 │ QoS 1, Retain, 와일드카드, TLS│ JetStream 리플레이, KV, Object│
|
||||
설계 원칙 │ 초경량 동기 CLI 핫패스 보존 │ 고급 비동기 이벤트 스트리밍 │
|
||||
공유 자원 │ └───── 단일 정적 바이너리 / JetStream 스토리지 / ACL ─────┘│
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
특히 **WebSocket 포트(8080)**는 접속 경로(URL Path)에 따라 두 가지 프로토콜을 동시에 서빙합니다:
|
||||
1. **MQTT 1883**: TCP 네이티브 MQTT 3.1.1 (MAM CLI 에이전트 표준 경로)
|
||||
2. **WebSocket 8080 (`/mqtt` 경로)**: **MQTT-over-WebSocket** (`MQTT.js` 등으로 접속). 서버 내부에서 1883 리스너와 동일하게 취급되어 **retained 종료 이벤트를 완전하게 수신**합니다 (N-7).
|
||||
3. **WebSocket 8080 (`/` 또는 경로 없음)**: **NATS 네이티브 WebSocket** (`nats.ws` 등으로 접속). 라이브 스트림만 수신하며 retained 메시지는 수신하지 않습니다 (N-1).
|
||||
|
||||
### 5.2 교차 프로토콜 브리징 (Cross-Protocol Bridging)
|
||||
- `nats-server`는 내부적으로 MQTT 토픽(`/`)을 NATS Subject(`.`)로 실시간 자동 변환합니다.
|
||||
- MAM 에이전트가 MQTT 토픽 `python/mqtt/jobs/<job_id>/events`로 이벤트를 발행하면, 웹 브라우저나 타 프로젝트의 NATS 구독자는 NATS Subject `python.mqtt.jobs.<job_id>.events` 또는 `python.mqtt.jobs.*.events`로 즉시 실시간 수신할 수 있습니다.
|
||||
- **실용적 이점**: MAM 소스 코드를 단 1줄도 수정하지 않고도 React/Vue 웹 대시보드나 터미널 모니터링 툴을 즉시 부착할 수 있습니다.
|
||||
- **경계 (필수 인지 — N-1 & N-7)**:
|
||||
- NATS 네이티브(경로 없는 WebSocket 또는 4222)로 접속하는 경우: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. 잡이 끝난 뒤 접속한 네이티브 NATS 대시보드는 그 잡의 **종료 이벤트를 수신하지 못합니다** (retained 메시지는 MQTT SUBSCRIBE 경로 전용).
|
||||
- 웹 대시보드에서 사후 종료 이벤트까지 무상으로 수신하려면 **`ws://<host>:8080/mqtt` 경로로 `MQTT.js` 클라이언트를 연결**하십시오 (N-7). 리플레이 스트림 구축 및 디스크 관리 부담 없이 완전한 종료 이벤트를 즉시 수신할 수 있습니다.
|
||||
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자(`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject가 보이지 않습니다.
|
||||
- **주의 사항**: 토픽 레벨 내에 마침표(`.`)가 포함되면 NATS 계층에서 토큰이 분리될 수 있으나, MAM의 `job_id`는 8자리 hex, 워크스페이스 지문은 12자리 hex이므로 안전합니다.
|
||||
|
||||
### 5.3 JetStream 이벤트 리플레이 (Event Replay)
|
||||
- `python.mqtt.jobs.>` Subject를 구독하는 JetStream 스트림을 생성하면, 지난 작업의 이벤트 스트림 전체를 시점 지정(Time-based) 또는 시퀀스 지정(Sequence-based)으로 사후 리플레이할 수 있습니다.
|
||||
- **주의 사항**: 이 기능은 옵트인(Opt-in)이며, MQTT QoS 1 처리를 위한 내부 시스템 스트림(`$MQTT_*`)과 별개로 관리됩니다. 디스크 용량 관리를 위해 `max_age`나 `max_bytes` 상한을 반드시 설정해야 합니다.
|
||||
|
||||
### 5.4 내장 Key-Value (KV) 및 Object Store
|
||||
- 홈랩 및 개인 프로젝트에서 Redis나 MinIO 같은 별도 인프라를 띄우지 않고도 `nats-server` 내장 KV 및 Object Store를 즉시 사용할 수 있습니다.
|
||||
- **금지 사항 (Non-Goal)**: MAM의 로컬 레지스트리(`.mam/jobs/*.json`)를 JetStream KV로 대체해서는 안 됩니다 (`wait_for_job`의 fcntl 및 파일시스템 폴링 계약 유지).
|
||||
|
||||
### 5.5 멀티테넌트 계정 분리 및 보안
|
||||
- 단일 서버 내에서 `MAM` 전용 계정과 `HOME` 개인 계정을 분리하여 리소스 쿼터와 권한을 완벽히 격리할 수 있습니다.
|
||||
- **권고 배치**: MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치하고(관측자는 읽기 전용 `mam_observer` 역할 부여), 무관한 홈랩 서비스는 별도 계정(`HOME`)에 배치합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. MAM 클라이언트 연동 설정 (`.mam.env`)
|
||||
|
||||
개인 서버 브로커가 구동되면, MAM 저장소 루트의 [`.mam.env`](file:///.mam.env) 파일에 개인 서버 주소를 등록합니다.
|
||||
|
||||
> [!NOTE]
|
||||
> MAM 코드(`mqtt_common.py`)는 `MQTT_*` 접두사의 환경변수를 읽습니다. 이전 비공식 문서의 `MAM_MQTT_*` 변수는 무효하므로 반드시 아래의 표준 변수명을 사용해야 합니다.
|
||||
|
||||
```bash
|
||||
# ==============================================================================
|
||||
# MAM Private MQTT Broker Configuration (.mam.env)
|
||||
# ==============================================================================
|
||||
|
||||
# ── 모델 T (Tailscale 사설 오버레이망 권장) ───────────────────────
|
||||
MQTT_BROKER="mam-hub.tailXXXX.ts.net" # 또는 100.x.y.z (평문이므로 IP 사용 가능)
|
||||
MQTT_PORT=1883
|
||||
MQTT_TLS=0
|
||||
MQTT_USERNAME=mam_agent
|
||||
MQTT_PASSWORD=replace_me_with_token
|
||||
MQTT_KEEPALIVE=60 # WAN 구간 권장 연결 유지 시간
|
||||
|
||||
# ── 모델 P (공개 TLS / Let's Encrypt 모델) ───────────────────────
|
||||
# MQTT_BROKER="mam-broker.example.com" # D-4: 반드시 인증서 SAN 의 DNS 이름 (IP 금지)
|
||||
# MQTT_PORT=8883
|
||||
# MQTT_TLS=1
|
||||
# MQTT_USERNAME=mam_agent
|
||||
# MQTT_PASSWORD=replace_me_with_token
|
||||
# MQTT_CA_CERTS 는 Let's Encrypt 사용 시 '설정하지 않음' (시스템 신뢰 저장소 사용)
|
||||
```
|
||||
|
||||
*참고: OS 환경변수에 동일한 이름이 이미 `export`되어 있는 경우 OS 환경변수가 `.mam.env` 파일 설정보다 우선합니다.*
|
||||
|
||||
---
|
||||
|
||||
## 7. 연동 및 동작 검증 테스트 (4-Step Verification)
|
||||
|
||||
개인 서버 브로커와의 연동 상태를 정확하게 검증하는 4단계 절차입니다.
|
||||
|
||||
### Step 1. 브로커 리스너 및 JetStream 상태 확인
|
||||
```bash
|
||||
# MQTT 리스너 활성화 확인
|
||||
curl -s http://127.0.0.1:8222/varz | grep -i mqtt
|
||||
|
||||
# JetStream 엔진 정상 구동 확인
|
||||
curl -s http://127.0.0.1:8222/jsz
|
||||
```
|
||||
|
||||
### Step 2. 임시 잡 등록 및 연결 검증 이벤트 발행
|
||||
`publish_event.py`는 레지스트리에 등록된 잡에 대해서만 발행을 수행하므로, 임시 잡을 등록하고 발행한 후 완료 처리합니다.
|
||||
|
||||
```bash
|
||||
# 1) 임시 잡 등록 (자동 채번된 JID 캡처)
|
||||
JID=$(.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
|
||||
--registry-dir .mam/jobs \
|
||||
register \
|
||||
--prompt "Private broker connectivity test" \
|
||||
--agent-session "herdr:test")
|
||||
echo "registered test job: $JID"
|
||||
|
||||
# 2) 이벤트 발행 (상세 로그 출력 및 rc=0 단언)
|
||||
.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py \
|
||||
--registry-dir .mam/jobs \
|
||||
--job "$JID" \
|
||||
--event progress \
|
||||
--detail "Private broker connection verified" -v
|
||||
|
||||
# 3) 테스트 잡 종결 처리 (미종결 시 --wait-any 유령 잡 잔존 방지)
|
||||
.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
|
||||
--registry-dir .mam/jobs status --job "$JID" --set completed
|
||||
```
|
||||
|
||||
### Step 3. 접속 대상 브로커 IP 단언
|
||||
Step 2의 `-v` 출력 로그 또는 `.mam/delegate_job_logs/$JID/events.ndjson` 파일에서 실제 접속 호스트가 개인 브로커 IP로 나타나고 `broker.hivemq.com`이 포함되지 않았는지 확인합니다.
|
||||
|
||||
### Step 4. 단위 회귀 테스트 검증
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/ -q
|
||||
```
|
||||
*참고: MAM의 기본 단위/컴포넌트 테스트 스위트는 모의(Mock) 객체를 사용하므로 브로커 연결 여부와 무관하게 100% 통과합니다. 실제 네트워크 연동 검증은 Step 1~3이 담당합니다.*
|
||||
|
||||
---
|
||||
|
||||
## 8. 권장 실행 순서
|
||||
|
||||
```
|
||||
[Phase 1: 내결함성 확보] ──> [Phase 2: 개인 브로커 가동] ──> [Phase 3: A-2 보안 완전 종결]
|
||||
Track 0 (B-14, B-15) nats-server (nats.conf) 지문 토픽 및 인증 토큰 발급
|
||||
로컬 디스크 폴백 패치 .mam.env 환경변수 연동 외부 간섭 100% 차단
|
||||
```
|
||||
|
||||
1. **Phase 1 (Track 0 선행 패치)**: `publish_event.py`와 `job_subscriber.py`의 로컬 디스크 폴백(`B-14`, `B-15`)을 먼저 적용하여 브로커 다운 시에도 루프가 멈추지 않는 방탄 구조를 확립합니다.
|
||||
2. **Phase 2 (개인 브로커 가동)**: 개인 서버에 `nats-server -c nats.conf`를 구동하고 `.mam.env`에 `MQTT_BROKER`를 연결합니다.
|
||||
3. **Phase 3 (A-2 보안 완전 종결)**: 워크스페이스 지문 토픽(`mam/<sha256[:12]>/jobs/...`) 및 무조건 토큰 발급을 적용하여 공개 브로커 위험을 완전히 영구 폐기합니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 원격 서버 `nats-server` Docker 프로덕션 배포 가이드 (Track 1R)
|
||||
|
||||
원격 VPS 또는 상시 가동 홈랩 서버에 프로덕션 수준의 `nats-server`를 Docker 기반으로 구축하고 MAM과 연동하는 표준 절차입니다.
|
||||
|
||||
> [!NOTE]
|
||||
> **정본 자산 안내**: 본 절의 설정은 [`docker/`](docker/) 디렉터리에 정본(canonical) 파일(`docker/docker-compose.yaml`, `docker/nats.conf`, `docker/.env.example`, `docker/README.md`)로 관리되며, 아래 코드 펜스는 그 사본입니다. 두 곳이 어긋나면 `tests/test_deploy_freshness.py`의 D-22 ~ D-30 회귀 가드가 실패합니다.
|
||||
|
||||
### 9.1 프로덕션 `nats.conf`
|
||||
|
||||
```conf
|
||||
# ==============================================================================
|
||||
# docker/nats.conf — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
#
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.1
|
||||
# 시크릿: 이 파일에는 없습니다. 모든 password 는 docker/.env → compose
|
||||
# environment → 컨테이너 환경변수로 주입되는 $VAR 참조입니다.
|
||||
#
|
||||
# ⚠ 암호 문자 제약: NATS 는 환경변수 값을 자체 설정 렉서로 재파싱합니다.
|
||||
# 암호에 [공백 ; , ] } # ' " $] 가 들어가면 설정이 깨지거나 다르게 해석됩니다.
|
||||
# 반드시 `openssl rand -base64 32` (알파벳 A-Za-z0-9+/=) 를 사용하십시오.
|
||||
# ==============================================================================
|
||||
server_name: mam-hub
|
||||
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. 종료 이벤트 재수신이 여기에 의존 ──
|
||||
jetstream {
|
||||
store_dir: "/data" # 절대경로 고정. '~' 도 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G # 접미사는 대문자만 유효 (K/M/G/T)
|
||||
max_mem: 256M
|
||||
}
|
||||
|
||||
http_port: 8222 # 무인증 모니터링 → 호스트 게시는 loopback 한정 (compose)
|
||||
|
||||
mqtt {
|
||||
port: 1883
|
||||
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
|
||||
max_ack_pending: 1024 # 다중 에이전트 동시 발행 여유 (상한 65535)
|
||||
}
|
||||
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 사설망/tailnet 한정. 생략하면 TLS 설정 필수라 기동 실패
|
||||
|
||||
# ── 원점(Origin) 정책 ────────────────────────────────────────────────
|
||||
# 기본값은 이미 '모든 출처 허용'입니다. same_origin 의 기본값은 false 이고
|
||||
# allowed_origins 가 비어 있으면 checkOrigin() 이 Origin 헤더를 읽지도 않고
|
||||
# 즉시 nil 을 반환합니다 (server/websocket.go:1039).
|
||||
# → http://localhost:3000 의 브라우저 대시보드는 별도 설정 없이 접속됩니다.
|
||||
# → `same_origin: false` 를 적는 것은 no-op 입니다.
|
||||
#
|
||||
# 8080 을 tailnet 밖으로 노출한다면 아래를 켜서 출처를 좁히십시오.
|
||||
# 주의: allowed_origins 를 비우지 않는 순간 원점 검사가 '켜집니다'.
|
||||
# "*" 는 절대 쓰지 마십시오 — 옵션 검증 실패로 서버가 기동하지 못합니다
|
||||
# (websocket.go:1142 "must be absolute URLs with http or https scheme").
|
||||
# allowed_origins: ["https://dashboard.example", "http://localhost:3000"]
|
||||
|
||||
# ── 이 포트는 MQTT-over-WebSocket 도 서빙합니다 ───────────────────────
|
||||
# 경로 /mqtt 로 붙으면 완전한 MQTT 클라이언트가 됩니다 (mqtt.go:193,
|
||||
# websocket.go:1335 → createMQTTClient, 1883 리스너와 동일 함수).
|
||||
# ws://<host>:8080/mqtt → MQTT. retained 종료 이벤트를 받습니다.
|
||||
# ws://<host>:8080/ → NATS 네이티브. retained 를 받지 못합니다 (N-1).
|
||||
# 브라우저 대시보드는 MQTT.js 로 /mqtt 에 붙이는 것을 권장합니다.
|
||||
}
|
||||
|
||||
# ── 인증 및 멀티테넌시 ────────────────────────────────────────────────────
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
|
||||
users: [
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체 (.mam.env 의 MQTT_USERNAME)
|
||||
{ user: mam_agent, password: $MAM_BROKER_PASS }
|
||||
|
||||
# 관측자 — 대시보드/모니터링. 반드시 MAM 계정 안에 위치
|
||||
{ user: mam_observer, password: $MAM_OBSERVER_PASS,
|
||||
permissions: {
|
||||
subscribe: { allow: ["python.mqtt.jobs.>"] } # M3 이후: "mam.<fp>.jobs.>"
|
||||
publish: { deny: [">"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
# MAM 과 무관한 홈랩 서비스 전용. MAM subject 는 보이지 않음(의도된 격리)
|
||||
HOME: { jetstream: enabled, users: [ { user: home, password: $HOME_BROKER_PASS } ] }
|
||||
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
|
||||
}
|
||||
system_account: SYS
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **관측자는 반드시 `MAM` 계정 안에 둡니다.** NATS 계정은 하드 격리 경계이므로 `user: home`(계정 `HOME`)으로 접속한 클라이언트는 `python.mqtt.jobs.>`를 구독해도 **0건**을 받습니다. 서로 다른 신뢰 도메인이라 계정을 반드시 갈라야 하는 예외 상황은 **부록 X(Option A)** 를 따르십시오.
|
||||
|
||||
### 9.2 프로덕션 `docker/docker-compose.yaml`
|
||||
|
||||
```yaml
|
||||
# ==============================================================================
|
||||
# docker/docker-compose.yaml — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.2
|
||||
#
|
||||
# 사용법: cd docker && cp .env.example .env && <시크릿 채우기> && docker compose up -d
|
||||
# ==============================================================================
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine # alpine 필수: healthcheck 의 wget 이 여기에만 있음
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
environment:
|
||||
# 미설정/빈 값이면 컨테이너 생성 전에 compose 가 중단 → fail-closed
|
||||
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set MAM_BROKER_PASS in docker/.env}
|
||||
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set MAM_OBSERVER_PASS in docker/.env}
|
||||
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set HOME_BROKER_PASS in docker/.env}
|
||||
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set SYS_BROKER_PASS in docker/.env}
|
||||
ports:
|
||||
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 노출 통제는 방화벽이 아니라
|
||||
# 여기의 바인드 주소가 담당합니다. 기본값은 전부 loopback.
|
||||
- "${MQTT_BIND:-127.0.0.1}:1883:1883" # MQTT 3.1.1 (평면 A: MAM)
|
||||
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 네이티브 (평면 B)
|
||||
- "127.0.0.1:8222:8222" # 무인증 모니터링 — loopback 고정
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (NATS + /mqtt 경로의 MQTT)
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data # nats.conf 의 store_dir 와 일치
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
|
||||
volumes:
|
||||
nats-data:
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Docker의 published 포트는 UFW를 우회합니다.** Docker가 삽입하는 NAT/FORWARD 규칙이 `ufw`의 INPUT 체인보다 먼저 평가되므로, `ufw deny 1883`을 걸어도 `-p 1883:1883`으로 게시한 포트는 인터넷에 열립니다. 본 구성이 방화벽에만 의존하지 않고 **published 포트 자체에 바인드 주소를 명시**(`${MQTT_BIND}:1883:1883`)하는 이유입니다.
|
||||
|
||||
### 9.3 원격 네트워킹 & 보안 가이드
|
||||
|
||||
| 항목 | 🏆 **모델 T: Tailscale/WireGuard 오버레이 (권장)** | 모델 P: 공개 TLS (Let's Encrypt) |
|
||||
|---|---|---|
|
||||
| 인터넷 노출 면적 | **0** (공개 리스너 없음) | 8883 1개 |
|
||||
| 인증서 필요 | 불필요 (`MQTT_TLS=0`) | 필수 + 90일 갱신 |
|
||||
| **D-4 호스트명 제약** | **해당 없음** | 도메인 필수, IP 불가 |
|
||||
| 도메인 필요 | 불필요 | **필수** |
|
||||
| 이동성 | 자동 (Tailscale mesh) | 자동 |
|
||||
|
||||
**방화벽 (UFW) 설정 (모델 T):**
|
||||
```bash
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow 22/tcp
|
||||
|
||||
# tailnet 인터페이스만 허용
|
||||
sudo ufw allow in on tailscale0 to any port 1883 proto tcp
|
||||
sudo ufw allow in on tailscale0 to any port 4222 proto tcp
|
||||
sudo ufw allow in on tailscale0 to any port 8080 proto tcp
|
||||
|
||||
sudo ufw enable && sudo ufw status verbose
|
||||
```
|
||||
|
||||
**시크릿 생성 및 환경변수 주입 (`docker/.env`):**
|
||||
```bash
|
||||
# docker/.env.example 복사 및 권한 제한
|
||||
cd docker && cp .env.example .env && chmod 600 .env
|
||||
|
||||
# 시크릿 암호 생성 (openssl rand -base64 32 사용)
|
||||
# .env 파일을 열고 생성된 시크릿 및 바인드 주소 입력:
|
||||
# MAM_BROKER_PASS=<생성된_토큰>
|
||||
# MAM_OBSERVER_PASS=<생성된_토큰>
|
||||
# HOME_BROKER_PASS=<생성된_토큰>
|
||||
# SYS_BROKER_PASS=<생성된_토큰>
|
||||
# MQTT_BIND=$(tailscale ip -4)
|
||||
# WS_BIND=$(tailscale ip -4)
|
||||
```
|
||||
|
||||
### 9.4 원격 검증 플레이북 (R-1 ~ R-10)
|
||||
|
||||
| ID | 검증 항목 | 방법 | 통과 기준 |
|
||||
|---|---|---|---|
|
||||
| **R-1** | 브로커 헬스 | SSH 터널 후 `curl -sf http://127.0.0.1:8222/healthz` | **HTTP 200** |
|
||||
| **R-2** | 리스너 + TLS 신원 | `curl -s .../varz \| grep -i mqtt`; 모델 P는 `openssl s_client -connect H:8883 -servername H` | MQTT 리스너 노출, SAN에 `MQTT_BROKER` 포함 |
|
||||
| **R-3** | 노출 면적 단언 | 외부 망에서 `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` | 전부 closed/filtered |
|
||||
| **R-4** | 왕복 pub/sub + 계정 JetStream | 임시 잡 등록 → 구독자 기동 → `progress` 발행 → 수신 → 종결 | 수신 성공, `JetStream not enabled for account` **미발생** |
|
||||
| **R-5** | **retained 종료 이벤트 (MQTT)** | `--event completed` 발행 **후** 신규 `job_subscriber.py` 기동 | 즉시 최종 이벤트 수신 |
|
||||
| **R-6** | 브로커 신원 단언 | `grep -c 'broker.hivemq.com' .mam/delegate_job_logs/$JID/events.ndjson` | **0**, 원격 호스트 등장 |
|
||||
| **R-7** | freeze 회귀 (H-1/H-4) | freeze 경로 `publish_event.py`를 `MAM_ENV_FILE` 없이 실행 / 오타 경로 실행 | 공개 브로커로 나가지 않음 |
|
||||
| **R-8** | 전체 회귀 스위트 | `.venv/bin/python -m pytest tests/ -q` | **전건 통과** |
|
||||
| **R-9** | **관측자 계정 경계** | `mam_observer`로 NATS 구독 → 수신 확인. 이어서 `home`으로 동일 구독 | `mam_observer` **수신**, `home` **0건** |
|
||||
| **R-10** | **retained 경계 확인 (N-1)** | 잡 종료 **후** NATS 네이티브 구독자를 새로 붙임 | **0건 수신** (retained는 MQTT 전용) |
|
||||
|
||||
**WAN 지연 시간 측정 (Latency Probe):**
|
||||
```bash
|
||||
.venv/bin/python - <<'PY'
|
||||
import sys, time, statistics
|
||||
sys.path.insert(0, '.agents/skills/multi-agent-mux-delegate-job/scripts')
|
||||
import mqtt_common as m
|
||||
cfg = m.broker_config_from_env()
|
||||
print(f"target: {cfg.host}:{cfg.port} tls={cfg.tls}")
|
||||
conn, rtt = [], []
|
||||
for _ in range(5):
|
||||
c = m.make_client("latency", cfg)
|
||||
t0 = time.perf_counter(); c.connect(cfg.host, cfg.port, 10); c.loop_start()
|
||||
conn.append((time.perf_counter() - t0) * 1000)
|
||||
t1 = time.perf_counter()
|
||||
info = c.publish("mam/latency/probe", b"x", qos=1); info.wait_for_publish(10)
|
||||
rtt.append((time.perf_counter() - t1) * 1000)
|
||||
c.loop_stop(); c.disconnect()
|
||||
print(f"connect p50={statistics.median(conn):.1f}ms max={max(conn):.1f}ms")
|
||||
print(f"qos1 rtt p50={statistics.median(rtt):.1f}ms max={max(rtt):.1f}ms")
|
||||
PY
|
||||
```
|
||||
|
||||
### 9.5 전환(Cutover) 절차 (H-2 대응)
|
||||
|
||||
```
|
||||
[1 드레인] ──> [2 잔여 스캔] ──> [3 .mam.env 교체] ──> [4 R-1~R-10] ──> [5 레거시 차단]
|
||||
```
|
||||
1. **드레인**: 신규 위임 중단, 진행 중 잡이 모두 terminal 될 때까지 대기.
|
||||
2. **잔여 스캔**: 옛 브로커에 핀 고정된 레코드 스캔 후 정리.
|
||||
3. **`.mam.env` 교체**: 원격 브로커 주소 및 토큰 적용.
|
||||
4. **검증**: R-1 ~ R-10 전건 통과 확인.
|
||||
5. **레거시 차단**: 공개 브로커 설정 완전 제거.
|
||||
|
||||
---
|
||||
|
||||
### 부록 X. Option A (계정 간 Export / Import) 예외 경로
|
||||
|
||||
관측자가 **서로 다른 신뢰 도메인**에 속해 계정을 엄격히 분리해야 하는 경우:
|
||||
|
||||
```conf
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled
|
||||
users: [ { user: mam_agent, password: $MAM_BROKER_PASS } ]
|
||||
exports: [ { stream: "python.mqtt.jobs.>", accounts: [HOME] } ]
|
||||
}
|
||||
HOME: {
|
||||
users: [ { user: home, password: $HOME_BROKER_PASS } ]
|
||||
imports: [ { stream: { account: MAM, subject: "python.mqtt.jobs.>" } } ]
|
||||
}
|
||||
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
|
||||
}
|
||||
system_account: SYS
|
||||
```
|
||||
|
||||
+1
-1
Submodule nats-docker updated: c86cc982be...a4b6e49a1f
@@ -266,6 +266,20 @@ def test_d10_customization_survives_repeated_refresh(src_and_target):
|
||||
"user gets no signal that their edit is diverging" % n)
|
||||
|
||||
|
||||
def _resolve_private_server_doc() -> str:
|
||||
for candidate in [
|
||||
os.path.join(REPO_ROOT, "nats-docker", "PRIVATE_SERVER.md"),
|
||||
os.path.join(REPO_ROOT, "nats-docker", "docs", "PRIVATE_SERVER.md"),
|
||||
os.path.join(REPO_ROOT, "PRIVATE_SERVER.md"),
|
||||
]:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return os.path.join(REPO_ROOT, "nats-docker", "PRIVATE_SERVER.md")
|
||||
|
||||
|
||||
PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
|
||||
# variables that broker_config_from_env() actually parses.
|
||||
@@ -274,8 +288,8 @@ def test_d11_private_server_env_names_valid():
|
||||
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
||||
import mqtt_common
|
||||
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
assert os.path.exists(doc_path), "PRIVATE_SERVER.md missing"
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
assert os.path.exists(doc_path), f"PRIVATE_SERVER.md missing at {doc_path}"
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -301,7 +315,7 @@ def test_d11_private_server_env_names_valid():
|
||||
# active configuration code blocks.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d12_private_server_no_mam_mqtt_in_code_fences():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -317,7 +331,7 @@ def test_d12_private_server_no_mam_mqtt_in_code_fences():
|
||||
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d13_private_server_nats_config_valid():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -333,7 +347,7 @@ def test_d13_private_server_nats_config_valid():
|
||||
# CLI flags matching the actual scripts' argparse parsers.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d14_private_server_cli_args_valid():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -353,7 +367,7 @@ def test_d14_private_server_cli_args_valid():
|
||||
# and heredocs writing it must be unquoted (<<EOF).
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d15_private_server_store_dir_valid():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -374,7 +388,7 @@ def test_d15_private_server_store_dir_valid():
|
||||
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d16_private_server_nats_image_alpine_pinned():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -390,7 +404,7 @@ def test_d16_private_server_nats_image_alpine_pinned():
|
||||
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d17_private_server_monitoring_port_localhost_bound():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -407,7 +421,7 @@ def test_d17_private_server_monitoring_port_localhost_bound():
|
||||
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d18_private_server_tls_examples_use_domain_names():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -423,7 +437,7 @@ def test_d18_private_server_tls_examples_use_domain_names():
|
||||
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d19_private_server_subject_literals_match_default_topic_root():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -526,7 +540,7 @@ def test_d23_compose_image_matches_doc_and_is_alpine():
|
||||
assert compose_tag != "latest", "Compose image tag must not be 'latest'"
|
||||
assert "alpine" in compose_tag, f"Compose image tag '{compose_tag}' must use alpine variant"
|
||||
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
doc_content = f.read()
|
||||
doc_tags = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', doc_content)
|
||||
|
||||
Reference in New Issue
Block a user