Podman으로 rootless 컨테이너 운영하기 — UID 매핑, 볼륨 권한, Pod, Quadlet

Docker는 root 권한으로 도는 dockerd 데몬에 명령을 보내는 구조라서, docker 그룹에 사용자를 넣는 순간 그 사용자는 사실상 root가 된다. docker run -v /:/host로 호스트 파일시스템 전체를 마운트할 수 있기 때문이다. 공용 빌드 서버나 여러 사람이 쓰는 개발 서버에서 이 구조가 부담스러울 때 대안이 Podman이다.

이 글에서는 Podman의 rootless 컨테이너가 사용자 네임스페이스(user namespace)로 UID를 어떻게 매핑하는지, 그 때문에 생기는 볼륨 권한·포트 문제, Pod와 Kubernetes YAML, Quadlet으로 systemd 서비스를 만드는 방법까지 실행 결과와 함께 정리한다. 환경은 Ubuntu 24.04 LTS, Podman 4.9.3이다.

Docker와 차이

항목DockerPodman
구조dockerd 데몬(root) + 클라이언트데몬 없음, 명령마다 conmon이 컨테이너를 감시
기본 실행 권한root (rootless 모드는 별도 설정)일반 사용자 rootless가 기본
CLIdocker거의 동일 (alias docker=podman으로 대부분 동작)
Pod없음여러 컨테이너가 네트워크를 공유하는 Pod 지원
서비스 등록--restart 정책 (데몬이 관리)systemd 유닛(Quadlet)으로 관리

설치와 rootless 확인

$ sudo apt install -y podman
$ podman --version
podman version 4.9.3

$ podman info --format '{{.Host.Security.Rootless}} {{.Store.GraphDriverName}} {{.Host.NetworkBackend}} cgroup{{.Host.CgroupsVersion}}'
true overlay netavark cgroupv2

$ podman info --format '{{.Store.GraphRoot}}'
/home/noble/.local/share/containers/storage

$ grep noble /etc/subuid /etc/subgid
/etc/subuid:noble:100000:65536
/etc/subgid:noble:100000:65536

이미지와 컨테이너는 사용자 홈(~/.local/share/containers)에 저장된다. /etc/subuidnoble:100000:65536은 이 사용자가 컨테이너 안에서 쓸 수 있는 보조 UID 범위이며, 아래 모든 동작의 기반이 된다.

이미지 이름은 레지스트리까지 적는다

$ podman pull nginx
Error: short-name "nginx" did not resolve to an alias and no unqualified-search registries are defined in "/etc/containers/registries.conf"

$ grep -E '"(alpine|nginx)"' /etc/containers/registries.conf.d/shortnames.conf
  "alpine" = "docker.io/library/alpine"

$ podman pull docker.io/library/nginx:1.27-alpine
6769dc3a703c719c1d2756bda113659be28ae16cf0da58dd5fd823d6b9a050ea

Docker는 짧은 이름을 무조건 Docker Hub로 보내지만, Ubuntu의 Podman은 검색 레지스트리가 비어 있어 shortnames.conf에 별칭이 없는 이름은 실패한다. 스크립트와 설정 파일에는 항상 docker.io/library/...처럼 전체 경로를 쓰는 것이 안전하다.

컨테이너의 root는 호스트의 누구인가

$ podman run --rm docker.io/library/alpine:3.20 id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

$ podman run --rm docker.io/library/alpine:3.20 cat /proc/self/uid_map
         0       1000          1
         1     100000      65536

컨테이너 안에서는 uid=0이지만 uid_map을 보면 컨테이너 UID 0이 호스트 UID 1000(noble)에, 1~65536이 /etc/subuid의 100000번대에 매핑되어 있다. 호스트에서 프로세스를 보면 실제 소유자가 드러난다.

$ podman run -d --name sleeper docker.io/library/alpine:3.20 sleep 300
625167e050b51637fffecd767cab17d2d1cb95c45139763587012aa68b01cbb2

