RHEL 중급 #7 컨테이너 입문 — Podman/Buildah/Skopeo
이번 글로 RHEL 중급 시리즈를 닫습니다. 마지막 주제는 컨테이너, 더 정확히는 RHEL 9가 표준으로 채택한 Podman입니다. Docker와 명령어는 비슷하지만 내부 구조는 꽤 다릅니다. 데몬이 없고, 일반 사용자 권한으로 컨테이너를 띄우며, systemd와도 자연스럽게 통합됩니다. 이 차이를 이해하지 못한 채 들어가면 Docker식 습관을 그대로 가져와 실수하기 쉽습니다. 이번 글에서는 그 차이를 운영 관점에서 정리합니다.
RHEL 중급 시리즈에서 이번 글의 위치:
- #1 SELinux 입문 — Enforcing/Permissive, 라벨, 트러블슈팅
- #2 LVM — PV/VG/LV, 스냅샷, 확장
- #3 스토리지 심화 — Stratis, NFS, Samba
- #4 네트워킹 — NetworkManager (nmcli), bonding, teaming
- #5 로그 관리 — journald, rsyslog, log rotation
- #6 작업 스케줄링 — cron, systemd timer, at
- #7 컨테이너 입문 — Podman/Buildah/Skopeo (Docker와의 차이) ← 이번 글
Docker 자체의 입문은 별도 시리즈에서 다뤘습니다 — 도커 기초. 이번 글은 Docker를 한 번이라도 써본 독자를 가정하고, 같은 일을 RHEL 9의 표준 도구로 어떻게 하는지에 초점을 맞춥니다.
왜 RHEL 9는 Docker가 아닌가 #
CentOS/RHEL 8부터 Red Hat은 Docker 패키지를 기본 저장소에서 제외했습니다. 그 역할을 Podman이 대신했습니다. 이유는 단순하지 않지만 운영 관점에서 보면 분명한 한 줄로 요약됩니다.
Docker는 root 데몬에 모든 컨테이너 권한이 집중되는 구조, Podman은 데몬 없이 사용자 권한으로 직접 실행되는 구조.
| 비교 | Docker | Podman |
|---|---|---|
| 데몬 | dockerd 항상 실행 | 없음 |
| 명령 → 컨테이너 | 클라이언트가 데몬에 RPC | fork/exec 직접 실행 |
| 기본 권한 | root 데몬 | 사용자 권한 (rootless 기본) |
| 공격 표면 | 데몬 한 곳에 집중 | 컨테이너별로 격리 |
| systemd 통합 | 별도 wrapper 필요 | quadlet으로 일급 시민 |
| compose | docker-compose | podman compose / quadlet |
| 명령 | docker ps | podman ps (alias로 docker도 가능) |
명령어가 같으니 학습 비용은 거의 0이지만, rootless가 기본이라는 점은 운영 관점에서 큰 차이를 만듭니다.
Podman 설치와 첫 컨테이너 #
$ sudo dnf install -y podman
$ podman --version
podman version 4.9.xDocker 사용자에게 익숙한 흐름 그대로:
$ podman run --rm -it docker.io/library/alpine sh
/ # apk add curl
/ # exit
# nginx 띄우기
$ podman run -d --name web -p 8080:80 docker.io/library/nginx:1.27
$ curl http://localhost:8080
$ podman ps
$ podman logs web
$ podman stop web && podman rm web처음 보면 어디가 다른지 잘 모를 정도. Docker와 다른 점이 드러나는 대목은 “이걸 root 없이 했다” 는 사실입니다.
rootless 컨테이너 — 무엇이 달라지나 #
사용자 네임스페이스 #
rootless Podman은 사용자 네임스페이스(user namespace) 위에서 동작합니다. 컨테이너 안의 root(uid 0)는 호스트에서는 일반 사용자의 uid로 매핑됩니다. 컨테이너 안에서 권한이 있어 보여도 호스트 자원에 대해서는 일반 사용자만큼만 권한을 가집니다.
$ id
uid=1000(curtis) gid=1000(curtis)
$ podman unshare cat /proc/self/uid_map
0 1000 1
1 100000 65536읽는 법: 컨테이너 안 uid 0 = 호스트 uid 1000(나), 컨테이너 안 uid 1~65536 = 호스트 uid 100000~165535 (subuid).
이 매핑이 가능하려면 /etc/subuid와 /etc/subgid에 사용자별 영역이 설정되어 있어야 합니다. RHEL 9는 사용자 생성 시 자동으로 잡아줍니다.
$ cat /etc/subuid
curtis:100000:65536rootless 제약 #
권한이 약한 만큼 못 하는 일도 있습니다.
- 포트 1024 미만 바인딩 ✗ — 80, 443은 sysctl로 풀거나 Caddy/HAProxy 같은 reverse proxy 뒤에 둠
- NFS 마운트 ✗ — 사용자 네임스페이스에서 NFS는 보통 막힙니다
- AF_NETLINK 제한 — 호스트 네트워크 깊숙이 들어가는 도구는 동작 안 할 수 있음
- MTU/IP forwarding 일부 옵션 제한
이런 한계가 걸리면 rootful 모드(sudo podman ...)로 도망갈 수 있습니다. 둘은 완전히 분리된 저장소를 씁니다.
# rootless
~/.local/share/containers/storage/
# rootful
/var/lib/containers/storage/같은 머신에 같은 이미지를 두 번 받게 되니, 운영에선 한쪽으로 통일 하는 게 표준입니다. 보안이 중요하면 rootless, 호스트 네트워크 통합이 절대적이면 rootful.
포트 1024 미만 바인딩 #
# 영구 적용
$ sudo sh -c 'echo "net.ipv4.ip_unprivileged_port_start=80" > /etc/sysctl.d/99-podman-rootless.conf'
$ sudo sysctl --system
# 이후
$ podman run -d -p 80:80 nginx이미지 — 레지스트리와 OCI #
Podman은 OCI(Open Container Initiative) 표준을 따릅니다. Docker 이미지와 호환됩니다. 다만 레지스트리 이름을 명시적으로 쓰는 점이 다릅니다.
$ podman pull nginx:1.27
?: Please select an image:
▸ registry.access.redhat.com/nginx:1.27
registry.redhat.io/nginx:1.27
docker.io/library/nginx:1.27Docker는 묻지 않고 docker.io를 가정하지만, Podman은 /etc/containers/registries.conf에 적힌 후보 중 어디서 받을지 한 번 확인합니다. 자동화 스크립트라면 항상 풀 경로(docker.io/library/nginx:1.27)로 적는 게 안전.
unqualified-search-registries = ["registry.access.redhat.com", "registry.redhat.io", "docker.io"]Red Hat 레지스트리 #
RHEL 사용자가 흔히 만나는 두 곳:
registry.access.redhat.com— 인증 없이 받을 수 있는 공개 이미지 (UBI 등)registry.redhat.io— 구독 인증 필요.podman login registry.redhat.io로 로그인 후 사용
UBI(Universal Base Image) — Red Hat이 RHEL 베이스 이미지를 무료로 배포하는 라인. 운영에서 RHEL 위에 RHEL 호환 컨테이너를 돌리는 표준입니다.
$ podman pull registry.access.redhat.com/ubi9/ubi:latest
$ podman run --rm -it registry.access.redhat.com/ubi9/ubi cat /etc/redhat-release
Red Hat Enterprise Linux release 9.x ...Buildah — Dockerfile 없이도 빌드 #
Podman은 podman build로 Dockerfile 빌드를 지원합니다(내부적으로 Buildah가 일합니다). Buildah를 직접 쓰면 더 세밀하게 제어할 수 있습니다.
Dockerfile 빌드 #
FROM registry.access.redhat.com/ubi9/ubi:latest
RUN dnf install -y python3 && dnf clean all
COPY app.py /opt/app.py
CMD ["python3", "/opt/app.py"]$ podman build -t myapp:1.0 .
$ buildah bud -t myapp:1.0 . # 같은 의미Containerfile이 있으면 그걸 쓰고, 없으면 Dockerfile을 씁니다. 둘 다 OCI 표준 형식.
Buildah 스크립트 빌드 #
Dockerfile은 layer마다 commit이 일어나 이미지가 커지기 쉬운 구조입니다. Buildah는 buildah from → 컨테이너 시작 → 변경 → buildah commit의 명시적 흐름을 제공해 layer 수를 직접 제어할 수 있습니다.
#!/bin/bash
ctr=$(buildah from registry.access.redhat.com/ubi9/ubi)
buildah run "$ctr" -- dnf install -y python3
buildah run "$ctr" -- dnf clean all
buildah copy "$ctr" app.py /opt/app.py
buildah config --cmd '["python3","/opt/app.py"]' "$ctr"
buildah commit "$ctr" myapp:1.0
buildah rm "$ctr"dnf install + dnf clean all을 두 RUN으로 쪼개도 결과 이미지에는 한 layer로 들어갑니다. 운영에서 이미지 크기를 줄여야 할 때 유용한 기법입니다.
Dockerfile에서 자주 보는 RHEL 패턴 #
FROM registry.access.redhat.com/ubi9/go-toolset:latest AS build
WORKDIR /src
COPY . .
RUN go build -o /tmp/app ./cmd/app
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
COPY --from=build /tmp/app /usr/local/bin/app
USER 1001
CMD ["/usr/local/bin/app"]ubi9/ubi-minimal은 약 100MB 수준의 슬림 베이스. USER 1001처럼 non-root 유저로 떨어뜨리는 게 컨테이너 보안의 기본.
Skopeo — 레지스트리 사이를 옮기는 도구 #
skopeo는 이미지를 풀어내지 않고도 레지스트리 사이에서 옮기거나 검사할 수 있는 도구. 백업, 미러링, 에어갭(air-gapped) 환경에 필수.
$ sudo dnf install -y skopeo자주 쓰는 흐름 #
$ skopeo inspect docker://registry.access.redhat.com/ubi9/ubi:latest
{
"Name": "registry.access.redhat.com/ubi9/ubi",
"Digest": "sha256:abc...",
"Architecture": "amd64",
...
}$ skopeo copy \
docker://docker.io/library/nginx:1.27 \
docker://registry.example.com/mirror/nginx:1.27# 인터넷 연결된 곳에서
$ skopeo copy \
docker://docker.io/library/nginx:1.27 \
dir:/tmp/nginx-1.27
# tar로 묶어 옮긴 뒤
$ tar czf nginx-1.27.tar.gz -C /tmp nginx-1.27
# 격리된 곳에서
$ tar xzf nginx-1.27.tar.gz -C /tmp
$ skopeo copy \
dir:/tmp/nginx-1.27 \
docker://registry.internal/mirror/nginx:1.27$ skopeo list-tags docker://registry.access.redhat.com/ubi9/ubidocker pull + docker save + docker load의 조합을 한 명령으로 끝낸다고 보면 됩니다. 데몬이 필요 없으니 CI/CD 파이프라인에서도 가볍게 돕니다.
Podman + systemd — quadlet #
운영에서 컨테이너를 띄우는 표준 방법은 systemd unit으로 관리하는 것. RHEL 9의 Podman 4.4+부터 quadlet이 도입되어 systemd 친화적인 unit 파일로 컨테이너를 정의할 수 있습니다.
[Unit]
Description=Nginx web container
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/library/nginx:1.27
PublishPort=8080:80
Volume=/srv/web:/usr/share/nginx/html:ro,Z
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=multi-user.target default.targetsystemd에 등록하고 시작:
$ sudo systemctl daemon-reload
$ sudo systemctl start web.service
$ sudo systemctl status web.service
$ journalctl -u web.service -fquadlet은 .container 파일을 systemd가 부팅 시점에 자동으로 .service unit으로 변환해 줍니다. systemd가 컨테이너 라이프사이클의 1급 관리자가 되는 구조입니다.
사용자 단위로도 똑같이 가능합니다 — ~/.config/containers/systemd/web.container에 두고 systemctl --user로 관리.
Volume의 :Z — SELinux 라벨
#
quadlet 예시의 Volume=/srv/web:/usr/share/nginx/html:ro,Z에서 Z는 호스트 디렉터리에 컨테이너 전용 SELinux 라벨을 자동으로 붙입니다.
| 옵션 | 의미 |
|---|---|
:z | 공유 라벨 (여러 컨테이너 접근 가능) |
:Z | 전용 라벨 (이 컨테이너만 접근) |
| 옵션 없음 | 라벨 안 붙임 → SELinux Enforcing 환경에서 컨테이너가 거부당함 |
#1 SELinux 입문에서 다룬 라벨 개념이 컨테이너 볼륨에서도 그대로 살아 있다는 점. 이 옵션을 빼먹어 컨테이너가 읽기 거부당하는 사고가 가장 흔합니다.
Podman compose — docker-compose 호환 #
기존 docker-compose.yml을 그대로 쓰고 싶다면:
$ sudo dnf install -y podman-compose
$ podman-compose -f docker-compose.yml up -d다만 운영에서는 quadlet으로 옮기는 흐름을 권장합니다 — systemd 통합과 부팅 자동 시작이 한 번에 잡혀요.
Auto Update — 이미지 자동 갱신 #
quadlet이나 systemd unit에 AutoUpdate=registry를 적어두면, podman-auto-update.timer (RHEL 9 기본 timer 중 하나)가 매일 한 번 새 이미지를 확인해 자동으로 가져오고 컨테이너를 재시작합니다.
$ systemctl list-timers podman-auto-update.timer
$ podman auto-update --dry-run운영 권장:
- 개발/스테이징:
AutoUpdate=registry로 자동 갱신 - 프로덕션: 자동 갱신 끄고 명시적 배포 절차로 (의도치 않은 마이너 버전 변경 차단)
디버깅 — 컨테이너가 안 뜰 때 #
# 1. 이미지가 실제로 받아졌나
$ podman images
$ podman pull <image>
# 2. 컨테이너 시도 시 정확한 에러
$ podman run --rm -it <image> sh
# (백그라운드 -d로 띄웠다가 "Exited" 만 보고 끝나는 일이 잦음)
# 3. 살아 있는 컨테이너의 로그
$ podman logs <name>
$ podman logs --tail 100 -f <name>
# 4. SELinux 거부 — Volume에 :Z 빠뜨렸을 때
$ sudo ausearch -m AVC -ts recent
$ sudo journalctl -t setroubleshoot --since "10 min ago"
# 5. 포트 충돌
$ sudo ss -tlnp | grep :8080
# 6. quadlet — 변환 결과 확인
$ /usr/libexec/podman/quadlet -dryrun-d로 띄우자마자 컨테이너가 죽고 사라지면, --rm 옵션을 빼고 podman logs <name> 또는 podman run --rm -it <image> sh로 인터랙티브하게 들어가 보는 게 가장 빠른 진단입니다.
흔한 함정 #
- Docker식 습관 —
docker.io/library/접두를 빠뜨리면 unqualified-search 후보 중 어디서 받을지 묻거나 다른 이미지를 받게 됩니다. 자동화에선 항상 풀 경로를 쓰세요. - rootless에서 80 포트 시도 —
bind: permission denied. sysctl로 풀거나 reverse proxy 뒤로. - Volume
:Z누락 — SELinux Enforcing 환경(RHEL 9 기본)에서 컨테이너가 호스트 디렉터리를 읽지 못함. - rootless ↔ rootful 저장소 분리 —
sudo podman pull받은 이미지는 일반podman명령에서 안 보입니다. 한쪽으로 통일. - 컨테이너 안 root = 호스트 root가 아님 — rootless는 user namespace로 매핑됨. 보안 가정이 다름을 잊지 말 것.
- 이미지 캐시 누적 —
podman system prune -a -f로 주기적 정리. quadletAutoUpdate=registry만 쓰면 옛 이미지가 계속 쌓여요.
기억해 둘 명령 #
| 작업 | 명령 |
|---|---|
| 이미지 받기 / 확인 | podman pull <img> / podman images |
| 컨테이너 실행 | podman run -d --name <n> -p 8080:80 <img> |
| 로그 보기 | podman logs -f <n> |
| 셸 진입 | podman exec -it <n> bash |
| 이미지 빌드 | podman build -t <tag> . |
| 레지스트리 복사 | skopeo copy docker://A docker://B |
| 이미지 검사 | skopeo inspect docker://<img> |
| systemd 통합 | quadlet *.container + systemctl daemon-reload |
| 정리 | podman system prune -a |
정리 #
- Podman = Docker 명령어 호환 + 데몬 없음 + rootless 기본. RHEL 9의 컨테이너 표준.
- Buildah — 이미지 빌드 도구.
podman build가 내부에서 사용. Dockerfile/Containerfile + 스크립트 빌드 둘 다 지원. - Skopeo — 레지스트리 사이를 옮기는 도구. 미러링, 에어갭, CI/CD 파이프라인에 필수.
- quadlet — Podman + systemd의 1급 통합.
.container파일을 systemd가 자동 service로 변환. - 운영의 핵심 함정: 풀 레지스트리 경로, Volume의
:Z, rootless ↔ rootful 분리, 컨테이너 root와 호스트 root의 차이.
시리즈 마무리 #
이걸로 RHEL 중급 시리즈 7편을 마칩니다. SELinux, LVM, 스토리지, 네트워킹, 로그, 스케줄링, 컨테이너까지, 운영의 일상에서 자주 만나는 일곱 영역을 모두 다뤘습니다.
다음은 RHEL 고급 시리즈입니다. 클러스터링(Pacemaker), 고가용성, 튜닝, 보안 강화, Ansible 자동화처럼 한 머신을 넘어 여러 머신을 함께 운영하는 영역으로 넘어갑니다.
여기까지 읽어주셔서 감사합니다.