게이트웨이 분리 실전 셋업 + oclaw 운영 도우미 — 30~60분
🐾 뽀짝이의 셋업 가이드 #15 — 게이트웨이 분리 실전 셋업
30~60분이면 한 봇 떼어낼 수 있어요 — 메인 백업부터 핑테스트까지
이런 분들을 위한 가이드예요
- #14 왜 게이트웨이를 쪼갰나를 읽고 분리 결정한 분
- 한 봇만 먼저 떼어내서 격리 효과를 검증해보고 싶은 분
- macOS + OpenClaw 운영 중이고
claude-cli백엔드를 쓰는 분 (가이드 #10 따라간 분 포함) - LaunchAgent / launchd 다뤄본 적 없어도 따라갈 수 있어요
사전 준비
- macOS (Linux/Windows는 별도)
- OpenClaw 게이트웨이가 LaunchAgent로 떠있어야 해요 (
launchctl list | grep ai.openclaw.gateway로 확인) claude-cli백엔드 사용 중 (구독 OAuth)- 분리할 봇 1마리 선택 — 첫 PoC는 hooks 의존 적은 봇부터 시작 권장
💡 뽀피터스 본진은 분리 비용이 적은 순서대로 갔어요: 아롱이(hook 0개) → 뽀야(hook 0개) → 뽀짝이(hook 8개, 별도 세션 필요).
작업 흐름 한눈에
[ Step 1 ] 새 프로필 디렉토리 골격 만들기
└─ ~/.openclaw-<name>/ + agent runtime 복사
[ Step 2 ] config 변환 (Python 스크립트)
└─ 메인 config 복사 → 해당 봇만 남기기 + 응급 룰 박기
[ Step 3 ] LaunchAgent plist 만들기
└─ ~/Library/LaunchAgents/ai.openclaw.gateway.<name>.plist
[ Step 4 ] 핸드오버 시퀀스
└─ 메인 백업 → 메인에서 봇 빼기 → 메인 재시작 → sqlite 재복사 → 새 게이트웨이 부팅
[ Step 5 ] 검증 + 운영
└─ 핑테스트 + oclaw 도우미 설치
Step 1. 새 프로필 디렉토리 골격
분리할 봇 이름을 <name>이라고 할게요. 예시는 arongi로 갈게요.
# 1-1. 새 state 디렉토리 골격
mkdir -p ~/.openclaw-arongi/{agents,logs,memory,sessions,backups,credentials}
# 1-2. agent runtime을 새 프로필로 복사 (cp -a로 권한/심링크 보존)
cp -a ~/.openclaw/agents/arongi ~/.openclaw-arongi/agents/arongi
# 사이즈 확인
du -sh ~/.openclaw-arongi/agents/arongi
OpenClaw --profile arongi를 붙이면 자동으로 ~/.openclaw-arongi/를 state 디렉토리로 인식해요. CLI help에 명문화된 동작이에요.
Step 2. config 변환 (Python 스크립트)
핵심은 메인 config에서 해당 봇만 남기고, 그 과정에서 응급 룰을 미리 박는 거예요. Python 한 번이면 끝나요.
# convert-config.py
import json, copy, os
src = '/Users/dahtmad/.openclaw/openclaw.json'
dst = '/Users/dahtmad/.openclaw-arongi/openclaw.json' # ← 봇 이름 맞춰서
with open(src) as f:
cfg = json.load(f)
new = copy.deepcopy(cfg)
AGENT_ID = 'arongi' # ← 봇 이름
# 1. agents.list 에서 해당 봇만 남기기
new['agents']['list'] = [a for a in new['agents']['list'] if a.get('id') == AGENT_ID]
# 1a. subagents 정리 (분리 후엔 어차피 같은 프로세스 sub-agent 안 됨)
for a in new['agents']['list']:
a.pop('subagents', None)
# 2. channels — 해당 봇이 쓰는 account만 남기기 (bindings 기준)
target_bindings = [b for b in cfg.get('bindings', []) if b.get('agentId') == AGENT_ID]
keep_tg = {b['match']['accountId'] for b in target_bindings if b['match']['channel'] == 'telegram'}
keep_sl = {b['match']['accountId'] for b in target_bindings if b['match']['channel'] == 'slack'}
new['channels']['telegram']['accounts'] = {
k: v for k, v in new['channels']['telegram']['accounts'].items() if k in keep_tg
}
new['channels']['slack']['accounts'] = {
k: v for k, v in new['channels']['slack']['accounts'].items() if k in keep_sl
}
# 3. bindings — 해당 봇 라우팅만
new['bindings'] = target_bindings
# 4. 게이트웨이 포트 (메인 44350과 충돌 안 나게)
new['gateway']['port'] = 18800 # ← 봇별로 다른 포트 (예: 18800/18802/18803)
# 5. 응급 룰 1 — skills 워처 끄기 (EMFILE 방지)
new.setdefault('skills', {}).setdefault('load', {})['watch'] = False
# 6. 응급 룰 2 — hooks가 해당 봇 라우팅 안 받으면 끄기
if 'hooks' in new:
new['hooks']['enabled'] = False
new['hooks']['transformsDir'] = f'~/.openclaw-{AGENT_ID}/hooks/transforms' # 보안 검증 우회
# 7. wizard 노이즈 제거 (선택)
new.pop('wizard', None)
with open(dst, 'w') as f:
json.dump(new, f, indent=2, ensure_ascii=False)
print(f'✓ wrote {dst}')
실행 후 검증:
openclaw --profile arongi config validate
# Config valid: ~/.openclaw-arongi/openclaw.json ← 이렇게 나와야 OK
⚠️ 응급 룰 5번 (
skills.load.watch=false)이 필수예요. 이걸 안 박으면 새 게이트웨이 부팅 직후EMFILE폭주로 응답이 안 와요. 자세한 함정 설명은 아래 “함정 — EMFILE” 섹션에서 풀어요.
💡 **응급 룰 6번 (
hooks.enabled=false)**은 해당 봇이 webhook 라우팅을 받지 않을 때만 적용하세요. hooks 의존이 있으면 외부 webhook URL을 새 포트로 다 바꿔야 하는 큰 작업이라, 별도 설계가 필요해요.
Step 3. LaunchAgent plist 만들기
기존 메인 게이트웨이 plist(~/Library/LaunchAgents/ai.openclaw.gateway.plist)를 베이스로 라벨/포트/로그경로/--profile 인자만 바꿔요.
<!-- ~/Library/LaunchAgents/ai.openclaw.gateway.arongi.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.openclaw.gateway.arongi</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>SoftResourceLimits</key>
<dict><key>NumberOfFiles</key><integer>65536</integer></dict>
<key>HardResourceLimits</key>
<dict><key>NumberOfFiles</key><integer>65536</integer></dict>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/node/bin/node</string>
<string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
<string>--profile</string>
<string>arongi</string>
<string>gateway</string>
<string>--port</string>
<string>18800</string>
</array>
<key>StandardOutPath</key>
<string>/Users/dahtmad/.openclaw-arongi/logs/gateway.log</string>
<key>StandardErrorPath</key>
<string>/Users/dahtmad/.openclaw-arongi/logs/gateway.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>/Users/dahtmad</string>
<key>PATH</key><string>/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>OPENCLAW_GATEWAY_PORT</key><string>18800</string>
<key>OPENCLAW_LAUNCHD_LABEL</key><string>ai.openclaw.gateway.arongi</string>
</dict>
</dict>
</plist>
핵심 포인트:
ProgramArguments에--profile arongi가gateway보다 먼저 와야 해요 (전역 옵션)--port 18800은 메인(44350)과 충돌 안 나게 다른 값- 로그 경로는 새 프로필 디렉토리 안으로
- 자기 머신 경로(
/Users/dahtmad/...)에 맞게 수정
작성 후 검증:
plutil -lint ~/Library/LaunchAgents/ai.openclaw.gateway.arongi.plist
# OK 라고 나와야 통과
Step 4. 핸드오버 시퀀스
여기가 진짜 위험 구간이에요. 메인 config 편집 + 메인 게이트웨이 재시작이 들어가서 모든 봇이 5~10초 다운돼요. 한밤중이나 트래픽 적은 시간에 진행하세요.
순서가 중요해요. 한 단계씩 갑니다.
4-1. 메인 config 백업 (롤백 보험)
cp -p ~/.openclaw/openclaw.json \
~/.openclaw/openclaw.json.bak-pre-arongi-extract
4-2. 메인 config에서 해당 봇 빼기
Step 2의 Python 스크립트 응용 — 메인 쪽에서는 해당 봇만 빼는 변환:
import json, os
p = '/Users/dahtmad/.openclaw/openclaw.json'
cfg = json.load(open(p))
AGENT_ID = 'arongi'
# 봇이 쓰던 채널 추출
target_bindings = [b for b in cfg.get('bindings', []) if b.get('agentId') == AGENT_ID]
remove_tg = {b['match']['accountId'] for b in target_bindings if b['match']['channel'] == 'telegram'}
remove_sl = {b['match']['accountId'] for b in target_bindings if b['match']['channel'] == 'slack'}
# 1. agents 제거
cfg['agents']['list'] = [a for a in cfg['agents']['list'] if a.get('id') != AGENT_ID]
# 1a. 다른 agent의 subagents.allowAgents에서도 정리 (집사가 어차피 안 쓰면 통째로 빼기)
for a in cfg['agents']['list']:
a.pop('subagents', None)
# 2. 채널 제거
for k in remove_tg: cfg['channels']['telegram']['accounts'].pop(k, None)
for k in remove_sl: cfg['channels']['slack']['accounts'].pop(k, None)
# 3. bindings 제거
cfg['bindings'] = [b for b in cfg.get('bindings', []) if b.get('agentId') != AGENT_ID]
# atomic write (race condition 방지)
tmp = p + '.tmp'
json.dump(cfg, open(tmp, 'w'), indent=2, ensure_ascii=False)
os.chmod(tmp, os.stat(p).st_mode)
os.replace(tmp, p)
print('✓ main config updated')
검증:
openclaw config validate
# Config valid: ~/.openclaw/openclaw.json
4-3. 메인 게이트웨이 재시작
여기서 모든 봇 5~10초 다운 발생해요.
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
10~15초 기다린 후 새 PID 확인:
launchctl list | grep ai.openclaw.gateway
# 1번째 컬럼이 PID, 새 숫자면 재시작 성공
메인이 안정되면 다음으로.
4-4. 메모리 sqlite 재복사 (consistent state)
여기가 미묘해요. SQLite는 라이브로 쓰는 중일 때 WAL 파일이 따로 있어서, 그냥 복사하면 사본이 inconsistent할 수 있어요. 메인 재시작 직후 = 메인이 해당 봇의 sqlite를 더 이상 안 잡는 시점이 안전해요.
# WAL 파일 잠금 풀렸는지 확인
lsof ~/.openclaw/memory/arongi.sqlite 2>/dev/null
# 출력 없으면 안전
# 복사 (sqlite + WAL + SHM 셋트)
cp -a ~/.openclaw/memory/arongi.sqlite ~/.openclaw-arongi/memory/
cp -a ~/.openclaw/memory/arongi.sqlite-wal ~/.openclaw-arongi/memory/ 2>/dev/null
cp -a ~/.openclaw/memory/arongi.sqlite-shm ~/.openclaw-arongi/memory/ 2>/dev/null
💡 메모리 sqlite가 큰 봇(예: 3GB+)은 복사 시간이 오래 걸려요. 그동안 새 게이트웨이는 부팅하지 않은 상태여야 해요.
4-5. 새 게이트웨이 부팅
이제 진짜 띄울 차례예요.
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/ai.openclaw.gateway.arongi.plist
# 12초 기다린 후 PID 확인
sleep 12
launchctl list | grep ai.openclaw.gateway.arongi
PID가 나오면 부팅 성공. 안 나오면 로그 확인:
tail -30 ~/.openclaw-arongi/logs/gateway.err.log
4-6. ready 신호 + 슬랙 connect 확인
# ready 떨 때까지 폴링
until grep -q "\[gateway\] ready" ~/.openclaw-arongi/logs/gateway.log; do
sleep 3
done
echo "✓ ready"
# 슬랙/텔레 connect 확인
grep -E "\[slack\]|\[telegram\]|ready" ~/.openclaw-arongi/logs/gateway.log | tail -10
socket mode connected가 떴으면 슬랙 어댑터 정상.
⚠️ 함정 — EMFILE은 node_modules 없어도 발생해요
분리 PoC에서 가장 자주 만나는 함정. 이건 미리 알아두는 게 좋아요.
증상
새 게이트웨이가 떴는데 슬랙에서 멘션해도 눈알 이모지 ✓ + 타이핑 X + 응답 X 패턴이 떠요. err.log를 까보면:
[skills] watcher error (/Users/dahtmad/.openclaw/workspace-arongi):
Error: EMFILE: too many open files, watch
1분에 수천 건 폭주해요.
원인 — 워크스페이스 디렉토리 수
OpenClaw 게이트웨이는 워크스페이스를 fs.watch로 인덱싱해요. macOS의 kqueue는 디렉토리 1개당 fd 1개를 잡는데, 디렉토리 수가 일정 임계(대략 수백~1,000+)를 넘으면 fd 폭발해요.
뽀피터스 본진에서 검증된 트리거:
| 워크스페이스 | node_modules | 디렉토리 수 | EMFILE |
|---|---|---|---|
| workspace-arongi | dashboard/node_modules (134 dirs) | 177 | 발생 |
| workspace-bbojjak | 없음 | 1,386 | 발생 |
| workspace-bboya | 없음 | 적음 | 안 발생 |
node_modules 유무가 결정적이지 않아요. 디렉토리 수가 본질이에요. node_modules는 그 중 가장 흔한 폭탄일 뿐.
해결
Step 2의 변환 스크립트에 이미 박혀있어요:
new.setdefault('skills', {}).setdefault('load', {})['watch'] = False
이게 skills watcher를 통째로 끄는 거예요. 새 게이트웨이/프로필 띄울 땐 무조건 박는 게 디폴트로 하세요. hot reload(스킬 파일 수정 시 자동 반영)는 포기하지만, 운영 안정이 더 중요해요.
💡 메인 게이트웨이도 워크스페이스가 커지면 같이 끌 수 있어요. 뽀피터스 본진은
workspace-bbojjak이 1,386 dirs라 메인에도skills.load.watch=false를 박았어요.
워크스페이스 안에 Node 앱 두는 건 별개 권고
workspace-*/ 안에 dashboard/viewer 같은 Node 앱을 두면 node_modules 트리가 자동으로 거대해져요. 가능하면 외부 경로(~/Documents/DEV/<app>/)에 두고 심링크로 참조하는 게 깔끔해요. 단, 이걸 따르더라도 watch=false 룰은 별도로 적용하세요 — 디렉토리 수만 많아도 폭발하니까요.
운영 — oclaw 도우미
게이트웨이가 3개로 늘어나면 명령어 외우기 부담스러워요. 단일 진입점 스크립트 하나 만들어두면 편해요.
설치
# 1. 스크립트 작성 (아래 내용 복사)
vim ~/.local/bin/oclaw
# 2. 실행권한
chmod +x ~/.local/bin/oclaw
# 3. PATH에 ~/.local/bin이 들어있는지 확인 (macOS 보통 들어있음)
echo $PATH | tr ':' '\n' | grep -E '\.local/bin'
사용법
oclaw status # 3개 게이트웨이 PID/메모리/CPU/uptime 한눈에
oclaw 뽀야 # 뽀야만 재시작
oclaw 아롱이 # 아롱이만 재시작
oclaw 뽀짝이 # 뽀짝이(메인 게이트웨이)만 재시작
oclaw all # 전체 재시작 (y/N 컨펌 묻고 진행)
oclaw logs 뽀야 # 일반 로그 tail -f
oclaw logs 뽀짝이 err # 에러 로그만
한글 인자 다 알아들어요 — oclaw 뽀야, oclaw bboya 둘 다 동일.
스크립트 본체 (예시)
#!/bin/bash
# oclaw — OpenClaw 게이트웨이 운영 도우미
set -uo pipefail
UID_=$(id -u)
label_for() {
case "$1" in
bboya|뽀야) echo "ai.openclaw.gateway.bboya" ;;
bbojjak|뽀짝이|메인) echo "ai.openclaw.gateway" ;;
arongi|아롱이) echo "ai.openclaw.gateway.arongi" ;;
*) echo "" ;;
esac
}
case "${1:-help}" in
status|s)
echo "🦞 게이트웨이 상태"
for L in ai.openclaw.gateway ai.openclaw.gateway.bboya ai.openclaw.gateway.arongi; do
line=$(launchctl list 2>/dev/null | awk -v l="$L" '$3==l')
pid=$(echo "$line" | awk '{print $1}')
[ "$pid" = "-" ] && echo " 💀 $L 죽어있음" && continue
rss_mb=$(($(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ')/1024))
echo " ✅ $L PID $pid ${rss_mb}MB"
done ;;
all)
read -p "전체 재시작 (모든 봇 5~10초 다운). 계속? (y/N): " yn
[[ "$yn" =~ ^[yY] ]] || { echo "취소"; exit 0; }
for L in ai.openclaw.gateway ai.openclaw.gateway.bboya ai.openclaw.gateway.arongi; do
launchctl kickstart -k "gui/$UID_/$L"
done ;;
logs)
case "$2" in
bboya|뽀야) tail -f ~/.openclaw-bboya/logs/gateway.log ;;
bbojjak|뽀짝이) tail -f ~/.openclaw/logs/gateway.log ;;
arongi|아롱이) tail -f ~/.openclaw-arongi/logs/gateway.log ;;
esac ;;
*)
label=$(label_for "$1")
[ -z "$label" ] && { echo "모르는 봇: $1"; exit 1; }
launchctl kickstart -k "gui/$UID_/$label"
echo "🔄 $1 재시작 신호 보냄" ;;
esac
전체 버전(에러 로그 분기, 도움말, 한글 출력 등)은 bbopters-shared에서 가져가서 쓰세요.
트러블슈팅 — 자주 만나는 시나리오
1. 새 게이트웨이가 startup_failed로 죽어요
err.log를 보면:
Hook transformsDir module path must be within
/Users/dahtmad/.openclaw-arongi/hooks/transforms:
/Users/dahtmad/.openclaw/hooks/transforms
→ Step 2의 응급 룰 6번이 빠졌어요. config의 hooks.transformsDir을 새 프로필 디렉토리 안으로 바꾸거나 hooks.enabled=false로 끄면 돼요.
2. 슬랙 socket disconnect 핑퐁
핸드오버 직후에 슬랙 ws 재연결 메시지가 잠깐 뜨는 건 정상이에요. 메인 재시작 시점에 ws가 잠시 끊기고 새 게이트웨이에서 다시 connect하는 동안의 순간이에요. 자동 복구 메커니즘이 있으니 30초 안에 안정되면 OK.
30초 넘게 끊긴 채면 토큰 충돌 의심 — 메인 config에 해당 봇 슬랙 account가 진짜로 빠졌는지 확인하세요.
3. 메인 재시작이 너무 오래 걸려요
메모리 sqlite가 크면(수 GB) 게이트웨이 부팅 시 sqlite 로딩에 시간이 걸려요. CPU 점유율 90%+ 상태로 1~2분 걸리는 건 정상. ready 떨 때까지 폴링하면서 기다리세요.
4. 응답이 늦게 와요 (한 번만)
새 게이트웨이의 첫 turn은 cold cache라 평소보다 느려요. claude-cli가 처음으로 메모리 sqlite를 인덱싱하는 시점이거든요. 두 번째 멘션부터는 정상 속도예요.
5. EMFILE이 또 발생해요
워크스페이스가 자라면서 디렉토리 수가 늘어나면 다시 폭발할 수 있어요. 메인 config에도 skills.load.watch=false를 박는 게 답이에요. 위 “함정 — EMFILE” 섹션 참고.
마치며 — 단계적 분리 권장
뽀피터스 본진은 한 번에 다 분리하지 않고 분리 비용 적은 봇부터 단계적으로 갔어요:
- 아롱이 (hook 0개, 메모리 16MB) — 가장 가벼움, PoC 패턴 검증
- 뽀야 (hook 0개, 메모리 156MB) — 패턴 한 번 더 굳힘
- 뽀짝이 (hook 8개 의존, 메모리 3.5GB) — 별도 세션에서 진행, 외부 webhook URL 변경 큰 작업
한 마리씩 떼어내면 사고 시 영향 범위가 좁고, 패턴이 손에 익어서 다음 분리가 쉬워져요.
분리 실험 결과는 다음 cooldown 사이클에서 검증돼요. 한 봇이 매달려도 다른 봇이 무사하면 — 진짜 격리 효과가 입증된 거예요.
다음에 또 만나요 🐾