$ ps -o user,pid,cmd -C sleep
USER         PID CMD
noble       7471 sleep 300

$ podman top sleeper user huser pid comm
USER        HUSER       PID         COMMAND
root        1000        1           sleep

$ podman rm -f -t 0 sleeper
sleeper

컨테이너를 탈출하더라도 얻는 권한은 일반 사용자 noble 수준이다. podman tophuser 열이 호스트 기준 사용자다.

볼륨 파일 소유권

같은 매핑이 바인드 마운트한 파일에도 그대로 적용된다. 컨테이너 root가 만든 파일과, 컨테이너 안 UID 1000 사용자가 만든 파일을 비교했다.

$ mkdir -p data
$ podman run --rm -v ./data:/data docker.io/library/alpine:3.20 sh -c 'touch /data/by_root; chmod 777 /data; adduser -D -u 1000 app; su app -c "mkdir /data/cache; touch /data/cache/by_app"; ls -ln /data'
total 4
-rw-r--r--    1 0        0                0 Sep 13 14:55 by_root
drwxr-xr-x    2 1000     1000          4096 Sep 13 14:55 cache

$ ls -ln data
total 4
-rw-r--r-- 1   1000   1000    0 Sep 13 14:55 by_root
drwxr-xr-x 2 100999 100999 4096 Sep 13 14:55 cache

$ rm -rf data
rm: cannot remove 'data/cache/by_app': Permission denied

컨테이너 UID 1000은 호스트에서 100000 + 1000 - 1 = 100999가 되어, 정작 호스트 사용자는 그 파일을 지우지 못한다. podman unshare는 같은 사용자 네임스페이스 안에서 명령을 실행해 이런 파일을 정리할 수 있다.

$ podman unshare ls -ln data
total 4
drwxr-xr-x 2 1000 1000 4096 Sep 13 14:55 cache

$ podman unshare rm -rf data/cache && rm -rf data && echo removed
removed

컨테이너 프로세스가 호스트 사용자와 같은 UID로 돌게 하려면 --userns=keep-id를 쓴다. 소스 디렉터리를 마운트해 빌드하는 개발용 컨테이너에서 특히 유용하다.

$ mkdir -p src
$ podman run --rm --userns=keep-id -v ./src:/src docker.io/library/alpine:3.20 sh -c 'id; touch /src/build.log'
uid=1000(noble) gid=1000(noble) groups=1000(noble)

$ ls -ln src
total 0
-rw-r--r-- 1 1000 1000 0 Sep 13 14:55 build.log

1024 미만 포트는 바인드할 수 없다

$ podman run -d --name web -p 80:80 docker.io/library/nginx:1.27-alpine
Error: rootlessport cannot expose privileged port 80, you can add 'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024), or choose a larger port number (>= 1024): listen tcp 0.0.0.0:80: bind: permission denied

$ podman ps -a --format '{{.Names}} {{.Status}}'
web Created

$ podman rm web && podman run -d --name web -p 8080:80 docker.io/library/nginx:1.27-alpine
web
b708b9307e1173d1e0e4105df56e27f09d15f2092911281fb8ae6a28783e643e

$ curl -sI http://127.0.0.1:8080 | head -2
HTTP/1.1 200 OK
Server: nginx/1.27.5

$ podman rm -f -t 0 web
web

실패한 컨테이너도 Created 상태로 남아 같은 이름을 다시 쓰려면 먼저 지워야 한다. 꼭 80번이 필요하면 에러 메시지대로 net.ipv4.ip_unprivileged_port_start sysctl을 낮추거나, 앞단 리버스 프록시에서 포워딩한다.

Pod — 네트워크를 공유하는 컨테이너 묶음

$ podman pod create --name app -p 8081:80
03f0f8f4fc54ae270ea88216dca65120fbad67215be8f06fa6733f867f075d12

$ podman run -d --pod app --name app-web docker.io/library/nginx:1.27-alpine
979a787108d005e668b24faf2a9731c95b2168026401e9a36c6f54723f6a40a7

