docs(broker): establish remote Docker deployment plan for nats-server with networking/security guides and add D-15~D-21 freshness guards

This commit is contained in:
2026-08-22 23:47:29 +09:00
parent c6b6c77ce4
commit 3523b9b1ea
7 changed files with 1215 additions and 65 deletions
+242 -32
View File
@@ -70,7 +70,7 @@ server_name: mam-hub
# JetStream 영속 스토리지 (MQTT QoS 1 및 Retained 메시지 처리에 필수)
jetstream {
store_dir: "~/.local/share/nats/data" # Docker 환경에서는 "/data"로 매핑
store_dir: "/data" # Docker 환경 기본 스토리지 (네이티브 실행 시 $HOME 전개)
max_file: 10G # 홈랩 디스크 상한 설정
}
@@ -93,17 +93,17 @@ websocket {
**단일 Docker 실행:**
```bash
# 호스트에 nats.conf 생성 후 실행
# 호스트에 nats.conf 생성 후 실행 (D-2: alpine 고정 핀, D-3: 루프백/바인드 한정)
docker run -d \
--name mam-nats \
--restart unless-stopped \
-p 1883:1883 \
-p 4222:4222 \
-p 8222:8222 \
-p 8080:8080 \
-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:latest \
nats:2.12-alpine \
-c /etc/nats/nats.conf
```
@@ -113,15 +113,15 @@ version: '3.8'
services:
nats:
image: nats:latest
image: nats:2.12-alpine
container_name: mam-nats
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf"]
ports:
- "1883:1883" # MQTT 3.1.1 포트 (평면 A: MAM)
- "4222:4222" # NATS 기본 포트 (평면 B)
- "8222:8222" # HTTP 모니터링 (/varz, /jsz)
- "8080:8080" # WebSocket (평면 B)
- "${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
@@ -138,11 +138,11 @@ macOS의 sealed APFS 루트 볼륨(`/data`) 권한 문제를 방지하기 위해
# 설정 및 데이터 디렉터리 생성 (sudo 불필요)
mkdir -p ~/.config/nats ~/.local/share/nats/data
# 설정 파일 작성
cat <<'EOF' > ~/.config/nats/nats.conf
# 설정 파일 작성 (D-1: 비인용 heredoc <<EOF 으로 $HOME 을 파일 생성 시점에 절대경로로 고정)
cat <<EOF > ~/.config/nats/nats.conf
server_name: mam-hub
jetstream {
store_dir: "~/.local/share/nats/data"
store_dir: "$HOME/.local/share/nats/data"
max_file: 10G
}
http_port: 8222
@@ -215,6 +215,8 @@ persistence_location /mosquitto/data/
- `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)**: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. MQTT의 retained 메시지는 MQTT 구독자에게만 전달되므로(`mqttSendRetainedMsgsToNewSubs`가 MQTT SUBSCRIBE 경로 전용), 잡이 끝난 뒤 접속한 NATS/WebSocket 대시보드는 그 잡의 **종료 이벤트를 수신하지 못합니다**. 사후 상태가 필요하면 (a) 대시보드를 MQTT(1883)로 연결하거나 (b) §5.3 JetStream 리플레이 스트림을 옵트인하십시오.
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자(`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject가 보이지 않습니다.
- **주의 사항**: 토픽 레벨 내에 마침표(`.`)가 포함되면 NATS 계층에서 토큰이 분리될 수 있으나, MAM의 `job_id`는 8자리 hex, 워크스페이스 지문은 12자리 hex이므로 안전합니다.
### 5.3 JetStream 이벤트 리플레이 (Event Replay)
@@ -227,7 +229,7 @@ persistence_location /mosquitto/data/
### 5.5 멀티테넌트 계정 분리 및 보안
- 단일 서버 내에서 `MAM` 전용 계정과 `HOME` 개인 계정을 분리하여 리소스 쿼터와 권한을 완벽히 격리할 수 있습니다.
- **권고 배치**: MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치하고, 무관한 홈랩 서비스는 별도 계정(`HOME`)에 배치합니다.
- **권고 배치**: MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치하고(관측자는 읽기 전용 `mam_observer` 역할 부여), 무관한 홈랩 서비스는 별도 계정(`HOME`)에 배치합니다.
---
@@ -243,23 +245,21 @@ persistence_location /mosquitto/data/
# MAM Private MQTT Broker Configuration (.mam.env)
# ==============================================================================
# 개인 서버 IP 또는 도메인
MQTT_BROKER="192.168.1.100" # 예: 10.0.0.5, mqtt.my-domain.com 등
# MQTT 기본 포트 (평문 TCP: 1883, TLS 암호화: 8883)
# ── 모델 T (Tailscale 사설 오버레이망 권장) ───────────────────────
MQTT_BROKER="mam-hub.tailXXXX.ts.net" # 또는 100.x.y.z (평문이므로 IP 사용 가능)
MQTT_PORT=1883
# TLS 암호화 활성화 여부 (0: 평문 TCP, 1: TLS 암호화)
MQTT_TLS=0
MQTT_USERNAME=mam_agent
MQTT_PASSWORD=replace_me_with_token
MQTT_KEEPALIVE=60 # WAN 구간 권장 연결 유지 시간
# 인증 설정 (익명 브로커는 주석 처리 또는 빈 문자열 유지)
# MQTT_USERNAME=my_agent_user
# MQTT_PASSWORD=my_secure_password
# TLS 인증서 경로 (MQTT_TLS=1 설정 시 사용)
# MQTT_CA_CERTS=/path/to/ca.crt
# MQTT_CERTFILE=/path/to/client.crt
# MQTT_KEYFILE=/path/to/client.key
# ── 모델 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` 파일 설정보다 우선합니다.*
@@ -273,10 +273,10 @@ MQTT_TLS=0
### Step 1. 브로커 리스너 및 JetStream 상태 확인
```bash
# MQTT 리스너 활성화 확인
curl -s http://192.168.1.100:8222/varz | grep -i mqtt
curl -s http://127.0.0.1:8222/varz | grep -i mqtt
# JetStream 엔진 정상 구동 확인
curl -s http://192.168.1.100:8222/jsz
curl -s http://127.0.0.1:8222/jsz
```
### Step 2. 임시 잡 등록 및 연결 검증 이벤트 발행
@@ -325,3 +325,213 @@ Step 2의 `-v` 출력 로그 또는 `.mam/delegate_job_logs/$JID/events.ndjson`
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과 연동하는 표준 절차입니다.
### 9.1 프로덕션 `nats.conf`
```conf
# nats.conf — 원격 프로덕션 (컨테이너 내부 절대경로 기준)
server_name: mam-hub
# ── JetStream: MQTT retained/QoS1 저장소. MAM 종료 이벤트 재수신이 여기에 의존 ──
jetstream {
store_dir: "/data" # D-1: 절대경로 고정. '~' 도, 인용된 "$HOME" 도 확장되지 않음
max_file: 10G
max_mem: 256M
}
http_port: 8222 # D-3: 호스트 게시는 loopback 한정 (§9.3)
mqtt {
port: 1883 # 공개 TLS 모델에서는 8883 + tls 블록 (§9.3)
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
max_ack_pending: 1024 # 기본 100 → 다중 에이전트 동시 발행 여유
}
websocket {
port: 8080
no_tls: true # 사설망/tailnet 한정
}
# ── 인증 및 멀티테넌시 (Rev.2: C1/C2 반영) ────────────────────────────────
accounts {
MAM: {
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
users: [
# 발행자 겸 구독자 — MAM 에이전트 본체
{ user: mam_agent, password: $MAM_BROKER_PASS }
# 관측자 — 대시보드/모니터링. PRIVATE_SERVER.md §5.5 의 '동일 계정 배치' 처방
{ 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-compose.yml`
```yaml
version: '3.8'
services:
nats:
image: nats:2.12-alpine # D-2: latest(=scratch)는 healthcheck 불가
container_name: mam-nats
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf"]
environment:
# 미해결 $VAR 는 파싱 에러 → 시크릿 누락 시 '기동 실패'로 fail-closed
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set in .env}
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set in .env}
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set in .env}
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set in .env}
ports:
- "${MQTT_BIND:-127.0.0.1}:1883:1883"
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 네이티브 프로토콜 (Plane B)
- "127.0.0.1:8222:8222" # D-3: 무인증 모니터링은 loopback 한정
- "${WS_BIND:-127.0.0.1}:8080:8080"
volumes:
- ./nats.conf:/etc/nats/nats.conf:ro
- nats-data:/data
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
```
**시크릿 생성 및 바인드 주소 주입 (`.env`):**
```bash
# 서버 측 compose 옆 .env
cat <<EOF > .env
MQTT_BIND=$(tailscale ip -4)
WS_BIND=$(tailscale ip -4)
MAM_BROKER_PASS=$(openssl rand -base64 32)
MAM_OBSERVER_PASS=$(openssl rand -base64 32)
HOME_BROKER_PASS=$(openssl rand -base64 32)
SYS_BROKER_PASS=$(openssl rand -base64 32)
EOF
chmod 600 .env
```
### 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
```