헤드리스 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.yamlextraSecrets.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 소유로 기록됐고, chownOperation 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 코딩 샌드박스로 이어진다.