Docker bind mount의 UID/GID와 Synology ACL — 전용 그룹으로 해결한 권한 문제

Docker Linux Synology NAS

개인용 Synology NAS에서 24시간 동작하는 Go 데몬을 하나 운영하고 있다.

이 프로젝트는 Claude Code와 Codex를 컨테이너 내부에서 실행하기 때문에, 컨테이너의 홈 디렉터리를 NAS에 bind mount해서 인증 정보와 설정을 유지하도록 구성했다.

대략적인 구조는 다음과 같다.

Synology NAS

/volume1/project/claude-window-keeper/home
├── .claude/
│   └── .credentials.json
└── .config/
    └── claude-window-keeper/

Docker에서는 이 디렉터리를 그대로 /home/keeper에 연결한다.

Host
/volume1/project/claude-window-keeper/home
                    │
                    │ bind mount
                    ▼
Container
/home/keeper

코드와 GitHub Actions 기반 CI/CD까지 준비한 뒤 실제 NAS에 처음 배포했다.

컨테이너 자체는 생성됐다.

그런데 로그를 확인하자 동일한 에러가 계속 반복되고 있었다.

error: stat /home/keeper/.config/claude-window-keeper/config.toml: permission denied
error: stat /home/keeper/.config/claude-window-keeper/config.toml: permission denied
error: stat /home/keeper/.config/claude-window-keeper/config.toml: permission denied
...

처음에는 흔히 볼 수 있는 Docker volume 소유권 문제라고 생각했다.

결과적으로는 아니었다.

chown도 해봤고, 컨테이너 UID를 실제 DSM 관리자 계정의 UID와 동일하게 맞춰보기도 했다.

둘 다 해결되지 않았다.

최종적으로 문제를 해결하기 위해서는 Docker의 UID 하나가 아니라 GID와 Synology ACL까지 같이 봐야 했다.


config.toml이 없는 건 원래 정상인데?

가장 먼저 애플리케이션 코드를 따라갔다.

프로그램 진입점에서는 CLI 실행 중 에러가 발생하면 그대로 출력한 뒤 종료한다.

func main() {
    if err := cli.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
}

watch 명령은 시작 과정에서 설정을 읽는다.

config, err := config.Load()
if err != nil {
    return err
}

그런데 config.Load()의 의도상 config.toml이 아예 존재하지 않는 것은 문제가 아니다.

설정 파일이 없으면 기본 설정을 사용한다.

개념적으로는 다음과 같다.

if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
    return config, nil
}

따라서 정상적인 최초 실행이라면:

/home/keeper/.config/claude-window-keeper/config.toml

이 존재하지 않아도 된다.

문제는 로그에 나온 에러가:

No such file or directory

가 아니라:

Permission denied

였다는 것이다.

즉 애플리케이션의 설정 파일 생성 로직을 보기 전에, 파일 시스템 접근 자체에서 막히고 있었다.


로그가 계속 반복된 이유

컨테이너에는 다음 재시작 정책이 설정되어 있었다.

--restart unless-stopped

그래서 실제 흐름은 다음과 같았다.

container 시작
    ↓
config.Load()
    ↓
Permission denied
    ↓
process exit
    ↓
Docker 자동 재시작
    ↓
다시 config.Load()
    ↓
Permission denied
    ↓
...

한 프로세스가 같은 에러를 무한히 출력하고 있던 것이 아니라, 컨테이너가 crash-loop에 빠져 매번 같은 위치에서 종료되고 있었다.


첫 번째 가설: bind mount 소유권이 잘못됐다

Dockerfile에서는 보안상 root가 아닌 별도의 keeper 사용자로 프로세스를 실행하고 있었다.

최초 구성은 UID 1001이었다.

RUN useradd -m -u 1001 keeper

USER keeper

그런데 NAS에서 CI/CD가 다음 경로를 생성하면:

/volume1/project/claude-window-keeper/home

sudo mkdir로 만들어진 디렉터리가 root 소유로 남을 수 있다.

그래서 가장 먼저 다음과 같이 소유권을 맞췄다.

sudo chown -R 1001:1001 \
  /volume1/project/claude-window-keeper/home

확인해보니 실제로 owner는 1001:1001로 변경됐다.

drwxrwxrwx+ 1 1001 1001 ...

이 정도면 흔히 보는 Docker bind mount 권한 문제는 해결될 것 같았다.

컨테이너를 다시 시작했다.

결과는 동일했다.

error: stat /home/keeper/.config/claude-window-keeper/config.toml: permission denied

bind mount는 권한을 부여하지 않는다