$ podman run -d --pod app --name app-redis docker.io/library/redis:7-alpine
98309fadf58c53df7dc47bff3b1b55e6e4224cbd4b44e236f2377678df7e21b9

$ podman ps --pod --format '{{.Names}}	{{.PodName}}	{{.Status}}'
03f0f8f4fc54-infra	app	Up 1 second
app-web	app	Up 1 second
app-redis	app	Up Less than a second

$ podman exec app-web sh -c 'nc -z 127.0.0.1 6379 && echo redis reachable on localhost:6379'
redis reachable on localhost:6379

같은 Pod의 컨테이너는 -infra 컨테이너가 붙잡아둔 네트워크 네임스페이스를 공유해 localhost로 서로 접근한다. 실행 중인 Pod는 Kubernetes YAML로 내보내고 다시 띄울 수 있다.

$ podman generate kube app > app.yaml && sed -n '5,23p' app.yaml
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2026-09-13T14:55:46Z"
  labels:
    app: app
  name: app
spec:
  containers:
  - args:
    - nginx
    - -g
    - daemon off;
    image: docker.io/library/nginx:1.27-alpine
    name: app-web
    ports:
    - containerPort: 80
      hostPort: 8081
  - args:

$ podman pod rm -f app && podman kube play app.yaml | head -2
Pod:
f34f0a7d46fa71897473b8c19ddb1b0804b54c9927bf92bcb5d742f9fe86ec9d

$ podman pod ps --format '{{.Name}} {{.Status}} {{.NumberOfContainers}}'
app Running 3

$ podman kube down app.yaml | head -1
Pods stopped:

Quadlet — systemd 서비스로 운영하기

Podman 4.4부터는 .container 파일을 ~/.config/containers/systemd/에 두면 systemd generator(Quadlet)가 서비스 유닛을 자동으로 만든다. 예전 podman generate systemd 방식을 대체한다.

[Unit]
Description=nginx web (rootless podman)

[Container]
Image=docker.io/library/nginx:1.27-alpine
ContainerName=web
PublishPort=8080:80
Volume=%h/pdemo/html:/usr/share/nginx/html:ro,Z
AutoUpdate=registry

[Service]
Restart=always

[Install]
WantedBy=default.target
$ cp web.container ~/.config/containers/systemd/
$ /usr/libexec/podman/quadlet -dryrun -user | grep -E '^(ExecStart|ExecStop|Type|KillMode)='
KillMode=mixed
ExecStop=/usr/bin/podman rm -v -f -i --cidfile=%t/%N.cid
Type=notify
ExecStart=/usr/bin/podman run --name=web --cidfile=%t/%N.cid --replace --rm --cgroups=split --sdnotify=conmon -d -v %h/pdemo/html:/usr/share/nginx/html:ro,Z --label io.containers.autoupdate=registry --publish 8080:80 docker.io/library/nginx:1.27-alpine

-dryrun으로 생성될 유닛을 미리 볼 수 있다. %h는 홈 디렉터리로 치환되고, Z는 SELinux 레이블 옵션이라 Ubuntu에서는 무시된다.

$ systemctl --user daemon-reload
$ systemctl --user start web.service
$ systemctl --user status web.service --no-pager | head -5
● web.service - nginx web (rootless podman)
     Loaded: loaded (/home/noble/.config/containers/systemd/web.container; generated)
     Active: active (running) since Sun 2026-09-13 14:55:52 UTC; 3s ago
   Main PID: 8264 (conmon)
      Tasks: 24 (limit: 9423)

$ curl -s http://127.0.0.1:8080
<h1>hello from quadlet</h1>

컨테이너가 죽으면 Restart=always에 따라 systemd가 다시 띄운다.

$ podman ps --format '{{.ID}} {{.Names}} {{.Status}}'
ed2ab07f5de3 web Up 3 seconds

