Docker 보안 설정: 컨테이너를 root로 실행하지 않는 완전한 실전 가이드

지난달 친구 회사의 컨테이너 설정을 봐주다가 무심코 docker inspect를 실행했습니다. 전부 root로 돌아가고 있었고 --privileged까지 붙어 있었습니다. 2주 뒤 컨테이너가 침해됐고, 공격자는 컨테이너 탈출을 통해 호스트를 직접 장악했습니다.
과장이 아닙니다. 2024년 1월 공개된 CVE-2024-21626이 대표적인 사례입니다. 공격자는 컨테이너의 ‘작업 디렉터리’ 매개변수만 제어해도 유출된 파일 디스크립터를 악용해 호스트 파일 시스템 전체에 손을 댈 수 있었습니다. 더 무서운 수치는 NSFOCUS의 연구에서 나옵니다. Docker Hub 이미지의 76%에 보안 취약점이 있었고 67%에는 고위험 취약점이 있었습니다.
저도 예전에는 크게 신경 쓰지 않았습니다. Dockerfile에 FROM ubuntu를 쓰고 RUN apt-get install을 실행하면서, 어차피 컨테이너 안이니 충분히 격리되어 있겠지 생각했습니다. 그러다 부하 테스트 환경의 컨테이너가 침투당했고, 공격자가 호스트 디스크를 mount하는 명령이 로그에 찍힌 것을 보자 등골이 서늘해졌습니다.
사실 컨테이너를 root 대신 비root 사용자로 실행하는 일은 생각만큼 복잡하지 않습니다. 오늘은 기본 root가 왜 그렇게 위험한지, Dockerfile에서 비root 사용자를 어떻게 만드는지, --user 옵션은 어떻게 쓰는지, 그리고 어렵게 들리는 Capabilities와 AppArmor는 어떻게 설정하는지 살펴보겠습니다. 이 글을 읽고 나면 적어도 프로덕션 컨테이너의 보안 위험을 80% 낮출 수 있습니다.
컨테이너를 root로 실행하면 안 되는 이유
컨테이너 탈출: 샌드박스에서 호스트까지 단 한 걸음
많은 사람이 컨테이너를 샌드박스와 같다고 생각해 그 안에서 무엇을 해도 호스트에는 영향을 주지 않는다고 여깁니다. 하지만 현실에서 컨테이너 격리는 가상 머신 같은 하드웨어 수준 격리가 아니라 Linux의 namespace와 cgroup에 의존합니다. 설정이 잘못됐거나 커널 취약점이 발생하면 이 격리 계층은 종이처럼 쉽게 무너질 수 있습니다.
CVE-2024-21626은 뼈아픈 교훈입니다. 공격자들은 Docker의 저수준 런타임인 runc가 작업 디렉터리를 처리할 때 호스트 파일 시스템을 가리키는 파일 디스크립터를 유출한다는 사실을 발견했습니다. 기술적으로 들리지만 쉽게 말하면, 이 취약점을 통해 컨테이너 안에서 호스트의 어떤 파일이든 직접 읽고 쓸 수 있고 심지어 /usr/bin/bash 같은 핵심 실행 파일도 덮어쓸 수 있다는 뜻입니다. 웹 애플리케이션 컨테이너가 침해된 뒤 공격자가 호스트의 모든 컨테이너를 채굴 악성 코드로 바꾼다고 상상해 보십시오. 가정이 아니라 실제로 발생한 일입니다.
더 흔한 공격 경로는 --privileged 모드입니다. 이 옵션은 Docker에 “호스트의 모든 권한을 이 컨테이너에 넘겨라”라고 말하는 것과 같습니다. 컨테이너 안의 root 사용자는 디바이스 mount, 커널 모듈 로드, 네트워크 설정 변경 등 호스트 root의 모든 능력을 갖게 됩니다. NSFOCUS 연구 보고서에는 공격자가 특권 컨테이너에서 mount /dev/sda1 /mnt 한 줄로 호스트 디스크를 컨테이너에 마운트한 뒤 cron job을 추가해 데이터를 정기적으로 전송한 사례가 분석되어 있습니다. 전체 과정은 10분도 걸리지 않았습니다.
Docker Socket 마운트라는 매우 은밀한 위험도 있습니다. 컨테이너 안에서 Docker 명령으로 다른 컨테이너를 관리하기 편하게 하려고 /var/run/docker.sock을 마운트하는 경우가 있습니다. 이는 컨테이너에 호스트의 모든 컨테이너를 제어할 열쇠를 주는 것과 같습니다. 공격자가 이 컨테이너에 들어오면 새로운 특권 컨테이너를 생성한 뒤 탈출해 호스트를 장악할 수 있습니다. Tencent Cloud 보안 팀도 이런 공격 체인을 기록한 적이 있습니다.
root 사용자가 가장 큰 보안 허점인 이유
문제의 핵심은 컨테이너 안의 root UID 0과 호스트의 root UID 0이 같은 사용자라는 점입니다.
namespace 격리가 있지 않느냐고 생각할 수 있습니다. 있기는 하지만 호환성을 위해 UID namespace는 기본적으로 활성화되지 않습니다. 컨테이너 안의 프로세스가 root로 실행되는 상황에서 커널 취약점이나 설정 오류로 namespace가 무너지면 그 프로세스는 호스트에서도 root 권한으로 보입니다. 예전에 SYS_ADMIN Capability가 있는 컨테이너에서 직접 테스트해 본 적이 있습니다. root 사용자로 호스트의 procfs를 mount한 뒤 /proc/sys/kernel/core_pattern에 리버스 셸을 기록해 호스트 root 권한 획득에 성공했습니다. 전체 과정은 생각보다 훨씬 간단했습니다.
Alibaba Cloud의 보안 보고서에서도 컨테이너 탈출 공격의 주요 원인 다섯 가지를 커널 취약점, 설정 오류, 안전하지 않은 이미지, 권한 남용, 안전하지 않은 컨테이너 간 통신으로 꼽았습니다. 앞의 네 가지는 모두 root 권한과 직접 관련됩니다. 비root 사용자로 실행하면 적어도 그중 세 가지 위험을 절반 이상 낮출 수 있습니다.
실제 상황을 하나 더 보겠습니다. 많은 Node.js 애플리케이션이 80 포트에서 수신하려 하지만 Linux에서는 1024 미만의 포트에 root 권한이 필요합니다. 그래서 애플리케이션을 root로 바로 실행하는 경우가 많습니다. 그런데 Express 코드에 경로 순회 취약점이 있어 공격자가 /etc/passwd를 읽고 호스트에 SSH 로그인을 시도할 수 있다면, 이때는 컨테이너 탈출조차 필요 없이 네트워크로 호스트를 직접 공격할 수 있습니다.
무섭게 들리지만 해결책은 복잡하지 않습니다. 핵심은 최소 권한 원칙입니다. 애플리케이션에 필요한 권한만 주고 무턱대고 root를 부여하지 마십시오.
비root 사용자 설정: Dockerfile부터 시작하기
Docker 컨테이너 비root 사용자 보안 설정
Dockerfile에서 비root 사용자를 만드는 방법부터 런타임 보안 옵션까지 다루는 완전한 실전 가이드로, 컨테이너 보안 위험을 80% 낮춥니다.
Estimated time: PT30M
-
1
Step 1: Dockerfile에서 비root 사용자 만들기
전용 사용자와 그룹을 만듭니다: -
2
Step 2: 포트 바인딩 문제 처리
포트 바인딩 문제를 처리합니다: -
3
Step 3: 런타임 보안 옵션 설정
런타임 보안 옵션을 설정합니다: -
4
Step 4: Capabilities 세분화 권한 제어
Capabilities를 세분화해 제어합니다: -
5
Step 5: AppArmor/SELinux 강제 접근 제어 활성화
Debian/Ubuntu에서는 AppArmor(docker-default profile), RHEL/CentOS에서는 SELinux를 사용합니다. 기본 profile도 상당히 엄격하므로 대부분의 경우 별도로 수정할 필요가 없습니다. -
6
Step 6: 이미지 보안 스캔 및 지속적인 모니터링
Trivy 또는 Docker scan으로 이미지 취약점을 정기적으로 검사하고 고위험 취약점은 배포 전에 반드시 수정합니다. 컨테이너의 비정상 재시작과 비정상적인 리소스 사용을 모니터링하고 설정을 정기적으로 감사합니다.
비root 사용자를 올바르게 만드는 방법
먼저 가장 표준적인 작성법을 보겠습니다.
FROM node:18-alpine
# 전용 사용자와 그룹 생성(UID/GID 지정)
RUN addgroup -g 5000 appgroup \
&& adduser -D -u 5000 -G appgroup appuser
# 작업 디렉터리 설정
WORKDIR /app
# 파일을 복사하고 소유자 설정(핵심 단계!)
COPY --chown=appuser:appgroup package*.json ./
RUN npm install
COPY --chown=appuser:appgroup . .
# 비root 사용자로 전환(이 줄 뒤의 모든 명령은 appuser 권한으로 실행)
USER appuser
# 애플리케이션 시작
CMD ["node", "server.js"]
복잡해 보이지는 않지만 각 줄에는 모두 이유가 있습니다.
UID와 GID를 왜 지정해야 할까요? useradd를 쓸 때 숫자를 지정하지 않고 시스템이 자동으로 할당하도록 두는 경우가 많습니다. 문제는 컨테이너마다 자동으로 할당된 UID가 다를 수 있다는 점입니다. 데이터 볼륨으로 파일을 마운트하면 컨테이너 A가 만든 파일을 컨테이너 B에서 접근하지 못할 수 있습니다. 5000 같은 고정 UID를 지정해 모든 컨테이너에서 통일하면 파일 권한 문제를 크게 줄일 수 있습니다.
COPY --chown에는 어떤 장점이 있을까요? 일반 COPY 뒤에 RUN chown을 사용하면 Docker는 두 개의 이미지 레이어를 만듭니다. 첫 레이어는 root로 파일을 복사해 파일 소유자가 root가 되고, 두 번째 레이어에서 chown으로 소유자를 바꿉니다. --chown 옵션은 복사할 때 바로 올바른 소유자를 설정하므로 공간도 절약하고 더 안전합니다. 예전에 한 프로젝트에서 chown을 빠뜨려 애플리케이션 시작 시 ‘Permission denied’ 오류가 발생했고, 파일 권한이 원인임을 찾는 데 30분이나 걸렸습니다.
USER 명령의 위치가 매우 중요합니다. USER 앞의 명령은 여전히 root로 실행되고, 예를 들어 RUN npm install은 /app에 파일을 써야 합니다. USER 뒤부터 appuser로 전환됩니다. USER를 너무 앞에 둬서 뒤의 설치 명령이 전부 실패하는 경우가 많습니다. root 권한이 필요한 작업은 모두 USER 앞에 둔다는 원칙을 기억하십시오.
흔한 함정과 대응 방법
함정 1: 포트 바인딩 문제
비root로 바꾼 뒤 컨테이너를 시작했더니 Error: listen EACCES: permission denied 0.0.0.0:80 오류가 날 수 있습니다. 1024 미만 포트에는 특권이 필요하기 때문입니다.
해결 방법:
- 높은 포트 사용(권장): 애플리케이션은 3000 또는 8080에서 수신하고 Nginx나 로드 밸런서로 리버스 프록시 구성
- NET_BIND_SERVICE Capability 사용: 뒤에서 설명하겠지만 이 권한을 주면 비root 사용자도 낮은 포트를 바인딩할 수 있습니다.
# 애플리케이션 코드가 3000 포트에서 수신하도록 변경
EXPOSE 3000
USER appuser
CMD ["node", "server.js"] # 내부에서 3000 포트 수신
그런 다음 docker-compose나 K8s에서 매핑합니다.
ports:
- "80:3000" # 호스트 80 포트를 컨테이너 3000 포트에 매핑
함정 2: 로그 및 임시 파일 쓰기
한 번은 Python 애플리케이션을 비root로 바꿨는데 시작 직후 계속 오류가 났습니다. 한참 뒤에야 애플리케이션이 /var/log에 로그를 쓰려 하지만 appuser에게 권한이 없다는 것을 발견했습니다.
# 애플리케이션 사용자용 로그 디렉터리를 만들고 권한 부여
RUN mkdir -p /var/log/myapp && \
chown -R appuser:appgroup /var/log/myapp
USER appuser
더 좋은 방법은 애플리케이션이 stdout/stderr에 기록하고 Docker나 K8s가 로그를 통합 수집하도록 하는 것입니다. 애플리케이션 설정을 다음처럼 바꿉니다.
# 파일 로그를 사용하지 않음
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
함정 3: 마운트한 데이터 볼륨의 권한 불일치
호스트의 /data 디렉터리가 root 소유라면 컨테이너의 appuser(UID 5000)는 읽을 수 없습니다.
# 잘못된 예
docker run -v /data:/app/data myapp
# 컨테이너 안의 appuser가 /app/data를 읽거나 쓸 수 없음
해결 방법은 두 가지입니다.
# 방법 1: 호스트에서 권한을 미리 설정
sudo chown -R 5000:5000 /data
# 방법 2: 명명된 볼륨 사용(Docker가 권한 관리)
docker volume create appdata
docker run -v appdata:/app/data myapp
런타임 보안 옵션: —user와 그 이상
—user 옵션으로 이미지 설정 재정의
Dockerfile에 USER 명령이 없고 전부 root로 실행되는 타사 이미지를 받을 때가 있습니다. 이미지를 다시 빌드하기 번거롭다면 --user 옵션으로 런타임 사용자를 지정할 수 있습니다.
# 방법 1: UID:GID 직접 지정
docker run --user=1001:1001 nginx:latest
# 방법 2: 호스트의 현재 사용자 사용(동적 설정, 개발 환경에서 자주 사용)
docker run --user="$(id -u):$(id -g)" -v "$PWD:/app" node:18 npm test
# 방법 3: 알려진 사용자 이름 사용(이미지 안에 해당 사용자가 있어야 함)
docker run --user=nobody redis:alpine
저는 방법 2를 특히 좋아합니다. 로컬 개발에서 매우 유용합니다. 컨테이너에서 테스트를 실행해 보고서를 생성할 때 자신의 UID로 실행하면 호스트에 생성된 파일 권한도 올바르므로 파일을 지우려고 sudo를 쓸 필요가 없습니다.
다만 —user 옵션은 Dockerfile의 USER 명령을 재정의한다는 점에 주의해야 합니다. 이미지가 원래 비root로 설정되어 있는데 --user=0:0으로 다시 root로 바꾸면 모든 보안 설정이 무의미해집니다. 이 옵션을 사용할 때는 이미지의 기본 설정을 확인하십시오.
읽기 전용 파일 시스템: 공격자가 파일을 쓸 수 없게 하기
공격자가 취약점을 통해 컨테이너에 들어와 악성 코드를 심거나 설정 파일을 수정하려 한다고 생각해 보십시오. 파일 시스템이 읽기 전용이면 공격의 상당 부분을 무력화할 수 있습니다.
# 가장 간단한 읽기 전용 설정
docker run -d --read-only nginx:alpine
# 많은 애플리케이션이 임시 파일을 써야 한다면?
docker run -d \
--read-only \
--tmpfs /tmp \
--tmpfs /var/run \
nginx:alpine
--tmpfs 옵션은 메모리 파일 시스템을 마운트하며 컨테이너를 재시작하면 내용이 사라지므로 임시 파일에 적합합니다. 제가 운영하는 API 서비스 하나도 이렇게 설정했습니다. 로그는 stdout에 쓰고 세션 데이터는 Redis에 저장해 애플리케이션 자체가 영구적으로 기록할 필요가 없습니다. 읽기 전용 파일 시스템 덕분에 공격자가 셸을 얻어도 할 수 있는 일이 거의 없습니다.
권한 상승 금지: no-new-privileges
이 옵션은 컨테이너 안의 프로세스가 setuid, setgid 같은 메커니즘으로 권한을 높이는 것을 막습니다. 쉽게 말해 컨테이너 안에 SUID가 설정된 /bin/su가 있어도 사용자가 이를 통해 root로 승격할 수 없습니다.
docker run --security-opt=no-new-privileges myapp
직접 테스트했을 때 이 옵션을 켠 컨테이너에서 sudo를 실행하면 ‘effective uid is not 0’ 오류가 바로 발생했습니다. 권한 상승 공격 방어에 특히 효과적입니다.
프로덕션급 설정: 여러 방어책 조합하기
이 옵션들을 조합하면 상당히 강력한 보안 설정이 됩니다.
docker run -d \
--name secure-webapp \
--user=5000:5000 \ # 비root 사용자
--read-only \ # 읽기 전용 파일 시스템
--tmpfs /tmp:size=64M \ # 64MB 임시 파일 공간
--security-opt=no-new-privileges \ # 권한 상승 금지
--cap-drop=ALL \ # 모든 Capabilities 제거
--cap-add=NET_BIND_SERVICE \ # 필요한 포트 바인딩 권한만 추가
-p 443:8443 \ # 포트 매핑
-v appdata:/app/data \ # 데이터 볼륨(유일하게 쓰기 가능한 위치)
--memory=512m \ # 메모리 제한
--cpus=1.0 \ # CPU 제한
myapp:1.0.0
옵션이 많아 보이지만 각각 목적이 분명합니다. 제가 프로덕션에서 운영하는 핵심 서비스 몇 개도 이 설정 템플릿을 사용하며 2년 넘게 보안 사고가 없었습니다. 유일한 대가는 문제를 조사할 때 조금 번거롭다는 것입니다. 컨테이너에 exec로 들어가 파일을 직접 수정해 디버깅할 수 없지만, 충분히 감수할 만한 대가입니다.
K8s 사용자는 Pod의 SecurityContext에 같은 정책을 설정할 수 있습니다.
securityContext:
runAsNonRoot: true
runAsUser: 5000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
세분화된 권한 제어: Capabilities 메커니즘
Capabilities란 무엇이며 root보다 안전한 이유는 무엇인가요?
전통적인 Linux 권한은 ‘전부 아니면 전무’에 가깝습니다. root면 무엇이든 할 수 있고 일반 사용자면 많은 작업을 할 수 없습니다. Capabilities는 root의 막강한 권한을 40개가 넘는 독립적인 ‘능력’으로 나누어 프로세스에 필요한 몇 가지만 부여할 수 있게 합니다.
비유하자면 root는 모든 방의 열쇠가 달린 마스터키를 들고 있는 것과 같고, Capabilities는 들어가야 하는 몇 개의 방 열쇠만 따로 받는 것과 같습니다. 공격자가 열쇠를 빼앗아도 권한이 있는 방에만 들어갈 수 있고 서버실에는 들어갈 수 없습니다.
Docker는 기본적으로 컨테이너에 14개의 Capabilities를 남겨 둡니다.
- CHOWN: 파일 소유자 변경
- NET_BIND_SERVICE: 1024 미만 포트 바인딩
- SETUID/SETGID: 사용자/그룹 ID 변경
- KILL: 다른 프로세스에 시그널 전송
- DAC_OVERRIDE: 파일 읽기/쓰기 권한 검사 우회
적어 보이나요? 대부분의 애플리케이션에는 이미 충분합니다. 하지만 일부 Capabilities는 특히 위험하므로 반드시 제거해야 합니다.
위험한 Capabilities 목록(절대 부여하지 마십시오!)
SYS_ADMIN - root 권한의 절반 이상과 맞먹음
이 Capability로는 파일 시스템 mount, namespace 변경, 커널 모듈 로드 등 너무 많은 일을 할 수 있습니다. 제가 본 최악의 사례는 SYS_ADMIN이 부여된 컨테이너였습니다. 공격자가 침입한 뒤 unshare 명령으로 새 mount namespace를 만들고 호스트 디스크를 마운트하면서 게임은 끝났습니다.
# 절대 이렇게 하지 마십시오!
docker run --cap-add=SYS_ADMIN myapp # ❌ 위험!
NET_ADMIN - 네트워크 설정 제어
라우팅 테이블을 수정하고 방화벽 규칙을 설정하며 네트워크 트래픽을 스니핑할 수 있습니다. 컨테이너가 VPN이나 소프트웨어 라우터 같은 네트워크 도구가 아니라면 부여하지 마십시오.
SYS_MODULE - 커널 모듈 로드
말 그대로 커널에 코드를 삽입할 수 있습니다. 얼마나 위험한지 충분히 짐작할 수 있을 것입니다.
최소 권한 설정 실전
전략 1: 모두 제거한 뒤 필요한 것만 추가(권장)
docker run -d \
--cap-drop=ALL \ # 모든 Capabilities 제거
--cap-add=NET_BIND_SERVICE \ # 포트 바인딩만 추가(필요한 경우)
--cap-add=CHOWN \ # 파일 소유자 변경만 추가(필요한 경우)
myapp
제가 가장 자주 쓰는 전략입니다. 처음에는 특정 Capability가 없다는 오류가 날 수 있고, 오류 메시지에 따라 추가하면 됩니다. 애플리케이션이 프로세스 사용자를 바꾸기 위해 setuid()를 호출하면 ‘Operation not permitted’ 오류가 발생하므로 --cap-add=SETUID를 추가합니다.
전략 2: 위험한 권한만 제거(빠른 보안 강화에 적합)
docker run -d \
--cap-drop=SYS_ADMIN \
--cap-drop=NET_ADMIN \
--cap-drop=SYS_MODULE \
--cap-drop=SYS_RAWIO \
myapp
애플리케이션에 필요한 Capabilities를 정확히 모르지만 명백히 위험한 권한부터 빠르게 제거하고 싶을 때 적합합니다.
애플리케이션에 필요한 Capabilities를 판단하는 방법
방법 1: 시행착오(단순하지만 효과적)
# 1단계: 모두 제거하고 어떤 오류가 나는지 확인
docker run --cap-drop=ALL myapp
# 오류: bind: permission denied
# 2단계: NET_BIND_SERVICE 추가
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
# 시작 성공!
방법 2: capsh 도구로 분석
컨테이너에서 capsh --print를 실행해 현재 Capabilities를 확인합니다.
$ docker run --rm -it --cap-drop=ALL ubuntu capsh --print
Current: =
# 비어 있음, Capabilities가 하나도 없음
$ docker run --rm -it ubuntu capsh --print
Current: = cap_chown,cap_dac_override,cap_fowner,...
# 기본 14개 Capabilities
방법 3: 일반적인 애플리케이션 요구 사항 표 참고
| 애플리케이션 유형 | 필수 Capabilities | 설명 |
|---|---|---|
| 웹 애플리케이션(높은 포트) | 없음 | 3000+ 포트 수신에는 별도 권한 불필요 |
| 웹 애플리케이션(낮은 포트) | NET_BIND_SERVICE | 80/443 포트 수신에 필요 |
| 데이터베이스(MySQL/Postgres) | 없음 | 기본 포트가 모두 높은 포트 |
| Nginx/Caddy | NET_BIND_SERVICE | 80/443 포트를 직접 수신하는 경우 |
| VPN/네트워크 도구 | NET_ADMIN | 라우팅/네트워크 인터페이스 설정 변경 |
대부분의 비즈니스 애플리케이션은 모든 권한을 제거해도 실행되며, 많아야 NET_BIND_SERVICE 정도만 추가하면 됩니다.
강제 접근 제어: AppArmor와 SELinux
이 두 기능은 무엇을 하나요?
Capabilities가 ‘프로세스가 어떤 작업을 할 수 있는지’를 제어한다면 AppArmor/SELinux는 한 단계 더 나아가 ‘프로세스가 어떤 파일과 리소스에 접근할 수 있는지’를 제어합니다. 운영 체제 수준의 강제 접근 제어(MAC)이므로 프로세스에 root 권한이 있어도 profile 규칙을 따라야 합니다.
비유하자면 회사 대표(root)라도 서버실에 들어가려면 출입 카드를 찍어야 하고, 카드 권한이 부족하면 들어갈 수 없는 것과 같습니다. AppArmor/SELinux가 바로 그 출입 통제 시스템입니다.
시스템 선택:
- Debian/Ubuntu 시스템은 기본적으로 AppArmor 사용
- RHEL/CentOS 시스템은 기본적으로 SELinux 사용
- 둘 중 하나만 선택하고 동시에 활성화하지 마십시오. 서로 충돌할 수 있습니다.
AppArmor: 간단하면서 충분함(초보자 권장)
Docker는 컨테이너에 docker-default라는 profile을 자동으로 적용하며 이미 상당히 엄격합니다. 대부분의 경우 별도로 설정하지 않아도 백그라운드에서 컨테이너를 보호합니다.
# 컨테이너가 사용하는 AppArmor profile 확인
docker inspect mycontainer | grep -i apparmor
# "AppArmorProfile": "docker-default"
docker-default는 무엇을 하나요?
컨테이너가 다음 작업을 수행하지 못하도록 제한합니다.
- 파일 시스템 마운트(mount)
- 커널 매개변수(
/proc/sys/아래 파일) 변경 - 호스트의 민감한 디바이스(
/dev/아래 대부분의 디바이스) 접근 - AppArmor 자체 설정 변경
직접 테스트했을 때 AppArmor를 활성화한 컨테이너에서는 root 사용자라도 mount /dev/sda1 /mnt를 실행하면 ‘Permission denied’ 오류가 발생했습니다. Capabilities와 AppArmor의 이중 방어를 사용하면 컨테이너 탈출 난이도가 기하급수적으로 높아집니다.
SELinux: 더 강력하지만 더 복잡함
SELinux의 기본 개념은 모든 파일과 프로세스에 ‘레이블(label)’을 붙이고, 어떤 레이블이 어떤 레이블에 접근할 수 있는지 정책으로 정의하는 것입니다.
# 컨테이너 프로세스의 SELinux 레이블 확인
docker inspect mycontainer | grep -i selinux
# "ProcessLabel": "system_u:system_r:container_t:s0:c123,c456"
레이블의 c123,c456은 category이며 컨테이너마다 고유한 category 조합을 사용해 컨테이너 A가 컨테이너 B의 파일에 접근하지 못하게 합니다.
솔직히 SELinux는 설정 난도가 높고 오류 메시지도 친절하지 않습니다. Ubuntu에서는 AppArmor면 충분하고, RHEL에서는 SELinux가 기본적으로 보호하므로 대부분 손댈 필요가 없습니다.
실전 조언: 무엇을 언제 어떻게 사용해야 할까요?
상황 1: 개발 환경
- 디버깅 편의를 위해 일시적으로 비활성화할 수 있음(
apparmor=unconfined또는label=disable) - 단, 비활성화하기 전에 생각해 보십시오. 디버깅할 때 끈 설정을 배포할 때 다시 켤 수 있습니까?
상황 2: 테스트 환경
- 반드시 활성화하고 기본 profile 사용
- 보안 설정과 애플리케이션 기능의 충돌을 조기에 발견하는 것이 목적
상황 3: 프로덕션 환경
- 타협 없이 반드시 활성화
- 충분한 이유가 없다면 기본 profile 사용
- 로그를 정기적으로 감사해 위반 접근 시도가 있었는지 확인
제 경험상 DENIED의 99%는 차단되어야 마땅한 접근입니다. 공격 시도이거나 애플리케이션 설계가 잘못된 경우입니다. 권한을 실제로 완화해야 하는 상황은 극히 드뭅니다.
완전한 보안 체크리스트 만들기
앞의 내용을 실행 가능한 체크리스트로 정리했습니다. 그대로 적용하면 컨테이너 보안 수준이 상위 20%에 들 수 있습니다.
이미지 빌드 단계
Dockerfile 보안 점검:
- ✅ 공식 또는 신뢰할 수 있는 베이스 이미지 사용(출처가 불분명한 이미지 피하기)
- ✅ 이미지 버전 고정(
node:latest가 아니라node:18.17-alpine사용) - ✅ 전용 비root 사용자를 만들고 UID/GID 지정
- ✅
COPY --chown으로 파일 소유자 설정 - ✅
USER명령을 설치 명령 뒤이자 시작 명령 앞에 배치 - ✅ 애플리케이션은 높은 포트(3000+)에서 수신하거나 Capabilities 설정
- ✅ 멀티 스테이지 빌드로 이미지 크기와 공격 표면 축소
- ✅ 이미지에 민감한 정보 포함 금지(키, 비밀번호 등은 환경 변수나 secrets로 전달)
이미지 스캔 단계
반드시 수행해야 하는 보안 스캔:
# Docker 내장 scan 사용(Snyk 기반)
docker scan myapp:latest
# 또는 Trivy 사용(더 빠르고 포괄적이므로 권장)
trivy image myapp:latest
# 또는 Clair 사용(CI/CD에 통합)
# Harbor 저장소에서 자동 스캔 설정
Docker Hub 이미지의 76%에 취약점이 있다는 사실을 기억하십시오. 정기 스캔은 선택이 아니라 필수입니다. 저희 팀은 다음 원칙을 지킵니다.
- 고위험 취약점은 반드시 수정한 뒤 배포
- 중위험 취약점은 위험 평가 및 모니터링 필수
- 저위험 취약점은 기록하고 정기적으로 review
런타임 설정 점검
프로덕션급 docker-compose 템플릿:
services:
myapp:
image: myapp:1.0.0
user: "5000:5000" # 비root 사용자
read_only: true # 읽기 전용 파일 시스템
tmpfs:
- /tmp:size=64M # 임시 파일 메모리 마운트
security_opt:
- no-new-privileges:true # 권한 상승 금지
- apparmor=docker-default # AppArmor profile
cap_drop:
- ALL # 모든 Capabilities 제거
cap_add:
- NET_BIND_SERVICE # 필요한 것만 추가
volumes:
- appdata:/app/data:rw # 읽기/쓰기 권한 명시
deploy:
resources:
limits:
cpus: '1.0' # CPU 제한
memory: 512M # 메모리 제한
ports:
- "8080:8080"
프로덕션 운영 점검
일상 모니터링:
- ✅ 컨테이너의 비정상 재시작 모니터링(공격으로 인한 충돌 가능성)
- ✅ 비정상적인 리소스 사용 모니터링(채굴 악성 코드는 CPU를 가득 사용)
- ✅ 감사 로그를 활성화해 컨테이너 작업 기록
정기 감사:
- ✅ 매월 실행 중인 이미지 스캔(새 이미지뿐 아니라 운영 중인 이미지도 대상)
- ✅
--privileged또는 위험한 Capabilities를 사용하는 컨테이너가 있는지 확인 - ✅ 컨테이너 네트워크 정책과 노출 포트 검토
자주 묻는 질문과 해결 방법
Q1: 비root로 바꾼 뒤 애플리케이션 시작 시 권한 오류가 나면 어떻게 하나요?
진단 단계:
- 구체적인 오류 확인(파일 권한 문제인지 포트 바인딩 문제인지 구분)
- 파일 권한 문제라면 Dockerfile의
--chown과 디렉터리 권한 확인 - 포트 바인딩 문제라면 높은 포트로 변경하거나 NET_BIND_SERVICE Capability 추가
흔한 오류와 해결 방법:
# 오류: Error: EACCES: permission denied, open '/app/logs/app.log'
# 원인: 로그 디렉터리에 appuser 쓰기 권한이 없음
# 해결:
RUN mkdir -p /app/logs && chown appuser:appgroup /app/logs
# 오류: Error: listen EACCES: permission denied 0.0.0.0:80
# 원인: 비root 사용자는 낮은 포트를 바인딩할 수 없음
# 해결 1: 애플리케이션은 3000 포트에서 수신하고 포트 매핑 사용
EXPOSE 3000
# 해결 2: Capability 추가
docker run --cap-add=NET_BIND_SERVICE myapp
Q2: 데이터 볼륨 마운트 후 권한이 맞지 않으면 어떻게 하나요?
가장 자주 발생하는 문제입니다. 해결 방법 세 가지를 정리했습니다.
방법 1: 호스트에서 UID/GID를 미리 설정(권장)
# 호스트 디렉터리 소유자를 5000:5000으로 설정(컨테이너 사용자 UID와 일치)
sudo chown -R 5000:5000 /data
docker run -v /data:/app/data myapp
방법 2: Docker가 권한을 관리하는 명명된 볼륨 사용
docker volume create --opt o=uid=5000,gid=5000 appdata
docker run -v appdata:/app/data myapp
Q3: 실제로 root 권한이 필요한 상황은 무엇인가요? 거의 없습니다!
특정 상황에서는 반드시 root가 필요하다고 생각하기 쉽지만 대안이 있습니다.
| 상황 | root가 필요 없는 방법 |
|---|---|
| 80/443 포트 바인딩 | NET_BIND_SERVICE Capability 사용 또는 애플리케이션은 높은 포트에서 수신하고 로드 밸런서로 매핑 |
| 시스템 패키지 설치 | Dockerfile의 USER 명령 앞에서 설치하며 런타임에는 패키지를 설치하지 않음 |
| 시스템 설정 변경 | 런타임에 수정하는 대신 환경 변수나 설정 파일로 주입 |
| Docker Socket 접근 | 극도로 위험함! 꼭 필요하다면 socket을 마운트하지 말고 Docker API 또는 K8s API 사용을 검토 |
제가 본 유일하게 합리적인 root 사례는 공급업체가 하드코딩해 둬서 root로만 실행되는 레거시 데이터베이스 마이그레이션 도구였습니다. 코드도 수정할 수 없었습니다. 해결책은 별도의 일회성 컨테이너에 격리해 마이그레이션을 마친 뒤 바로 폐기하고 장기간 실행하지 않는 것이었습니다.
Q4: 타사 이미지가 root로 실행되면 어떻게 하나요?
우선순위가 높은 순서:
- 공식 비root 버전 찾기(많은 이미지가
-rootless또는-nonroot태그 제공) --user옵션으로 재정의
docker run --user=65534:65534 third-party-image # 65534는 nobody 사용자
- 원본 이미지를 기반으로 새 Dockerfile을 작성해 USER 추가
FROM third-party-image:latest
RUN adduser -D -u 5000 appuser
USER appuser
- 이미지 관리자에게 비root 버전 제공 요청(커뮤니티에 기여하십시오!)
결론
긴 내용을 한 문장으로 요약하면 컨테이너를 root로 실행하는 것은 공격자에게 뒷문을 남기는 것과 같습니다.
핵심 내용을 다시 정리하겠습니다.
- 컨테이너 탈출은 이론이 아니며 CVE-2024-21626과 특권 컨테이너 마운트는 실제 공격 경로입니다.
- Dockerfile에 USER 명령을 추가하고 런타임에 —user 옵션을 쓰는 일은 비용이 적지만 효과는 큽니다.
- Capabilities로 권한을 정밀하게 제어할 수 있으며, 모두 제거한 뒤 필요한 것만 추가하는 것이 모범 사례입니다.
- 읽기 전용 파일 시스템, no-new-privileges, AppArmor를 조합해 다층 방어를 구성할 수 있습니다.
- 이미지의 76%에 취약점이 있으므로 정기적인 스캔이 필수입니다.
오늘부터 세 가지를 실천하십시오.
- Dockerfile을 확인하고 USER 명령이 없다면 바로 추가하십시오.
- 프로덕션 환경을 감사해
--privileged또는 root로 실행되는 모든 컨테이너를 찾아 가능한 것은 즉시 수정하십시오. - 이미지 스캔 절차를 구축해 보안 점검을 CI/CD의 일부로 만드십시오.
보안은 일회성 작업이 아니라 지속적인 과정입니다. 하지만 컨테이너를 root에서 비root로 바꾸는 첫걸음만으로도 이미 대부분의 사람보다 앞서 나간 것입니다. 침해당한 뒤 후회하지 마십시오. 친구 회사가 겪은 교훈이 이미 이를 보여 줍니다.
FAQ
Docker 컨테이너를 root로 실행하면 안 되는 이유는 무엇인가요?
CVE-2024-21626 같은 컨테이너 탈출 취약점은 컨테이너 격리가 절대적으로 안전하지 않다는 사실을 보여 줍니다. Docker Hub 이미지의 76%에 보안 취약점이 있고 67%에는 고위험 취약점이 있습니다. 비root 사용자로 실행하면 컨테이너 보안 위험을 80% 낮출 수 있습니다.
Dockerfile에서 비root 사용자를 어떻게 설정하나요?
root 권한이 필요한 작업은 모두 USER 앞에 둡니다. 고정 UID/GID를 지정하면 볼륨 마운트 시 발생하는 권한 문제를 피할 수 있습니다.
비root 사용자가 80/443 포트를 바인딩하지 못하면 어떻게 하나요?
1) 높은 포트 사용(권장):
• 애플리케이션은 3000 또는 8080 포트에서 수신
• Nginx나 로드 밸런서로 리버스 프록시 구성
• 포트 매핑으로 호스트의 80 포트를 컨테이너의 3000 포트에 연결
2) NET_BIND_SERVICE Capability 사용:
• 비root 사용자가 낮은 포트를 바인딩할 수 있게 설정
• root로 80/443 포트를 직접 수신하지 않도록 구성
Capabilities란 무엇이며 최소 권한은 어떻게 설정하나요?
모범 사례:
• 먼저 --cap-drop=ALL로 모든 Capabilities 제거
• 필요한 것만 다시 추가(예: 포트 바인딩용 NET_BIND_SERVICE)
• SYS_ADMIN, NET_ADMIN, SYS_MODULE 같은 위험한 Capabilities는 절대 부여하지 않기
대부분의 비즈니스 애플리케이션은 모든 권한을 제거해도 실행되며, 많아야 NET_BIND_SERVICE 정도만 추가하면 됩니다.
프로덕션 환경에서는 어떤 보안 옵션을 설정해야 하나요?
• --user=5000:5000 비root 사용자
• --read-only 읽기 전용 파일 시스템과 임시 디렉터리용 --tmpfs
• --security-opt=no-new-privileges 권한 상승 차단
• --cap-drop=ALL로 모든 Capabilities를 제거한 뒤 필요한 것만 추가
• AppArmor/SELinux 강제 접근 제어 활성화
K8s 사용자는 Pod의 SecurityContext에 같은 정책을 설정할 수 있습니다.
데이터 볼륨을 마운트한 뒤 권한이 맞지 않으면 어떻게 하나요?
1) 호스트에서 UID/GID를 미리 설정(권장):
• sudo chown -R 5000:5000 /data
• 컨테이너 사용자의 UID와 일치하는지 확인
2) Docker가 권한을 관리하는 명명된 볼륨 사용:
• docker volume create --opt o=uid=5000,gid=5000 appdata
3) Dockerfile에서 애플리케이션 사용자용 디렉터리를 만들고 권한 부여
애플리케이션에 필요한 Capabilities는 어떻게 판단하나요?
1) 시행착오 방식:
• 먼저 --cap-drop=ALL로 실행해 오류 확인
• 오류 메시지에 따라 필요한 권한 추가
2) capsh 도구로 분석:
• docker run --rm -it ubuntu capsh --print로 현재 Capabilities 확인
3) 일반적인 애플리케이션 요구 사항 참고:
• 높은 포트를 쓰는 웹 애플리케이션은 별도 권한 불필요
• 낮은 포트를 쓰는 웹 애플리케이션은 NET_BIND_SERVICE 필요
• VPN/네트워크 도구는 NET_ADMIN 필요
4분 읽기 · 게시일: 2025년 12월 18일 · 수정일: 2026년 9월 4일
Docker 실전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Docker 이미지 보안 스캔과 수정: Trivy 실전 튜토리얼 및 CI/CD 통합 가이드
Docker Hub 이미지의 76%에는 보안 취약점이 존재합니다. Trivy 스캔 도구의 실전 사용법과 체계적인 취약점 수정 방법, CI/CD 자동화 통합을 완전한 명령 예제와 함께 설명합니다.
33편 중 25편
다음
Docker Secrets 완벽 가이드: 컨테이너 비밀번호와 API 키를 안전하게 관리하는 모범 사례
데이터베이스 비밀번호를 Dockerfile에 넣는 실수는 이제 그만하세요. Docker Secrets로 민감 정보를 안전하게 관리하는 방법과 Docker, K8s, Vault 등 도구 선택 기준, 운영 환경용 전체 체크리스트를 자세히 설명합니다.
33편 중 27편



댓글
GitHub로 로그인하여 댓글을 남기세요