48 KiB
📐 구현 계획서 Rev.2: docker/ 프로덕션 배포 자산 정본화 (Job 2168e631)
- 작성일: 2026-08-23
- 역할: Planner (
.agents/MULTI_AGENT_RULES.md§1 — Planner 는 저장소 코드/문서를 수정하지 않으며, 산출물은 본 계획서입니다) - 기준 커밋:
3523b9b(테스트 297건 수집 실측) - 선행 리비전:
810987f6(Rev.1) ← 본 문서가 대체합니다 - 판정 대상 리뷰:
2934740e(agy,[VERDICT: PASS WITH CHALLENGE]) — WebSocketsame_origin기본값 이의제기 - 대상 산출물:
docker/docker-compose.yaml,docker/nats.conf,docker/.env.example,docker/README.md,tests/test_deploy_freshness.py(D-22 ~ D-30) - 테스트 수 예상: 297 → 306 (Rev.1 의 305 에서 D-30 추가)
A. 리뷰 판정 (Adjudication of Challenge 2934740e)
A-1. 판정 요약
| 항목 | 판정 | 근거 |
|---|---|---|
전제: "websocket {} 를 원점 설정 없이 정의하면 NATS 가 same_origin: true 로 기본 동작한다" |
❌ 기각 (사실과 반대) | SameOrigin 은 평범한 bool 필드이고 기본값을 true 로 설정하는 코드가 저장소 어디에도 없음. checkOrigin() 은 !checkSame && listEmpty 일 때 즉시 nil 반환 |
| 귀결: "브라우저 대시보드가 403 Forbidden 으로 거부된다" | ❌ 기각 | 403 경로는 실재하나(websocket.go:869) 도달 조건이 성립하지 않음. 현 설정에서 교차 출처 브라우저 접속은 그대로 성공 |
처방 1: same_origin: false 추가 |
⚠️ 부분 수용 (무해하나 no-op) | 유효한 키이나 기본값과 동일. "꺼야만 동작한다"는 잘못된 서사를 설정 파일에 새기게 됨 → 주석으로 사실을 기록하는 형태로 변환 수용 |
처방 2: allowed_origins: ["*"] |
🔴 강력 기각 — 서버가 기동하지 못함 | validateWebsocketOptions 가 "*" 의 scheme 이 http/https 가 아니라며 에러 반환(websocket.go:1142-1143). 이 처방을 따르면 브로커가 아예 뜨지 않음 |
처방 3: Mixed Content(https:// → ws://) 문서화 |
✅ 전면 수용 | NATS 와 무관한 브라우저 정책이며 실제로 홈랩에서 자주 발생. §3.4 트러블슈팅 표에 반영 |
| 부수 효과 | 🟢 신규 발견 N-7 | 챌린지가 지목한 "Plane B 브라우저 대시보드" 영역을 파다가, 8080 이 /mqtt 경로로 MQTT-over-WebSocket 도 서빙한다는 사실을 확인. Rev.2 이래 미해결이던 열린 질문이 이걸로 해소됨 |
A-2. 전제가 왜 사실과 반대인가 — 실측
(1) 구조체 필드 주석이 명시적으로 반대를 말합니다. server/opts.go:672-677:
// If true, the Origin header must match the request's host.
SameOrigin bool
// Only origins in this list will be accepted. If empty and
// SameOrigin is false, any origin is accepted.
AllowedOrigins []string
(2) 기본값을 true 로 세우는 코드가 없습니다. SameOrigin = true / SameOrigin: (구조체 리터럴) 로 grep 하면 opts.go·websocket.go 양쪽에서 0건입니다. 값은 오직 설정 파서에서만 대입됩니다(opts.go:5545-5546). 따라서 Go 제로값 false 가 그대로 유효 기본값입니다.
(3) 검사 자체가 단락(short-circuit)됩니다. server/websocket.go:1034-1041:
func (w *srvWebsocket) checkOrigin(r *http.Request) error {
checkSame := w.sameOrigin
listEmpty := len(w.allowedOrigins) == 0
if !checkSame && listEmpty {
return nil // ← 우리 설정이 여기서 끝납니다
}
...
우리 websocket { port: 8080, no_tls: true } 는 sameOrigin=false, allowedOrigins 비어 있음 → 첫 조건에서 nil 반환. Origin 헤더를 읽지도 않습니다. http://localhost:3000 에서 뜬 React 대시보드가 ws://100.x.y.z:8080 로 붙는 시나리오는 아무 변경 없이 성공합니다.
챌린지가 인용한 403 경로는 실재합니다(websocket.go:868-870, StatusForbidden, "origin not allowed: %v"). 다만 그 문은 checkSame || !listEmpty 일 때만 열립니다. 즉 문은 있으나 우리 설정에서는 그 앞까지 가지 않습니다.
A-3. 처방 2 를 따르면 브로커가 죽는다 — 실측
allowed_origins: ["*"] 는 단지 불필요한 게 아니라 기동 차단 설정입니다. server/websocket.go:1136-1150 (validateWebsocketOptions):
for _, ao := range wo.AllowedOrigins {
u, err := url.ParseRequestURI(ao)
if err != nil { return fmt.Errorf("unable to parse allowed origin: %v", err) }
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unable to parse allowed origin %q: allowed origins must be "+
"absolute URLs with http or https scheme", ao)
}
if u.Host == _EMPTY_ { ... }
"*" 는 scheme 이 비어 있으므로 두 번째 분기에서 에러. 옵션 검증 실패는 기동 실패입니다. 게다가 논리적으로도 역효과입니다 — AllowedOrigins 를 비우지 않는 순간 listEmpty 가 false 가 되어 원점 검사가 켜집니다. "모두 허용"을 의도한 설정이 "검사 활성화"를 유발하는 구조입니다.
Important
이 항목은 챌린지의 처방을 그대로 구현했다면 프로덕션 브로커가 기동조차 못 했을 사안입니다. 리뷰를 지시가 아니라 가설로 취급하고 측정한 결과이며,
MULTI_AGENT_RULES.md§1 의 '[REBUT:]' 절차에 해당합니다.
A-4. 그럼에도 챌린지가 옳게 짚은 것
- Mixed Content 는 100% 실재하는 제약입니다. NATS 설정과 무관하게,
https://로 서빙된 페이지는ws://연결을 브라우저가 차단합니다. 홈랩에서 대시보드를 HTTPS 로 올리는 순간 8080 평문 WS 는 못 씁니다. §3.4 트러블슈팅에 반영합니다. - 운영자가 반드시 궁금해할 지점을 정확히 지목했습니다. Rev.1 의
websocket { port: 8080, no_tls: true }는 원점 정책에 대해 아무 말도 하지 않았고, 그래서 리뷰어가 정반대로 추정했습니다. 설정 파일이 침묵하면 독자가 최악을 가정한다는 증거입니다 — 주석으로 사실을 명문화합니다(§3.1). - 보안 방향은 오히려 반대로 열려 있습니다. 기본값이 관대하므로, 8080 을 tailnet 밖으로 내보내는 순간 임의 웹 페이지가 핸드셰이크를 시도할 수 있습니다(인증은 별도로 막지만). 완화가 아니라 강화 처방(
allowed_origins에 실제 대시보드 URL)이 필요하며, 이를 주석 템플릿으로 제공하고 D-30 가드로"*"회귀를 봉인합니다.
A-5. 신규 발견 N-7 — 8080 은 MQTT-over-WebSocket 도 서빙한다 (열린 질문 해소)
챌린지의 주제(Plane B 브라우저 대시보드)를 측정하다 확인한 사실입니다.
// server/websocket.go:820-833
if r.URL != nil {
ep := r.URL.EscapedPath()
if strings.HasSuffix(ep, leafNodeWSPath) { kind = LEAF }
else if strings.HasSuffix(ep, mqttWSPath) { kind = MQTT } // mqttWSPath = "/mqtt"
}
// Reject MQTT-over-WebSocket upgrades unless MQTT is enabled.
if kind == MQTT && opts.MQTT.Port == 0 { ... 404 ... }
// server/websocket.go:1333-1335
case MQTT:
s.createMQTTClient(res.conn, res.ws) // ← 1883 리스너와 동일 함수
mqttWSPath = "/mqtt"(server/mqtt.go:193).- 게이트는
opts.MQTT.Port != 0뿐이며, 우리 설정은mqtt { port: 1883 }이므로 이미 충족입니다. - 생성 함수가 네이티브 1883 리스너와 동일한
createMQTTClient(mqtt.go:552vswebsocket.go:1335) 이므로, 이 연결은 트랜스포트만 WebSocket 인 완전한 MQTT 클라이언트입니다.
이것이 왜 중요한가: Rev.2(b11d499d) 이래 남아 있던 열린 질문 — "대시보드를 MQTT 로 붙일 것인가, 아니면 N-1(retained 는 MQTT 전용) 제약을 받아들이고 JetStream 리플레이 스트림을 만들 것인가" — 가 제3의 답으로 해소됩니다.
브라우저 대시보드가 MQTT.js 로
ws://mam-hub:8080/mqtt에 접속하면, 그것은 MQTT 클라이언트이므로mqttSendRetainedMsgsToNewSubs경로를 그대로 타고 retained 종료 이벤트를 받습니다. 별도 리플레이 스트림도, 디스크 관리 부담도 필요 없습니다.
반면 같은 8080 포트라도 NATS 네이티브 WebSocket(경로 없음 또는 /)으로 붙으면 N-1 이 그대로 적용되어 종료 이벤트를 못 받습니다. 같은 포트, 다른 경로, 다른 결과 — 이 함정은 반드시 문서화되어야 합니다.
부수 효과로 노출 모델 서술도 정정이 필요합니다: 8080 은 "Plane B 전용"이 아니라 MQTT 프로토콜 표면을 함께 노출합니다(인증은 계정 설정이 동일하게 강제).
B. Rev.1 → Rev.2 변경 요약
| # | 변경 | 출처 |
|---|---|---|
| C-1 | docker/nats.conf websocket {} 블록에 원점 정책 사실 주석 + allowed_origins 강화 템플릿(주석) 추가. same_origin: false 는 활성 라인으로 넣지 않음 |
챌린지 §2.3-1 변환 수용 |
| C-2 | docker/nats.conf websocket {} 에 /mqtt 경로 = MQTT-over-WS 사실 주석 추가 |
N-7 |
| C-3 | docker/README.md 트러블슈팅에 WS 403(정확한 발동 조건) · Mixed Content · /mqtt vs / 경로 차이 3행 추가 |
챌린지 §2.3-2 + N-7 |
| C-4 | docker/README.md 6절(클라이언트 연결)에 브라우저 대시보드 접속 레시피 신설 |
N-7 |
| C-5 | 신규 가드 D-30(WebSocket 원점 정책이 기동 가능한 형태인지) 추가 → 305 → 306 | A-3 |
| C-6 | 문서 동기화 작업에 T-5 신설: PRIVATE_SERVER.md §5.1/§5.2 의 '두 소비 평면' 서술에 세 번째 경로(MQTT-over-WS) 반영 |
N-7 |
| C-7 | 실측 원장에 M-19 ~ M-24 추가 | A-2 / A-3 / N-7 |
| C-8 | 열린 질문에서 'Rev.2 이월 질문' 삭제(해소됨), 대신 Q-5(대시보드 프로토콜 선택 권고) 로 대체 | N-7 |
Rev.1 의 §2 설계 결정 D-1 ~ D-8, §3.2 compose, §3.3 .env.example, §4 가드 D-22 ~ D-29, §5 T-1 ~ T-4, §7 발견 N-2 ~ N-6 은 리뷰에서 전부 승인되었으며 변경 없이 유지합니다.
0. 요약 — 이 계획이 무엇을 확정하는가
PRIVATE_SERVER.md §9 는 지금까지 문서 안의 코드 펜스로만 존재했습니다. 펜스는 복사-붙여넣기 대상이지 배포 자산이 아니므로,
- 서버에 실제로 올라간 설정이 문서와 갈라져도 아무도 알 수 없고,
test_deploy_freshness.py의 D-15 ~ D-19 가드는 문서만 검사하므로 실제 배포물의 회귀를 잡지 못하며,- 시크릿을 어디에 두는지가 규약이 아니라 관습으로 남습니다.
본 계획은 §9 의 펜스를 docker/ 하위의 정본(canonical) 파일로 승격시키고, 문서↔파일 드리프트를 9종의 신규 가드(D-22 ~ D-30)로 봉인합니다.
Important
현재 저장소에는 0 바이트짜리
docker/docker-compose.yaml이 untracked 상태로 존재합니다(git status=?? docker/). 신규 가드 D-22 는 이 상태에서 즉시 FAIL 하도록 설계되어 있습니다 — 즉 가드가 공허하게 통과하지 않음이 착수 시점에 자동으로 증명됩니다.
1. 실측 기반 (Measurement Ledger)
| # | 검증 항목 | 방법 | 실측 결과 |
|---|---|---|---|
| M-1 | 테스트 베이스라인 | pytest tests/ -q --collect-only |
297 collected |
| M-2 | nats:2.12-alpine 태그 실재 |
Docker Hub API library/nats/tags?name=2.12 |
존재. 최신 패치 2.12.15 (2026-08-12) |
| M-3 | alpine 이미지 베이스 | nats-docker/main/2.12.x/alpine3.22/Dockerfile |
FROM alpine:3.22 → busybox wget 내장 |
| M-4 | 엔트리포인트 인자 처리 | 동 디렉터리 docker-entrypoint.sh |
[ "${1#-}" != "$1" ] && set -- nats-server "$@" → command: ["-c", …] 동작 |
| M-5 | 이미지 HEALTHCHECK 유무 | 동 Dockerfile | 없음 → compose 가 반드시 정의 |
| M-6 | 미해결 $VAR 동작 |
conf/parse.go:390 |
파싱 에러 = fail-closed |
| M-7 | 환경변수 값 재파싱 | conf/parse.go lookupVariable → parseEnv(...) |
환경변수 값이 NATS 렉서로 재파싱 (N-5) |
| M-8 | 비인용 문자열 종결자 | conf/lex.go:958-960 |
NL, EOF, ;, ,, ], }, 공백. = + / 는 비종결자 |
| M-9 | mqtt {} 유효 키 |
server/opts.go parseMQTT |
port/ack_wait/max_ack_pending 전부 유효 |
| M-10 | max_ack_pending 상한 |
opts.go:5673-5679 |
[0..65535]. 1024 유효 |
| M-11 | jetstream {} 유효 키 |
opts.go parseJetStream |
store_dir, max_file, max_mem 전부 유효 |
| M-12 | 크기 접미사 대소문자 | opts.go:2507 suffixMap |
{"K","M","G","T"} 대문자 전용 (N-6) |
| M-13 | websocket {} 유효 키 |
opts.go parseWebsocket |
port, no_tls, same_origin, allowed_origins 등 유효 |
| M-14 | .gitignore 거동 |
git check-ignore -v |
docker/.env ignored(:21), docker/.env.example tracked(:23) |
| M-15 | 비인용 ${VAR:?msg} YAML |
PyYAML 6.0.3 파싱 | 평문 스칼라 → 문서 원문 그대로 파일화 가능 |
| M-16 | PyYAML 선언 여부 | requirements.txt=1행, tests/test_sanity.py:3=import yaml |
미선언 하드 의존 (N-2) |
| M-17 | 설치 스크립트 배포 범위 | deploy/install.sh:365-374 |
docker/·PRIVATE_SERVER.md 미배포 |
| M-18 | DEFAULT_TOPIC_ROOT |
mqtt_common.py:119 |
"python/mqtt/jobs" |
| M-19 | SameOrigin 기본값 |
opts.go 전역 grep SameOrigin = true / SameOrigin: |
0건 → Go 제로값 false. 주석(opts.go:676-677)도 "empty and SameOrigin is false → any origin is accepted" 명시 |
| M-20 | checkOrigin 단락 조건 |
websocket.go:1034-1041 |
!checkSame && listEmpty → 즉시 nil. Origin 헤더를 읽지 않음 |
| M-21 | 403 발동 지점 | websocket.go:868-870 |
StatusForbidden "origin not allowed" — checkOrigin 이 에러일 때만 |
| M-22 | allowed_origins: ["*"] |
websocket.go:1136-1150 validateWebsocketOptions |
scheme 이 http/https 가 아니라며 옵션 검증 실패 → 기동 실패 |
| M-23 | no_tls 필요성 |
websocket.go:1132-1134 |
TLSConfig == nil && !NoTLS → websocket requires TLS configuration. 현 설정의 no_tls: true 는 필수 |
| M-24 | MQTT-over-WebSocket | mqtt.go:193 mqttWSPath="/mqtt"; websocket.go:824,832,1334-1335 |
8080 의 /mqtt 경로가 createMQTTClient 로 분기. 게이트는 MQTT.Port != 0 뿐 → 이미 활성 (N-7) |
2. 설계 결정 (Design Decisions)
D-1. 파일명은 docker/docker-compose.yaml + 문서 참조 정정
브리프는 .yaml, PRIVATE_SERVER.md §9.2 제목은 .yml 을 씁니다. Compose 는 둘 다 인식하므로 기능 차는 없습니다. 브리프를 정본으로 채택하고 문서 참조를 정정합니다(T-3). 두 곳이 다른 채로 남으면 D-23 doc↔file 가드가 무엇을 비교하는지 모호해집니다.
D-2. 계정 사용자명은 mam_agent / mam_observer 유지
브리프의 MAM with mam/observer 축약은 계정 구조를 가리킨 것으로 읽습니다. 식별자를 바꾸면 PRIVATE_SERVER.md §6 (MQTT_USERNAME=mam_agent) 과 §9.4 R-9 플레이북이 조용히 깨집니다.
D-3. version: '3.8' 제거
Compose V2 는 매 up 마다 the attribute 'version' is obsolete 경고를 냅니다. 프로덕션 자산이 상시 경고를 뿜으면 운영자가 경고를 무시하는 습관을 들입니다. 기능 영향 0.
D-4. .env.example 의 시크릿은 빈 값으로 출하 (fail-closed 의 핵심)
| 방식 | cp .env.example .env 후 결과 |
|---|---|
MAM_BROKER_PASS=changeme |
브로커 정상 기동. 전 세계가 아는 암호로 프로덕션 가동 = fail-open |
MAM_BROKER_PASS= (빈 값) ✅ |
${VAR:?…} 는 콜론 형태라 빈 값에서도 중단 → 컨테이너 생성 전 비영점 종료 |
방어는 이중입니다: Compose 보간 실패(1차) → 그래도 떴다면 nats.conf 미해결 $VAR 파싱 에러(M-6, 2차).
D-5. nats.conf 는 시크릿을 한 글자도 담지 않는다
모든 password: 는 $VAR 참조. 따라서 docker/nats.conf 는 커밋 가능하고, 시크릿은 gitignore 된 docker/.env 에만 존재합니다(M-14). D-25(e) 가 봉인합니다.
D-6. 암호 생성기는 openssl rand -base64 32 로 고정하고 이유를 문서화
파서 제약입니다(M-7/M-8). 암호에 공백 ; , ] } # ' " $ 가 들어가면 설정이 깨지거나 조용히 다른 값이 됩니다. base64 알파벳(A-Za-z0-9+/=)은 이 집합과 교집합이 0입니다.
D-7. healthcheck 는 wget 유지 — 이미지 계열과 커플링해서 봉인
wget 은 alpine 베이스에만 있습니다(M-3). D-28 은 healthcheck 존재와 이미지 alpine 여부를 한 테스트 안에서 단언합니다. 분리하면 이미지만 바꾸는 커밋이 통과합니다.
D-8. docker/ 는 하위 워크스페이스로 배포하지 않는다
install.sh 는 PRIVATE_SERVER.md 조차 배포하지 않습니다(M-17). docker/ 는 이 저장소가 운영하는 서버의 자산이므로 동일하게 저장소 전용으로 둡니다. install.sh 를 건드리지 않는 것이 명시적 결정입니다(Q-3).
D-9 (신규). WebSocket 원점 정책은 활성 설정이 아니라 주석으로 다룬다
세 가지 선택지를 검토했습니다.
| 선택 | 결과 |
|---|---|
same_origin: false 활성 추가 (챌린지 처방 1) |
동작상 no-op(M-19). 그러나 설정 파일이 "이걸 꺼야 브라우저가 붙는다"는 거짓 서사를 후임자에게 전달 |
allowed_origins: ["*"] (챌린지 처방 2) |
🔴 기동 실패(M-22) |
| 주석으로 기본 동작을 명문화 + 강화 템플릿을 주석 제공 ✅ | 동작 불변, 리뷰어가 실제로 겪은 정보 공백을 메움, 8080 을 tailnet 밖으로 낼 때 필요한 강화 경로를 즉시 제공 |
세 번째를 채택합니다. 리뷰어의 관찰(설정이 원점 정책에 침묵한다)은 타당했고, 처방(끄기)만 방향이 반대였습니다. 침묵을 메우되 사실대로 메웁니다.
3. 산출물 명세 (Creator 구현 사양)
3.1 docker/nats.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
계약 값 (가드 검사 대상): store_dir = /data · mqtt.port = 1883 · websocket.port = 8080 · http_port = 8222 · 계정 MAM/HOME/SYS · 사용자 mam_agent/mam_observer/home/sys · subject 접두 python.mqtt.jobs · 모든 password: 는 $ 시작 · 활성 allowed_origins 에 "*" 없음(D-30).
Note
accounts {}를 정의하고no_auth_user를 두지 않았으므로 익명 접속은 MQTT·NATS·WebSocket 전 경로에서 거부됩니다. 이는 §9.4 R-3(노출 면적 0)과 독립된 두 번째 방어선이며, N-7 로 드러난/mqtt표면에도 동일하게 적용됩니다.
3.2 docker/docker-compose.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:
주의 사항
8222만 바인드 주소가 하드코딩입니다. 나머지 3개는${*_BIND:-127.0.0.1}로 tailnet IP 주입을 허용하되 기본값이 loopback 입니다. D-24 가 이 비대칭을 검사합니다../nats.conf는 compose 프로젝트 디렉터리 기준 상대경로이므로docker/안에서 실행해야 합니다.- 비인용
${VAR:?msg}는 PyYAML 로 평문 스칼라 파싱됨을 실측(M-15). 인용부호 추가 불필요.
3.3 docker/.env.example
# ==============================================================================
# docker/.env.example — 원격 브로커 서버 측 환경변수 템플릿
#
# 이 파일은 git 에 커밋됩니다 (.gitignore:23 의 `!.env.example`).
# 복사본 docker/.env 는 git 에서 제외됩니다 (.gitignore:21 의 `.env`).
#
# cd docker && cp .env.example .env && chmod 600 .env
#
# ⚠ 아래 시크릿 4종은 의도적으로 **빈 값**입니다. 채우지 않고 그대로 복사하면
# `docker compose up` 이 컨테이너를 만들기 전에 실패합니다 (fail-closed).
# 플레이스홀더 문자열을 넣지 마십시오 — '알려진 암호로 가동'이 최악입니다.
# ==============================================================================
# ── 시크릿 (필수) ────────────────────────────────────────────────────────
# 생성: openssl rand -base64 32
#
# ⚠ 반드시 위 명령을 사용하십시오. NATS 는 환경변수 값을 설정 렉서로 재파싱하므로
# 암호에 [공백 ; , ] } # ' " $] 가 포함되면 설정이 깨지거나 다르게 해석됩니다.
# base64 알파벳(A-Za-z0-9+/=)은 이 문자들과 교집합이 없어 안전합니다.
# MAM 에이전트 발행/구독 계정 (.mam.env 의 MQTT_PASSWORD 와 동일 값)
MAM_BROKER_PASS=
# 관측 전용 계정 (구독 allow: python.mqtt.jobs.>, 발행 전면 deny)
MAM_OBSERVER_PASS=
# MAM 과 무관한 홈랩 서비스용 별도 계정
HOME_BROKER_PASS=
# 시스템 계정 ($SYS). 운영 이벤트/모니터링 전용
SYS_BROKER_PASS=
# ── 리스너 바인드 주소 (선택 — 미설정 시 전부 127.0.0.1) ──────────────────
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 아래 값이 실질적인 노출 통제입니다.
# 모델 T(Tailscale) 권장 설정: MQTT_BIND=$(tailscale ip -4)
#
# MQTT 1883 리스너 바인드 주소
# MQTT_BIND=127.0.0.1
#
# NATS 4222 네이티브 리스너 바인드 주소
# NATS_BIND=127.0.0.1
#
# WebSocket 8080 바인드 주소.
# 참고: 이 포트는 NATS WebSocket 과 MQTT-over-WebSocket(/mqtt)을 함께 서빙합니다.
# WS_BIND=127.0.0.1
#
# HTTP 모니터 8222 는 무인증이므로 바인드 주소를 변수화하지 않습니다 (loopback 고정).
설계 포인트: 시크릿 4종은 주석 해제 없이 빈 값으로 활성, 바인드 주소 3종은 주석 처리. 이 비대칭이 "시크릿은 반드시 채워야 하고, 바인드는 안 채워도 안전한 기본값"이라는 의도를 파일 형태로 표현합니다.
3.4 docker/README.md
| 절 | 내용 | 정본 |
|---|---|---|
| 1. 무엇인가 | 이 디렉터리가 MAM 관측 백플레인 브로커의 정본임. PRIVATE_SERVER.md §9 링크 |
§9 |
| 2. 사전 요구 | Docker Engine + Compose V2, (모델 T) Tailscale, 개방 포트 없음 | §9.3 |
| 3. 5분 배포 | docker/ 복사 → cp .env.example .env → openssl rand -base64 32 ×4 → chmod 600 .env → docker compose up -d → docker compose ps healthy |
§9.2 |
| 4. 네트워크 잠금 | UFW 규칙 전문 + published 포트가 UFW 를 우회한다는 경고를 최상단에 | §9.3 |
| 5. 노출 검증 | 외부 망에서 nmap -Pn -p 1883,4222,8222,8080 <공개IP> → 전부 closed/filtered (R-3) |
§9.4 |
| 6. 클라이언트 연결 | (a) MAM 저장소 .mam.env 기입 (b) 브라우저 대시보드 레시피 (아래) |
§6 + N-7 |
| 7. 검증 플레이북 | R-1 ~ R-10 표 + 지연 측정 스니펫 링크 | §9.4 |
| 8. 운영 | 로그 로테이션(내장), JetStream 볼륨 백업, docker compose pull && up -d, /varz·/jsz 는 SSH 터널 경유 |
§9 P6 |
| 9. 트러블슈팅 | 아래 표 | 신규 |
6절 (b) 브라우저 대시보드 접속 레시피 — N-7 반영, 필수 신설:
// MQTT.js — retained 종료 이벤트까지 받는 경로 (권장)
const client = mqtt.connect("ws://mam-hub.tailXXXX.ts.net:8080/mqtt", {
username: "mam_observer",
password: "<MAM_OBSERVER_PASS>",
protocolVersion: 4, // MQTT 3.1.1
});
client.subscribe("python/mqtt/jobs/+/events");
// 이 클라이언트는 서버에서 완전한 MQTT 클라이언트로 취급되므로
// 잡이 끝난 뒤에 접속해도 retained 최종 이벤트를 즉시 수신합니다.
// nats.ws — 경로 없이 붙으면 NATS 네이티브. 라이브 스트림만 수신하며
// 이미 끝난 잡의 종료 이벤트는 받지 못합니다 (N-1).
9절 트러블슈팅 표(필수 항목)
| 증상 | 원인 | 조치 |
|---|---|---|
required variable MAM_BROKER_PASS is missing |
.env 미생성 또는 빈 값 |
의도된 fail-closed. 시크릿을 채울 것 |
variable reference for 'MAM_BROKER_PASS' … can not be found |
compose 통과했으나 컨테이너에 변수 미주입 | environment: 블록 누락 확인 |
컨테이너는 뜨는데 계속 unhealthy |
이미지를 latest/scratch/non-alpine 로 변경 → wget 부재 |
nats:2.12-alpine 로 복귀 |
| 설정이 알 수 없는 값으로 해석됨 | 암호에 렉서 종결자 포함 | openssl rand -base64 32 로 재발급 |
max_file 에러 |
10g 등 소문자 접미사 |
대문자 10G |
| 원격에서 접속 불가 | 바인드가 loopback 기본값 | .env 에 MQTT_BIND=$(tailscale ip -4) |
JetStream not enabled for account |
계정에 jetstream: enabled 누락 |
MAM 계정 블록 확인 |
WebSocket 403 origin not allowed |
기본 설정에서는 발생하지 않습니다. allowed_origins 를 채웠거나 same_origin: true 를 켠 경우에만 발동 |
해당 설정을 지우거나, 대시보드 URL을 allowed_origins 에 추가 |
서버가 allowed origins must be absolute URLs… 로 기동 실패 |
allowed_origins 에 "*" 또는 scheme 없는 값 |
["https://dashboard.example"] 처럼 절대 URL 로 |
| 브라우저 콘솔의 Mixed Content 차단 | https:// 페이지에서 ws:// 연결 시도 |
대시보드를 tailnet 내부 http:// 로 서빙하거나, 리버스 프록시로 wss:// 종단 제공 |
| 대시보드가 종료 이벤트를 못 받음 | 8080 에 경로 없이(NATS 네이티브) 접속함. retained 는 MQTT 전용 (N-1) | ws://host:8080/**mqtt** 로 MQTT.js 접속 (N-7) |
4. 신규 회귀 가드 D-22 ~ D-30
tests/test_deploy_freshness.py 말미에 추가합니다. 기존 D-11 ~ D-21 스타일(모듈 상단 상수, 펜스 스코핑, 공허 통과 방지 단언)을 따릅니다.
4.1 공통 헬퍼
DOCKER_DIR = os.path.join(REPO_ROOT, "docker")
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
SECRET_VARS = {"MAM_BROKER_PASS", "MAM_OBSERVER_PASS",
"HOME_BROKER_PASS", "SYS_BROKER_PASS"}
def _load_compose():
"""compose 파일을 dict 로. 파일이 비었거나 nats 서비스가 없으면 즉시 실패."""
import yaml
with open(COMPOSE_PATH, encoding="utf-8") as f:
doc = yaml.safe_load(f)
assert isinstance(doc, dict) and doc.get("services"), (
"docker/docker-compose.yaml is empty or has no services: — the docker/ "
"assets were never populated")
svc = doc["services"].get("nats")
assert svc, "compose file defines no 'nats' service"
return doc, svc
def _active_conf_lines():
"""nats.conf 에서 주석을 제외한 '활성' 라인만. 주석 템플릿을 오탐하지 않기 위함."""
with open(NATS_CONF_PATH, encoding="utf-8") as f:
lines = [ln.split("#", 1)[0].rstrip() for ln in f]
return [ln for ln in lines if ln.strip()]
_active_conf_lines()는 D-30 의 정확도를 위한 것입니다. §3.1 은allowed_origins를 주석 템플릿으로 제공하므로, 주석을 그대로 스캔하면 가드가 자기 문서를 오탐합니다.
4.2 가드 명세
| ID | 이름 | 단언 | 공허 통과 방지 | 잡아내는 회귀 |
|---|---|---|---|---|
| D-22 | docker_assets_exist_and_are_populated |
4개 파일 존재 + 크기 > 0, compose 가 nats 서비스를 갖는 dict 로 파싱 |
_load_compose() 가 services 비면 실패 |
현재의 0바이트 compose, 자산 삭제 |
| D-23 | compose_image_matches_doc_and_is_alpine |
compose 의 nats:<tag> == PRIVATE_SERVER.md 펜스에서 추출한 태그, tag != "latest", "alpine" in tag |
양쪽에서 태그 추출 실패 시 실패 | 한쪽만 업그레이드하는 드리프트, scratch 회귀 |
| D-24 | compose_port_exposure_contract |
컨테이너 포트 집합 == {1883,4222,8222,8080}; 모든 게시가 3필드 → bare "1883:1883" 금지; 8222 는 정확히 127.0.0.1:8222:8222 |
ports 비면 실패 |
바인드 누락으로 인터넷 노출, 포트 오타 |
| D-25 | secrets_are_fail_closed |
(a) nats.conf 의 모든 $VAR ⊆ compose environment: 키 (b) 값이 전부 :? 형태 (c) SECRET_VARS 전부 .env.example 에 존재 (d) .env.example 의 시크릿 4종 값이 빈 문자열 (e) nats.conf 의 모든 password: 값이 $ 시작 |
$VAR 0건이면 실패 |
플레이스홀더 암호, 평문 시크릿, :?→:- 완화 |
| D-26 | nats_conf_jetstream_and_mqtt_contract |
store_dir 절대경로 + compose 볼륨 타깃과 동일; max_file/max_mem 이 ^\d+[KMGT]$; mqtt { + port: 1883; MAM 계정 jetstream: enabled |
추출 실패 시 실패 | store_dir↔볼륨 불일치(데이터 유실), 소문자 접미사, 계정 JS 누락 |
| D-27 | observer_permissions_match_topic_root |
subject 리터럴이 mqtt_common.DEFAULT_TOPIC_ROOT.replace("/",".") 로 시작; mam_observer 에 publish deny 존재 |
리터럴 0건이면 실패 | M3 지문 토픽 전환 시 관측자 권한 고아화 |
| D-28 | healthcheck_contract_and_image_coupling |
healthcheck 존재 + /healthz + 127.0.0.1:8222 + wget; 동일 테스트에서 이미지가 alpine 계열임을 단언 |
healthcheck 키 없으면 실패 | healthcheck 삭제, wget 없는 이미지 |
| D-29 | env_secrets_never_tracked |
git check-ignore docker/.env rc == 0; .env.example 은 not-ignored; git ls-files docker/.env 빈 출력 |
— | .gitignore 완화로 실제 시크릿 커밋 |
| D-30 🆕 | websocket_origin_policy_is_startable |
활성(비주석) 라인 기준: (a) websocket { 블록에 no_tls: true 존재(M-23); (b) allowed_origins 가 활성이라면 그 항목이 전부 http:// 또는 https:// 로 시작 — 특히 "*" 금지(M-22); (c) nats.conf 가 /mqtt 경로 주석을 포함해 N-7 지식이 유실되지 않음 |
websocket { 블록을 못 찾으면 실패 |
챌린지 처방 2를 그대로 구현했을 때의 기동 불능, no_tls 누락으로 인한 TLS 요구 에러, N-7 문서 소실 |
4.3 뮤테이션 수용 기준 (구현 완료의 정의)
가드는 깨져야 할 때 깨지는 것이 증명되어야 통과로 인정합니다. Creator 는 아래를 각각 적용 → 해당 테스트 FAIL 확인 → 원복하고, 로그를 커밋 메시지 또는 리뷰 요청에 첨부합니다.
| 뮤테이션 | FAIL 해야 하는 가드 |
|---|---|
docker-compose.yaml 을 0바이트로 되돌림 |
D-22 |
이미지를 nats:latest 로 변경 |
D-23, D-28 |
- "1883:1883" (바인드 주소 제거) |
D-24 |
8222 를 0.0.0.0:8222:8222 로 변경 |
D-24 |
.env.example 의 MAM_BROKER_PASS= → =changeme |
D-25 |
compose 의 :? → :- 로 완화 |
D-25 |
store_dir: "/data" → "/var/lib/nats" (볼륨 유지) |
D-26 |
subscribe.allow 를 "mam.>" 로 변경 |
D-27 |
| healthcheck 블록 삭제 | D-28 |
.gitignore 에 !docker/.env 추가 |
D-29 |
allowed_origins: ["*"] 를 활성 라인으로 추가 |
D-30 |
websocket {} 에서 no_tls: true 삭제 |
D-30 |
Important
D-22 는 착수 시점에 이미 FAIL 합니다 (0바이트 compose 파일 때문). 가드를 먼저 커밋하고 자산을 채우는 테스트 우선 순서로 진행하면, 이 가드가 공허하게 통과하지 않는다는 사실이 별도 뮤테이션 없이 자동 증명됩니다.
4.4 예상 테스트 수
| 시점 | 수 |
|---|---|
현재 (3523b9b) — 실측 |
297 |
| D-22 ~ D-30 추가 후 | 306 |
5. 문서 동기화 작업 (Creator 범위)
docker/ 만 만들고 문서를 두면 다음 리뷰에서 "무엇이 정본인가"가 다시 논쟁이 됩니다. 아래 5건을 같은 커밋에 포함합니다.
| ID | 파일 | 작업 |
|---|---|---|
| T-1 | PRIVATE_SERVER.md §9 서두 |
"본 절의 설정은 docker/ 에 정본 파일로 존재하며 아래 펜스는 그 사본입니다. 어긋나면 tests/test_deploy_freshness.py 의 D-22~D-30 이 실패합니다." 1문단 추가 |
| T-2 | PRIVATE_SERVER.md §9.1 / §9.2 |
펜스를 docker/nats.conf·docker/docker-compose.yaml 최종본과 일치시킴 (version: '3.8' 제거, §3.1 의 websocket 주석 블록 포함) |
| T-3 | PRIVATE_SERVER.md §9.2 제목 · §9.3 .env 블록 |
docker-compose.yml → docker/docker-compose.yaml; .env 생성 절차를 docker/.env.example 복사 방식으로 교체 |
| T-4 | implementation_plan.md §5 / §8 |
§5 로드맵에 P0.5 docker/ 자산 정본화 삽입; §8 M2b 에서 이미 완료된 G-D5~G-D9, G-R1, G-R2 를 [x] 로 정정(N-4)하고 docker/ 자산 + D-22~D-30 (297 → 306) 항목 신설 |
| T-5 🆕 | PRIVATE_SERVER.md §5.1 / §5.2 |
'두 개의 소비 평면' 서술에 세 번째 경로를 명문화: 8080 은 NATS WebSocket 과 MQTT-over-WebSocket(/mqtt) 을 함께 서빙하며, 후자만 retained 종료 이벤트를 받는다(N-1 ↔ N-7 연결). §5.2 의 브리징 문단이 현재 이 구분 없이 서술되어 있어 정정 대상 |
T-2 수행 시 주의: §9.1/§9.2 펜스는 기존 D-15 ~ D-19 가드의 검사 대상이기도 하므로, 편집 후 전체 스위트를 돌려 기존 7종 가드의 회귀 없음을 확인해야 합니다.
6. 실행 순서 및 완료 정의
[1] 가드 선행 커밋 ──> [2] docker/ 자산 작성 ──> [3] 문서 동기화 ──> [4] 뮤테이션 검증 ──> [5] 서버 실배포
D-22~D-30 추가 3.1~3.4 (4개 파일) T-1~T-5 §4.3 12건 전건 R-1~R-10 (+R-11)
D-22 FAIL 확인 306 전건 GREEN 기존 D-15~D-19 각 FAIL→원복 (별도 잡)
회귀 없음
DoD (본 잡의 완료 정의)
docker/4개 파일이 §3 사양대로 존재하고,docker/.env는 존재하지 않는다.pytest tests/ -q가 306건 전건 통과.- §4.3 뮤테이션 12건이 각각 지정된 가드를 FAIL 시킴이 로그로 확인된다.
- T-1 ~ T-5 문서 동기화가 동일 커밋에 포함된다.
git status에docker/.env가 나타나지 않는다(D-29 로 자동 보증).
게이트: 3번(뮤테이션 전건 FAIL) 미충족 시 커밋 금지. 통과하지 않는 가드는 가드가 아니라 주석입니다.
신규 원격 검증 항목 (서버 배포 잡으로 이월):
| ID | 검증 | 방법 | 통과 기준 |
|---|---|---|---|
| R-11 | healthcheck 의 음성 대조 | 컨테이너 안에서 wget -q -O /dev/null http://127.0.0.1:8222/healthzX (404) |
비영점 종료 — 200 이 아닐 때 실제로 실패함을 증명 |
| R-12 | 교차 출처 브라우저 접속 | 다른 오리진의 페이지에서 new WebSocket("ws://<host>:8080/") |
핸드셰이크 성공 (A-2 의 반증 가능 형태 — 403 이 나오면 M-19/M-20 판정이 틀린 것) |
| R-13 | MQTT-over-WS retained | 잡 종료 후 MQTT.js 로 ws://<host>:8080/mqtt 접속 → 구독 |
retained 최종 이벤트 수신 (N-7 확증. 0건이면 N-7 이 틀린 것) |
R-12 와 R-13 은 의도적으로 반증 가능하게 작성했습니다. 실측이 틀렸다면 이 두 항목에서 드러나며, 그 경우 §A-2 와 §A-5 를 폐기하고 챌린지의 처방 1을 재검토해야 합니다.
본 잡 범위 밖: 실제 서버 프로비저닝, Tailscale 가입, .mam.env 전환, R-1 ~ R-13 실행.
7. 발견 사항 (Findings)
N-2 (P2) — PyYAML 이 선언되지 않은 하드 테스트 의존
requirements.txt 는 pytest>=8.0 단 한 줄인데 tests/test_sanity.py:3 은 최상단에서 import yaml 합니다. 현재 venv 에는 설치되어 있어 드러나지 않지만, requirements.txt 만으로 새 venv 를 만들면 수집 단계에서 다수 파일이 에러납니다. D-22 ~ D-28 이 yaml 의존을 더 깊게 만듭니다.
처방: requirements.txt 에 PyYAML>=6.0 추가. (pytest.importorskip 은 부적절 — 스킵되면 배포 가드가 조용히 사라집니다.)
N-3 (P2) — D-16 가드의 구멍: nats:2.12 가 통과한다
tests/test_deploy_freshness.py:387 의 assert "-alpine" in tag or tag.startswith("2.") 는 or 때문에 nats:2.12(non-alpine) 나 nats:2.12-scratch 를 통과시킵니다. 그런데 healthcheck 는 alpine 에만 있는 wget 을 씁니다(M-3). 즉 D-16 은 자신이 막으려던 실패 모드의 인접 변종을 놓칩니다.
처방: assert "alpine" in tag 로 단순화.
N-4 (P3) — implementation_plan.md 체크리스트가 실제보다 뒤처짐
§8 M2b 의 - [ ] 신규 가드 G-D5 ~ G-D9, G-R1, G-R2 …(290 -> 297) 이 미체크이나 실측 수집 수는 297 이고 D-15 ~ D-21 이 이미 존재합니다. T-4 에서 정정합니다.
N-5 (P2) — NATS 는 환경변수 값을 설정 렉서로 재파싱한다
conf/parse.go 의 lookupVariable 은 환경변수를 parseEnv(fmt.Sprintf("%s=%s", pkey, vStr), p) 로 다시 파싱합니다(M-7). 비인용 문자열 종결자는 NL·EOF·;·,·]·}·공백(M-8)이며 #·$·따옴표도 위험합니다. 문서는 openssl rand -base64 32 를 쓰면서 왜 그래야 하는지를 적지 않았습니다. §3.1/§3.3 주석과 README 트러블슈팅에 명문화합니다.
N-6 (P3) — 크기 접미사는 대문자 전용
opts.go:2507 suffixMap 은 {"K","M","G","T"} 뿐. max_file: 10g 는 기동 실패. D-26 이 ^\d+[KMGT]$ 로 봉인합니다.
N-7 (P1, 신규) — 8080 은 /mqtt 경로로 MQTT-over-WebSocket 을 서빙한다
§A-5 참조. mqttWSPath = "/mqtt"(mqtt.go:193)이며 websocket.go:1334-1335 가 네이티브 1883 리스너와 동일한 createMQTTClient 로 분기합니다. 게이트는 opts.MQTT.Port != 0 뿐이라 우리 설정에서 이미 활성입니다. 세 가지 귀결:
- 브라우저 대시보드가 MQTT.js 로
/mqtt에 붙으면 retained 종료 이벤트를 받습니다 → Rev.2 이래의 열린 질문 해소, JetStream 리플레이 스트림 불필요. - 같은 포트에 경로만 다르게 붙으면(NATS 네이티브) N-1 이 그대로 적용되어 종료 이벤트를 못 받습니다. 같은 포트, 다른 경로, 다른 결과 — 함정입니다.
- 노출 모델 서술 정정 필요: 8080 은 'Plane B 전용'이 아니라 MQTT 프로토콜 표면을 함께 노출합니다(인증은 동일 계정 규칙으로 강제).
N-1 재확인 (기존) — retained 는 MQTT 전용
mqttSendRetainedMsgsToNewSubs 가 mqttPacketSub 핸들러에서만 호출되고 MQTT 전용 필드 sub.mqtt.prm 를 순회한다는 Rev.2 실측은 유효합니다. 다만 N-7 로 경계가 프로토콜이지 포트가 아님이 분명해졌습니다 — MQTT-over-WS 도 MQTT 이므로 retained 를 받습니다.
8. 열린 질문 (비차단)
계획은 아래 답 없이 그대로 실행 가능합니다. 기본값을 명시했으므로 응답이 없으면 기본값으로 진행합니다.
| # | 질문 | 기본값(무응답 시) |
|---|---|---|
| Q-1 | 이미지 핀을 nats:2.12-alpine(마이너 추종) 로 둘 것인가, 2.12.15-alpine(패치 고정) 으로 조일 것인가? |
2.12-alpine 유지 — 문서 §9.2 와 일치, 보안 패치 자동 수령 |
| Q-2 | 컨테이너를 비루트(user: "1000:1000") 로 돌릴 것인가? |
변경하지 않음 — nats-data 볼륨 소유권 초기화가 필요해 무증상 실패 위험. 별도 하드닝 잡으로 분리 |
| Q-3 | docker/ 를 deploy/install.sh 로 하위 워크스페이스에 배포할 것인가? |
배포하지 않음 (D-8) |
| Q-4 | .mam.env 의 MQTT_PASSWORD ↔ 서버 docker/.env 의 MAM_BROKER_PASS 동기화를 스크립트화할 것인가? |
수동 — 두 파일이 다른 호스트에 있어 자동화는 시크릿 전송 경로를 새로 만드는 일. README 6절에 절차만 기술 |
| Q-5 🆕 | 대시보드 프로토콜: MQTT.js(/mqtt) 로 통일할 것인가, nats.ws 도 허용할 것인가? |
MQTT.js 단일 권장. retained 를 무상으로 얻고 N-1 함정이 사라짐. nats.ws 는 KV/JetStream 같은 NATS 고유 기능이 필요할 때만 |
Rev.2(
b11d499d) 이래 이월되던 "대시보드를 MQTT 로 붙일 것인가, JetStream 리플레이 스트림의 디스크 관리 부담을 수용할 것인가" 는 N-7 로 해소되어 목록에서 제거했습니다. 브라우저에서도 MQTT 를 쓸 수 있으므로 리플레이 스트림은 불필요합니다.
9. 부록 — Creator 착수 체크리스트
tests/test_deploy_freshness.py에 §4.1 헬퍼(_load_compose,_active_conf_lines) + D-22 ~ D-30 추가 →pytest -k d22가 FAIL 하는지 먼저 확인requirements.txt에PyYAML>=6.0추가 (N-2)docker/nats.conf작성 — websocket 주석 블록 포함 (§3.1)docker/docker-compose.yaml작성 — 기존 0바이트 파일 덮어쓰기 (§3.2)docker/.env.example작성 — 시크릿 4종 빈 값 확인 (§3.3)docker/README.md작성 — 브라우저 레시피 + 트러블슈팅 12행 (§3.4)PRIVATE_SERVER.mdT-1 ~ T-3, T-5 (§5)implementation_plan.mdT-4 (§5)- D-16 단언 정정 (N-3)
pytest tests/ -q→ 306 passed- §4.3 뮤테이션 12건 각각 FAIL 확인 후 원복, 로그 첨부
git status에docker/.env부재 확인- ⚠️
allowed_origins: ["*"]를 활성 설정으로 넣지 말 것 — 브로커가 기동하지 못합니다 (§A-3, M-22)