$ podman kill web; sleep 5; podman ps --format '{{.ID}} {{.Names}} {{.Status}}'
web
e9137646d09f web Up 4 seconds

$ systemctl --user show web.service -p NRestarts
NRestarts=1

$ podman auto-update --dry-run
            UNIT         CONTAINER           IMAGE                                POLICY      UPDATED
            web.service  e9137646d09f (web)  docker.io/library/nginx:1.27-alpine  registry    false

로그아웃하면 서비스가 멈춘다 — linger

사용자 systemd 인스턴스는 기본적으로 그 사용자의 마지막 로그인 세션이 끝나면 함께 종료된다. SSH를 끊고 20초 뒤 다시 접속해 저널을 확인했다.

$ loginctl show-user noble -p Linger
Linger=no

$ exit    # SSH 종료 후 20초 뒤 재접속
$ journalctl --user -u web.service --since '-60s' | grep -E 'Stopped|Starting|Started'
Sep 13 14:55:52 noble systemd[6933]: Starting web.service - nginx web (rootless podman)...
Sep 13 14:55:52 noble systemd[6933]: Started web.service - nginx web (rootless podman).
Sep 13 14:55:56 noble systemd[6933]: Starting web.service - nginx web (rootless podman)...
Sep 13 14:55:57 noble systemd[6933]: Started web.service - nginx web (rootless podman).
Sep 13 14:56:14 noble systemd[6933]: Stopped web.service - nginx web (rootless podman).
Sep 13 14:56:30 noble systemd[8508]: Starting web.service - nginx web (rootless podman)...
Sep 13 14:56:30 noble systemd[8508]: Started web.service - nginx web (rootless podman).

로그아웃과 함께 Stopped되고, 다시 로그인하자 default.target에 걸려 새로 시작됐다. 서버에서 로그인 없이 계속 돌리려면 linger를 켠다.

$ loginctl enable-linger
$ loginctl show-user noble -p Linger
Linger=yes

주의사항

상황증상대응
짧은 이미지 이름short-name ... did not resolve to an aliasdocker.io/library/nginx처럼 전체 경로 사용
컨테이너 비root 사용자가 만든 파일호스트에서 100999 소유로 보여 삭제 불가podman unshare rm, podman unshare chown, 또는 --userns=keep-id
1024 미만 포트rootlessport cannot expose privileged port높은 포트 + 리버스 프록시, 또는 ip_unprivileged_port_start 조정
포트 실패 후 재실행같은 이름의 Created 컨테이너가 남아 이름 충돌podman rm 후 재실행
Quadlet 서비스가 로그아웃 후 중지SSH 종료 몇 초 뒤 Stoppedloginctl enable-linger
Quadlet 파일 수정변경이 반영 안 됨systemctl --user daemon-reload 후 재시작 (systemctl --user enable은 불필요, [Install]로 처리)
/etc/subuid에 사용자 없음potentially insufficient UIDs or GIDs available in user namespacesudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 <user>podman system migrate
Docker Compose 파일podman compose는 외부 compose 도구를 호출하는 래퍼일 뿐 자체 구현이 없음podman-compose 설치 또는 podman kube play로 변환

마무리

하고 싶은 일명령
rootless 여부 확인podman info --format '{{.Host.Security.Rootless}}'
UID 매핑 확인podman unshare cat /proc/self/uid_map
호스트 기준 소유자 보기podman top <ctr> user huser
호스트 UID 그대로 실행podman run --userns=keep-id
Pod 생성·내보내기podman pod create, podman generate kube, podman kube play
서비스로 등록~/.config/containers/systemd/*.container + daemon-reload
로그아웃 후에도 유지loginctl enable-linger

rootless Podman의 권한·포트·파일 소유권 문제는 결국 '컨테이너 UID가 호스트에서 누구로 보이는가' 하나로 설명된다. 문제가 생기면 uid_mappodman top ... huser부터 확인하면 대부분 원인이 보인다.

참고

답글 남기기