여기에서 Docker bind mount의 동작을 다시 정리할 필요가 있었다.

예를 들어:

docker run \
  -v /volume1/project/claude-window-keeper/home:/home/keeper \
  ...

라고 실행했다고 하자.

이 설정이 의미하는 것은:

호스트의 /volume1/project/claude-window-keeper/home을 컨테이너의 /home/keeper에서 보이게 한다.

까지다.

Host directory
        │
        │ mount
        ▼
Container directory

컨테이너 사용자에게 해당 디렉터리의 파일 권한까지 새로 부여하는 것은 아니다.

bind mount 자체가 read-write로 연결되어 있다고 해도, 호스트 파일시스템의 사용자/그룹 권한을 무시하는 것은 아니다.

즉 다음은 서로 다른 문제다.

Docker mount
└── 이 경로를 container에서 볼 수 있는가?

Filesystem permission
└── 이 process가 그 안의 파일을 읽고 쓸 수 있는가?

이번에는 mount 자체는 성공했다.

그 안으로 들어갈 권한이 문제였다.


rwxrwxrwx++는 무엇이었을까

NAS의 디렉터리 상태를 다시 봤다.

drwxrwxrwx+

처음에는 777이면 충분하지 않나 싶었다.

그런데 마지막에 +가 붙어 있었다.

Synology의 공유 폴더는 단순한 UNIX owner / group / others 권한만 사용하는 것이 아니라 ACL 기반 권한 설정도 함께 사용한다.

즉 이 디렉터리에는:

POSIX 정보
- owner
- group
- mode

+

Synology ACL
- 특정 user의 권한
- 특정 group의 권한
- 상속된 권한

이 같이 존재하고 있었다.

그래서 chown으로 UID를 바꾸는 것만으로는 Synology ACL의 권한 항목까지 변경되지 않는다.


Synology ACL을 직접 확인해봤다

SSH에서 Synology의 synoacltool로 실제 ACL을 확인했다.

sudo /usr/syno/bin/synoacltool -get \
  /volume1/project/claude-window-keeper/home

당시 결과는 대략 다음과 같았다.

ACL version: 1
Archive: is_inherit,is_support_ACL
Owner: (user) not found
---------------------
[0] group:administrators:allow:rwxpdDaARWc--:fd--
[1] user::allow:r-x---a-R-c--:fd--
[2] user:hello:allow:rwxpdDaARWc--:fd--

여기서 두 가지가 눈에 띄었다.

첫 번째는:

Owner: (user) not found

였다.

chown 1001을 했기 때문에 Linux 파일시스템에는 UID 1001이라는 숫자가 들어갔지만, Synology DSM에는 UID 1001에 해당하는 실제 사용자가 존재하지 않았다.

두 번째는 ACL에 이미 administrators와 다른 DSM 사용자의 권한이 별도로 존재한다는 점이었다.


ACL을 최대한 쉽게 이해해보면

ACL은 Access Control List의 약자다.

말 그대로:

이 파일이나 폴더에 누가 어떤 권한을 갖는지 적어놓은 목록

이라고 생각하면 된다.

예를 들어:

home 폴더 ACL

administrators  → Read/Write
hello           → Read/Write
docker-services → Read/Write

이 목록 전체가 ACL이다.

각 한 줄은 더 정확하게는 ACE(Access Control Entry)라고 부른다.

ACL
├── ACE: administrators → RW
├── ACE: hello → RW
└── ACE: docker-services → RW

Synology의 File Station에서도:

폴더 우클릭
→ 속성
→ 권한
→ 생성

을 통해 특정 사용자나 그룹에 Allow/Deny, 적용 범위, 읽기/쓰기 권한을 설정할 수 있다.

또한 부모 폴더의 ACL을 하위 파일과 디렉터리로 상속시킬 수 있다.


User, UID, Group, GID

여기서 또 하나 중요했던 것이 사용자와 그룹의 차이다.

Linux에서는 사용자에게 UID가 있다.

hello
└── UID 1026

그룹에는 GID가 있다.

administrators
└── GID ...

사용자는 보통 하나의 primary group과 여러 supplementary group을 가질 수 있다.

User
├── UID
├── primary GID
└── supplementary GIDs

예를 들어 DSM의 실제 사용자 hello가 다음과 같은 구조라고 해보자.

hello
UID = 1026

groups
├── users
└── administrators

그러면 이 사용자는 자신의 UID 권한뿐 아니라 administrators 그룹에 부여된 ACL 권한도 사용할 수 있다.

반면 Docker 내부의:

keeper
UID = 1026

