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:
+242
-32
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user