헤드리스 K3s의 OpenClaw에 Discord 붙이기 — uid 1024라는 우회로
TL;DR
- 홈랩 K3s의 헤드리스 OpenClaw에 Discord 봇을 붙이는데 네 단계 함정을 통과
- 토큰은 객체가 아니라 문자열 + env 보간, Discord 채널은 stock 이미지에 없어 플러그인 설치 필요
- NFS uid squash(모든 파일
1024:100)가 플러그인 ownership 검증에 걸림 → NFS 수정은 다른 앱의 ACL 때문에 막힘- 검증 코드를 읽어보니 기대 uid가 정책이 아니라 런타임 uid였고 → 컨테이너를 uid 1024로 실행하는 역발상으로 해결
후속편: 이 글로 Discord를 붙여 봇이 대화는 하게 됐지만, 정작
pwd하나를 못 돌렸다. 그 exec 백엔드를 ssh로 붙이는 이야기는 헤드리스 OpenClaw 에이전트에게 손발 달아주기 — ssh backend 코딩 샌드박스로 이어진다.
들어가며
홈랩 K3s 클러스터에 OpenClaw를 Helm으로 띄워 운영 중이다. AI 에이전트를 Discord 봇으로도 부를 수 있게 채널을 붙이려 했는데 — “config에 토큰 한 줄 넣으면 되겠지”라는 예상은 빗나갔고, 네 단계의 함정을 통과해야 했다. 이 글은 그 디버깅 여정과, 마지막에 도달한 다소 의외의 해결책(컨테이너를 uid 1024로 실행)에 대한 기록이다.
환경:
- K3s (단일 클러스터, 홈랩)
- OpenClaw: Helm Chart 배포, 헤드리스 Pod (Gateway + Chromium sidecar)
- Storage: Synology NAS의
nfs-csi(NFS v4.1) - 채널 연결 목표: Discord 봇
함정 1 — token 형식: 객체가 아니라 문자열
공식 문서를 보고 channels.discord.token을 이렇게 넣었다.
channels:
discord:
enabled: true
token:
source: env
id: DISCORD_BOT_TOKEN
배포하자 gateway가 CrashLoopBackOff. 로그:
Gateway failed to start: Invalid config at openclaw.json.
channels.discord.token: invalid config: must be string
channels.discord.token.source: must be equal to constant (allowed: "file" / "exec")
{source: env, ...} 객체 형식은 이 이미지 버전(2026.5.28) 스키마에서 거부됐다. 정답은 문자열 + 환경변수 보간이었다. 마침 같은 클러스터의 LiteLLM provider가 apiKey: "${LITELLM_MASTER_KEY}" 패턴을 쓰고 있어서 그대로 따랐다.
token: "${DISCORD_BOT_TOKEN}" # env 보간
토큰 자체는 values-secret.yaml의 extraSecrets.DISCORD_BOT_TOKEN(평문, gitignore)으로 넣으면 Helm이 Secret → envFrom으로 컨테이너 env에 주입한다.
교훈: 공식 문서의 예시가 항상 내 이미지 버전 스키마와 일치하는 건 아니다. 에러 메시지(
must be string)가 정답을 정확히 알려준다.
함정 2 — Discord 채널은 이미지에 없다
gateway는 떴는데 채널 상태가 이상했다.
$ openclaw channels list
- Discord: not installed, configured, disabled, run openclaw plugins install @openclaw/discord
configured(설정은 인식)인데 not installed. 확인해보니 이 이미지의 stock 플러그인엔 telegram·signal만 있고 Discord는 빠져 있었다. Discord 채널은 별도 플러그인(@openclaw/discord) 설치가 필요했다.
# npm 캐시 디렉토리 권한 문제로 캐시 경로를 /tmp로 우회
npm_config_cache=/tmp/npm-cache node dist/index.js plugins install @openclaw/discord
함정 3 — NFS uid squash가 플러그인을 차단하다
설치는 됐는데 보안 검증에 막혔다.
blocked plugin candidate: suspicious ownership
(.../@openclaw/discord, uid=1024, expected uid=1000 or root)
plugin present but blocked: discord
플러그인 파일 소유자가 uid 1024인데 OpenClaw는 1000 또는 root를 기대했다. 원인은 NFS였다. touch로 만든 새 파일도 전부 1024:100 소유로 기록됐고, chown은 Operation not permitted로 거부됐다.
$ kubectl exec ... -- sh -c 'touch test && ls -lan test'
-rw-r--r-- 1 1024 100 0 ... test # 컨테이너는 uid 1000인데 파일은 1024
Synology NFS export가 **“모든 사용자를 admin으로 매핑”(all_squash → anonuid=1024)**으로 설정돼 있었던 것. 이 공유폴더에 쓰는 모든 파일이 강제로 1024:100이 된다. (사실 이건 같은 클러스터의 init 컨테이너 chown 문제와 같은 뿌리였다.)
막다른 길 — NFS를 고치려다 ACL에 막히다
“그럼 NFS squash를 root_squash로 바꿔 컨테이너 uid(1000)를 보존하면 되겠네”라고 생각했다. 인프라 쪽에서 시도했지만 — 같은 공유폴더의 다른 앱(파일 업로드 서비스)이 Synology ACL에 의존하고 있었고, all_squash가 그 ACL을 admin으로 통과시켜주던 구조였다. root_squash로 바꾸자 그 앱이 깨졌다. 결국 all_squash 고정으로 원복. NFS 레이어에서는 풀 수 없었다.
우회 — 검증 코드를 읽고, uid를 맞춰버리다
OpenClaw에 ownership 검증을 끄는 설정은 없었다. 그래서 minify된 검증 코드를 직접 열어봤다.
if (origin !== "bundled" && uid !== null && stat.uid !== uid && stat.uid !== 0)
return { reason: "path_suspicious_ownership", foundUid: stat.uid, expectedUid: uid };
핵심은 expectedUid가 고정값 1000이 아니라 uid 변수 — 즉 process.getuid(), 컨테이너 실행 uid였다. “파일 소유 uid == 프로세스 자신의 uid 또는 root”면 통과한다는 뜻.
그렇다면 답은 거꾸로다. NFS가 모든 파일을 1024로 만든다면, 컨테이너를 uid 1024로 실행하면 된다. 파일 소유자(1024)와 프로세스 uid(1024)가 일치하니 검증을 통과한다.
# values.yaml — pod/컨테이너 securityContext 둘 다 (컨테이너 sc가 pod sc를 오버라이드하므로)
podSecurityContext:
runAsUser: 1024
runAsGroup: 100
fsGroup: 100
securityContext:
runAsUser: 1024
runAsGroup: 100
부수효과도 좋았다. fsGroup을 NFS 루트 gid와 같은 100으로 맞추니, fsGroupChangePolicy: OnRootMismatch가 볼륨 전체 recursive chown을 스킵해서 마운트도 빨라졌다.
배포 후:
$ openclaw channels list
- Discord default: installed, configured, enabled, token=config
[discord] channels resolved: 1508... (guild:...)
[discord] Discord bot probe resolved @claw
[discord] client initialized; awaiting gateway readiness
봇이 온라인이 됐다. DM으로 pairing 코드를 받아 pairing approve discord <코드>로 승인하니 에이전트와 대화가 됐다.
교훈
expected uid는 정책이 아니라 런타임 값이었다. 검증을 “통과시키는” 방향이 막히면, 검증이 보는 값을 “맞추는” 방향이 있다. 컨테이너 uid를 인프라 제약(NFS squash uid)에 맞추는 역발상이 가장 깔끔한 해결이었다.- 인프라(NFS) 변경은 영향 범위가 넓다. 공유 스토리지의 squash/ACL을 건드리면 다른 앱이 깨진다. 애플리케이션 레이어(컨테이너 uid)에서 푸는 게 더 국소적이고 안전할 때가 있다.
- 헤드리스로 돌리는 도구는 “stock에 뭐가 들어있나”를 일찍 확인하자. Discord가 이미지에 없다는 걸 처음부터 알았다면 순서가 달랐을 것이다.
다음 — 손발은 아직이다
봇은 대화하게 됐지만, 막상 pwd 하나를 시키면 failed로 떨어졌다. 텍스트는 되는데 도구(exec)가 안 먹는, 챗봇과 에이전트 사이 어딘가의 상태. 그 exec 백엔드를 ssh로 붙여 진짜 손발을 달아주는 이야기는 후속편 ssh backend 코딩 샌드박스로 이어진다.