은 이름과 UID 숫자를 동일하게 맞췄다고 해서 DSM 사용자의 group membership까지 자동으로 복제되는 것이 아니다.

바로 이 지점이 다음 실패로 이어졌다.


두 번째 가설: DSM 관리자 계정 UID와 맞추면 되지 않을까?

UID 1001이 DSM에서 존재하지 않는 사용자라는 사실을 보고 다음 가설을 세웠다.

그렇다면 컨테이너 UID를 실제 DSM 사용자의 UID와 맞추면 ACL도 정상적으로 처리되지 않을까?

내 DSM 관리자 계정의 UID는 1026이었다.

그래서 Dockerfile을 수정했다.

# before
RUN useradd -m -u 1001 keeper

# after
RUN useradd -m -u 1026 keeper

NAS의 기존 볼륨도 다시 맞췄다.

sudo chown -R 1026:1026 \
  /volume1/project/claude-window-keeper/home

컨테이너 내부 사용자도 UID 1026.

NAS 디렉터리의 owner도 UID 1026.

숫자는 일치했다.

다시 배포했다.

그리고 또:

Permission denied

였다.

DSM 사용자와 컨테이너 UID를 동일하게 맞추는 것만으로도 해결되지 않았다.

이 단계가 이번 디버깅에서 가장 중요한 전환점이었다.


같은 UID라고 같은 권한을 가진 건 아니다

처음에는 다음처럼 생각하기 쉬웠다.

DSM
hello = UID 1026

Docker
keeper = UID 1026

→ 같은 사용자처럼 동작하지 않을까?

하지만 filesystem permission에는 UID만 있는 것이 아니다.

DSM 사용자 hello가 관리자라면:

UID 1026
+
administrators 그룹

권한을 가지고 있다.

반면 Docker 내부 keeper는:

UID 1026
+
keeper의 컨테이너 내부 group

일 수 있다.

즉:

UID는 같음
그룹 구성은 다름

이다.

Synology ACL에:

administrators → Read/Write

가 있다고 해서 UID만 동일한 Docker 프로세스가 자동으로 administrators의 권한까지 얻는 것은 아니다.

여기에서 문제를 사용자 UID가 아니라 컨테이너에게 어떤 그룹 권한을 줄 것인가로 다시 바라보게 됐다.


Everyone으로 열면 가장 간단하지 않을까?

Synology에서는 특정 폴더에 Everyone → Read/Write ACL을 주는 것도 가능하다.

실제로 permission 문제를 풀기만 하는 목적이라면 가장 단순한 방법 중 하나다.

하지만 이번 mount에는 다음 파일이 있었다.

/home/keeper/.claude/.credentials.json

Claude의 실제 인증 정보가 들어가는 파일이다.

Everyone ACL을 준다고 해당 파일이 곧바로 인터넷에 공개되는 것은 아니다.

ACL은 NAS 파일시스템에 이미 접근하려는 사용자나 프로세스의 권한을 결정하는 것이고, 외부 노출 여부는 DSM, SMB, SSH, WebDAV, reverse proxy 등의 별도 설정에 달려 있다.

하지만 Everyone을 Read/Write로 열면 NAS 내부에서 이 경로에 접근 가능한 주체의 범위 자체는 넓어진다.

특히 내 NAS는:

  • project가 DSM 공유 폴더이고
  • SMB와 File Station에서도 접근하며
  • 여러 Docker 컨테이너가 동작하고
  • 해당 NAS 자체에도 외부에서 로그인할 수 있다.

따라서 credential을 보관하는 디렉터리를 장기적으로 Everyone에 여는 건 선택하지 않았다.


최종 설계: Docker 전용 그룹 하나 만들기

결국 사용자 하나를 컨테이너마다 만드는 대신 Docker 전용 DSM 그룹 하나를 만들기로 했다.

그룹 이름은:

docker-services

로 정했다.

DSM에서:

제어판
→ 사용자 및 그룹
→ 그룹
→ 생성

으로 그룹을 만들었다.

중요한 점은 일반 DSM 사용자를 이 그룹에 넣지 않았다는 것이다.

이 그룹의 목적은 사람이 로그인하기 위한 것이 아니라 Docker 프로세스가 bind mount 경로에 접근할 수 있게 하는 것이다.


ACL은 bind mount root에만 부여했다

전체 project 공유 폴더에 권한을 열지는 않았다.

실제 Docker가 mount하는 경로:

/volume1/project/claude-window-keeper/home

에만 docker-services ACL을 추가했다.

/volume1/project                         X
/volume1/project/claude-window-keeper   X

/volume1/project/claude-window-keeper/home
└── docker-services → Read/Write

File Station에서는:

home
→ 속성
→ 권한
→ 생성
→ docker-services
→ 허용
→ Read/Write
→ 하위 폴더/파일에도 적용

으로 설정했다.

이렇게 하면:

home
├── .claude/
│   └── .credentials.json
└── .config/
    └── ...

에도 동일한 그룹 ACL이 상속된다.


그룹의 GID를 확인해야 했다

Docker에서는 docker-services라는 DSM 그룹 이름을 직접 아는 것이 아니다.

실제 filesystem permission에서는 숫자 GID가 중요하다.

처음에는 일반 Linux 환경처럼 다음을 실행했다.

getent group docker-services

그런데 Synology에서는:

-sh: getent: command not found

가 나왔다.

대신 Synology 명령을 사용했다.

sudo synogroup --get docker-services

여기에서 해당 그룹의 GID를 확인했다.

블로그에서는 환경에 따라 다른 값이므로 다음처럼 표현하겠다.

docker-services
GID = <DOCKER_SERVICES_GID>

컨테이너 GID를 docker-services와 맞추기

최종적으로 Docker의 keeper 사용자가 docker-services와 동일한 GID를 사용하도록 수정했다.

실제 성공한 Dockerfile에서는 UID와 GID를 모두 동일한 값으로 맞췄다.

일반화하면 다음과 같다.

ARG KEEPER_ID=<DOCKER_SERVICES_GID>

RUN groupadd -g "${KEEPER_ID}" keeper \
    && useradd \
        -m \
        -u "${KEEPER_ID}" \
        -g "${KEEPER_ID}" \
        keeper

이제 구조가 이렇게 된다.

Synology

docker-services
GID = <DOCKER_SERVICES_GID>
        │
        │ ACL Read/Write
        ▼
/volume1/project/claude-window-keeper/home
        ▲
        │ bind mount
        │
Docker

keeper
UID = <DOCKER_SERVICES_GID>
GID = <DOCKER_SERVICES_GID>

여기서 ACL과 직접 연결되는 핵심은 GID다.

docker-services는 그룹이고, 그룹을 식별하는 값이 GID이기 때문이다.


UID도 같은 값으로 바꾼 이유

이번 장애 대응에서는 실제 Dockerfile의 UID와 GID를 모두 같은 숫자로 맞췄다.

groupadd -g 65536 keeper
useradd -m -u 65536 -g 65536 keeper

따라서 실제로 확인한 사실은 정확히 다음과 같다.

keeper UID/GID 1001 기반
→ 실패

keeper UID를 DSM 관리자 계정인 1026으로 변경
→ 실패

docker-services ACL 생성
+
keeper UID/GID를 docker-services GID와 동일하게 설정
→ 성공

여기서 한 가지는 구분해야 한다.

Linux 권한 모델상 docker-services ACL과 직접 연결되는 값은 GID다.

따라서 이론적으로는:

UID = 기존 application UID
GID = docker-services GID

구성으로도 해결될 가능성이 있다.

하지만 이번 세션에서는 UID는 유지하고 GID만 변경하는 조합을 별도로 테스트하지 않았다.


65536에서 한 번 더 걸린 부분

실제 Synology에서 확인한 그룹 ID는 Debian의 일반적인 기본 UID/GID 범위를 넘어가는 값이었다.

그래서 단순히:

useradd -m -u 65536 keeper

만 사용하는 대신 group을 먼저 명시적으로 만들었다.

groupadd -g 65536 keeper \
&& useradd -m -u 65536 -g 65536 keeper

최종 Dockerfile에서는 다음과 같은 형태가 됐다.

FROM node:22-bookworm-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates curl \
    && rm -rf /var/lib/apt/lists/* \
    && npm install -g @anthropic-ai/claude-code @openai/codex \
    && groupadd -g 65536 keeper \
    && useradd -m -u 65536 -g 65536 keeper

USER keeper
WORKDIR /home/keeper

ENV HOME=/home/keeper
ENV XDG_CONFIG_HOME=/home/keeper/.config

블로그를 그대로 따라 적용할 경우 65536을 복사하는 것이 아니라 반드시 자신의 NAS에서 확인한:

<DOCKER_SERVICES_GID>

를 사용해야 한다.


--group-add는 사용하지 않았다

조사 과정에서는 Docker의 supplementary group을 추가하는:

--group-add <GID>

방식도 고려했다.

하지만 최종 구현에서는 이 방법을 사용하지 않았다.

현재 CI/CD의 docker run에는 기존 bind mount만 존재하고 별도의 --group-add 옵션은 없다.

개념적으로는:

docker run \
  -v /volume1/project/claude-window-keeper/home:/home/keeper \
  ...

구조를 그대로 유지했다.

대신 컨테이너 이미지 내부 keeperprimary GID 자체를 docker-services GID와 맞췄다.

Synology에서 Docker 전용 그룹을 만들고 bind mount root에 해당 그룹의 ACL을 부여한 뒤, 컨테이너 사용자의 GID를 그 DSM 그룹의 GID와 일치시켰다.


해결 후 실제로 확인한 것

ACL과 UID/GID 구성을 변경하고 새 이미지를 배포했다.

이번에는 기존에 반복되던:

error: stat /home/keeper/.config/claude-window-keeper/config.toml: permission denied

가 사라졌다.

그리고 문제 당시 존재하지 않았던:

/home/keeper/.config

도 정상적으로 생성됐다.

그 이후 다음까지 실제로 확인했다.

/home/keeper/.config 생성       OK
Claude credentials 접근        OK
Claude CLI 인증                OK
claude-window-keeper 시작      OK
실제 데몬 동작                 OK

이전에는 코드 레벨 테스트까지만 통과하고 NAS 실배포 검증을 하지 못했지만, 이번에는 실제 Synology NAS에서 최종 동작까지 확인했다.


결국 chown으로 해결되지 않았던 이유

처음의 사고방식은 단순했다.

container UID = 1001
        ↓
host owner도 1001로 chown
        ↓
끝

하지만 Synology NAS에서는 충분하지 않았다.

실제로는 다음 요소가 같이 존재했다.

Docker
├── UID
├── primary GID
└── bind mount

Synology
├── POSIX owner/group
└── ACL
    ├── user ACE
    ├── group ACE
    └── inheritance

chown은 이 중 owner와 group 숫자를 변경한다.

하지만:

docker-services → Read/Write

같은 ACL 규칙을 만들어주지도 않고, 컨테이너 프로세스를 특정 DSM 그룹의 멤버로 만들어주지도 않는다.

그래서 owner 숫자가 같다는 사실만 보고 권한 문제가 끝났다고 판단하면 안 됐다.


여러 Docker 컨테이너를 운영한다면 사용자보다 그룹이 편하다

여기까지 해결하고 나니 운영 방식 자체도 다시 생각하게 됐다.

처음에는 컨테이너마다 DSM 사용자를 하나씩 만들어야 하는지도 고민했다.

예를 들어:

claude-window-keeper-user
spring-api-user
crawler-user
another-service-user
...

하지만 개인 NAS에 Docker 서비스가 많아질수록 이 방식은 관리하기 어렵다.

대신 지금은 다음처럼 생각하고 있다.

DSM
└── docker-services group

각 프로젝트에서는 실제 bind mount가 필요한 폴더에만:

docker-services → Read/Write

ACL을 준다.

예를 들어:

/volume1/project/
├── claude-window-keeper/
│   └── home
│       └── docker-services RW
│
├── project-a/
│   └── data
│       └── docker-services RW
│
└── project-b/
    └── storage
        └── docker-services RW

반대로:

/volume1/project

전체에는 굳이 Docker 그룹 권한을 주지 않는다.


마무리

처음 로그만 봤을 때는 흔한 Docker volume 권한 문제라고 생각했다.

Permission denied

를 보고:

chown -R 1001:1001 ...

하면 끝날 것 같았다.

하지만 해결되지 않았다.

이후 UID 1001이 DSM 계정과 매핑되지 않는 게 문제라고 생각해 실제 관리자 계정 UID인 1026으로 맞췄다.

그래도 안 됐다.

결국 문제를 해결한 건 UID 숫자를 계속 바꾸는 것이 아니라 Synology가 실제로 어떤 사용자와 그룹에 ACL 권한을 주고 있는지를 확인하는 것이었다.

최종 구조는 다음과 같다.

Synology
docker-services group
        │
        │ Read/Write ACL
        ▼
bind mount root
        ▲
        │
        │ matching GID
        │
Docker keeper

그리고 이 구조로 실제 .config 생성, Claude credential 접근, 애플리케이션 실행까지 모두 확인했다.

이번 문제에서 가장 크게 얻은 것은 단순하다.

Docker bind mount는 경로를 연결할 뿐 권한을 만들어주지 않는다.

그리고 Synology NAS에서 그 권한을 제대로 이해하려면:

UID
GID
ACL

세 가지를 같이 봐야 한다.

7 ❤️ 0

댓글

댓글을 작성하려면 GitHub 로그인이 필요합니다.

첫 댓글을 남겨보세요.

AI 챗봇
...