infra
Platform

모듈 맵

[Docker] 루트 권한 탈피 및 최소 권한 원칙을 적용한 이미지 보안

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 21 / 27

[Docker] 루트 권한 탈피 및 최소 권한 원칙을 적용한 이미지 보안

root 없이 실행하고, 읽기 전용 파일시스템, capabilities 제한으로 컨테이너를 강화합니다

컨테이너 보안 강화 — 최소 권한 원칙

🚨INCIDENT ALERT
HIGH

보안 감사 직전 점검해보니 대부분 컨테이너가 root로 실행되고, 권한 예외를 --privileged로 덮어둔 상태입니다. 평소에는 문제 없어 보여도 취약점 하나가 터지면 호스트 전체 권한으로 확산될 수 있습니다. 보안은 "한 번의 설정"이 아니라 권한·파일시스템·시스템콜을 단계적으로 줄이는 습관입니다. 이 모듈은 최소 권한 원칙을 실제 실행 옵션과 Dockerfile 패턴으로 정착시키는 데 초점을 둡니다.

많은 컨테이너가 기본적으로 root 권한으로 실행됩니다. 개발 편의상 허용해왔던 이 관행은 프로덕션에서 심각한 보안 위협이 됩니다. 이 챕터에서는 컨테이너 보안의 핵심 원칙인 **최소 권한(Principle of Least Privilege)**을 실제 코드와 명령어로 구현하는 방법을 다룹니다. 금융이나 의료 분야에서 요구하는 수준의 보안 감사도 통과할 수 있는 실무 기법을 배웁니다.


이번 챕터에서 배울 것

컨테이너 보안은 단일 설정이 아닌 여러 계층의 방어(Defense in Depth)로 구성됩니다. 각 계층이 독립적으로 공격을 억제하는 방법을 학습합니다.

  • 1root 컨테이너의 위험성을 이해하고 USER 지시어로 non-root 실행을 구현할 수 있다
  • 2Linux capabilities 개념을 이해하고 --cap-drop ALL / --cap-add로 최소 권한을 적용할 수 있다
  • 3읽기 전용 파일시스템(--read-only)과 --tmpfs로 파일시스템 공격을 차단할 수 있다
  • 4seccomp 프로파일로 시스템 콜 수준에서 컨테이너 격리를 강화할 수 있다
  • 5Docker Bench Security로 실제 환경 보안 감사를 수행할 수 있다
실습 환경 준비

일부 실습은 Linux 커널 기능에 접근하므로 macOS Docker Desktop에서는 동작이 다를 수 있습니다. Linux 호스트(VM 포함)에서 실습하는 것을 권장합니다.

현재 컨테이너의 실행 사용자 확인
docker run --rm alpine id
Linux capabilities 관련 도구 설치 확인
docker run --rm alpine sh -c 'apk add -q libcap && capsh --print'
실습 디렉토리 생성
mkdir -p ~/security-lab && cd ~/security-lab
Docker Bench Security 이미지 pull
docker pull docker/docker-bench-security
Node.js 샘플 앱 소스는 실습 단계에서 작성

별도 설치 불필요

💡개념

root 컨테이너의 위험성 — 왜 non-root가 필수인가

root 컨테이너의 위험성 — 컨테이너 내부 root(UID 0)는 호스트 커널과 UID를 공유하므로, 컨테이너 탈출 취약점과 결합되면 호스트 root 권한 탈취로 이어짐. non-root 전용 사용자로 실행하면 탈출하더라도 권한이 제한됨확대

컨테이너와 호스트의 UID 공유

Docker 컨테이너는 기본적으로 호스트 커널을 공유합니다. 컨테이너 내부의 root(UID 0)호스트의 root와 동일한 UID입니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_21 && cd /tmp/docker/part5/exam_21

# 현재 대부분의 공식 이미지가 root로 실행됨
docker run --rm nginx id
# uid=0(root) gid=0(root) groups=0(root)

docker run --rm node:20 id
# uid=0(root) gid=0(root) groups=0(root)

# root 컨테이너에서 호스트 파일 접근 시나리오
# (bind mount가 있을 경우)
docker run --rm -v /etc:/host-etc alpine cat /host-etc/shadow
# root 컨테이너는 호스트의 /etc/shadow를 읽을 수 있음 (심각!)

실제 공격 시나리오

컨테이너 탈출(container escape) 취약점은 꾸준히 발견됩니다 (CVE-2019-5736 runc 취약점, CVE-2022-0185 등). root로 실행 중인 컨테이너에서 탈출이 발생하면 공격자는 즉시 호스트 root 권한을 갖습니다.

non-root(예: UID 1001)로 실행 중이었다면, 탈출하더라도 호스트에서의 권한은 일반 사용자 수준으로 제한됩니다.

[root 컨테이너 탈출]          [non-root 컨테이너 탈출]
호스트 root 권한 획득           호스트 UID 1001 권한만 획득
→ 모든 파일 접근 가능            → /etc, /var 등 접근 불가
→ 다른 컨테이너 침해 가능         → 피해 범위 제한적
→ 호스트 시스템 완전 장악         → 추가 공격 어려움
💡개념

컨테이너 보안이 계층으로 쌓이는 법 — 빌드부터 호스트까지 방어선

컨테이너 보안은 한 옵션으로 끝나지 않습니다. root만 막아도, 이미지만 스캔해도 다른 층이 뚫리면 무너집니다. 그래서 방어는 빌드 시점(이미지) → 실행 시점(런타임 격리·파일시스템) → 호스트 순으로 겹겹이 쌓아, 한 층이 뚫려도 다음 층이 피해를 가두는 방식(defense in depth)으로 설계합니다. 각 층이 무엇을 막고, 그 층을 빼면 무엇이 노출되는지를 알면 감사 지적도 "어느 층이 비었나"로 정리됩니다.

TEXT
[빌드 시점] ───────────────────────────────────────
   ① 이미지 스캔        Trivy·Scout 로 베이스·의존성 CVE 차단
   │
   ② 최소 베이스 + 비root   distroless/alpine · USER 1001 (이미지에 구움)
   │
[실행 시점] ───────────────────────────────────────
   ③ 런타임 격리        namespace 분리 · cgroup 자원 제한 · cap-drop ALL · seccomp
   │
   ④ 파일시스템·시크릿   --read-only + --tmpfs · secrets(env 노출 차단)
   │
[호스트] ─────────────────────────────────────────
   ⑤ 호스트 보호        userns-remap · no-new-privileges · --privileged 금지
   ▼
   각 층이 독립적으로 공격을 억제 — 한 층이 뚫려도 다음 층이 피해를 가둠

각 층이 막는 것과, 그 층을 빼면 노출되는 것:

단계하는 일(막는 것)이 층을 빼면
① 이미지 스캔빌드·CI에서 베이스·라이브러리의 알려진 CVE를 찾아 HIGH/CRITICAL이면 배포 차단취약점이 박힌 이미지가 그대로 운영에 올라가 첫 침투 지점이 됨
② 최소 베이스·비root불필요한 도구를 뺀 최소 이미지 + USER 1001로 non-root 실행컨테이너가 root(UID 0)로 떠서 탈출 시 호스트 root 탈취 · 쉘·패키지 매니저가 공격 도구가 됨
③ 런타임 격리namespace로 프로세스·네트워크를 분리, cgroup으로 자원 제한, --cap-drop ALL·seccomp로 커널 권한·syscall 최소화--privileged·과다 capability면 컨테이너가 호스트 커널을 조작 · seccomp 없으면 취약 syscall(예: fsconfig) 무방비
④ 파일시스템·시크릿--read-only로 런타임 변조를 막고 필요한 경로만 --tmpfs · 민감값은 env 대신 secrets 파일로쓰기 가능 fs면 웹셸·악성 바이너리 심기 가능 · env로 넣은 키는 docker inspect·/proc/PID/environ으로 유출
⑤ 호스트 보호userns-remap으로 컨테이너 UID를 호스트 무권한 범위로 매핑, no-new-privileges로 권한 상승 차단컨테이너 root가 호스트에서도 root · setuid 바이너리로 권한 상승 경로가 열림

즉 컨테이너 보안은 "한 줄 옵션"이 아니라 빌드 → 런타임 → 호스트로 내려가는 층의 합입니다. 감사에서 지적이 나오면 어느 층인지부터 — 이미지(①②)인지, 런타임 격리(③④)인지, 호스트(⑤)인지 — 나누면, docker inspect로 사용자(②)·Privileged(③)·ReadonlyRootfs(④)를 한 번에 확인해 빈 층부터 메울 수 있습니다.

💡개념

Dockerfile USER 지시어 — non-root 실행 구현

보안 감사팀이 운영 중인 컨테이너 이미지를 점검합니다. docker inspect 결과를 보니 서비스 컨테이너 12개 중 11개의 User 필드가 비어 있습니다. 빈 값은 root(UID 0)로 실행 중이라는 뜻입니다. 감사 보고서에 "PCI-DSS 요구사항 7 위반 — 최소 권한 원칙 미준수" 항목이 생깁니다. Dockerfile에 USER 지시어 한 줄이 빠졌을 뿐인데 컴플라이언스 위반이 됩니다. 이 ConceptBlock에서는 non-root 전용 사용자를 만들고 런타임에 안전하게 전환하는 Dockerfile 패턴을 다룹니다.

Dockerfile USER 지시어 패턴 — USER 지시어가 없으면 컨테이너가 root로 실행되어 PCI-DSS 최소 권한 원칙에 위배. 전용 시스템 유저(adduser)를 만들고 파일 소유권을 넘긴 뒤 USER로 전환해 런타임을 non-root로 실행하는 Dockerfile 패턴확대

기본 패턴: 전용 시스템 유저 생성

Dockerfile
FROM node:20-alpine

WORKDIR /app

# 의존성 설치는 root로 (파일 쓰기 권한 필요)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

# -- 전용 non-root 사용자 생성 --
# -r: 시스템 계정 (로그인 불가)
# -u 1001: 명시적 UID 지정 (다른 서비스와 충돌 방지)
# -g nodejs: 전용 그룹
RUN addgroup -S nodejs && \
    adduser -S -u 1001 -G nodejs nodeuser

# 앱 파일의 소유자를 nodeuser로 변경
RUN chown -R nodeuser:nodejs /app

# 이후 모든 명령은 nodeuser로 실행
USER nodeuser

EXPOSE 3000
CMD ["node", "dist/index.js"]

Alpine vs Debian 계열 사용자 생성 명령어 차이

Dockerfile
# Alpine (BusyBox adduser/addgroup)
RUN addgroup -S -g 1001 appgroup && \
    adduser -S -u 1001 -G appgroup appuser

# Debian/Ubuntu (useradd/groupadd)
RUN groupadd -r -g 1001 appgroup && \
    useradd -r -u 1001 -g appgroup -m -d /home/appuser appuser

# distroless는 이미 nonroot 사용자 포함 (UID 65532)
FROM gcr.io/distroless/nodejs20-debian12
USER 65532:65532

멀티 스테이지 빌드와 USER 결합

Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app

# runtime 스테이지에서만 사용자 생성
RUN addgroup -S nodejs && adduser -S -u 1001 -G nodejs nodeuser

COPY --from=builder --chown=nodeuser:nodejs /app/dist ./dist
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --chown=nodeuser:nodejs package.json .

# 중요: USER는 COPY 이후, CMD 이전에 위치
USER nodeuser

EXPOSE 3000
CMD ["node", "dist/index.js"]

COPY --chown 옵션으로 복사와 동시에 소유자를 지정하면 별도의 RUN chown 명령어(추가 레이어 생성)가 필요 없습니다.

💡개념

Linux Capabilities — root 권한을 세분화하다

컨테이너를 non-root로 바꿨는데 애플리케이션이 시작부터 죽습니다. 원인을 추적해 보니 bind: permission denied — 80 포트에 바인딩하려면 root 권한이 필요합니다. 답답한 개발자는 --privileged 플래그를 달고 "일단 돌아가니까 됐다"고 넘깁니다. 그런데 --privileged는 컨테이너에 호스트 커널에 대한 거의 모든 권한을 주는 옵션입니다. 포트 하나 열자고 서버 전체를 내준 셈입니다. root 권한을 40개의 독립된 단위로 쪼개놓은 것이 Linux capabilities입니다. 필요한 권한 하나만 추가하면 나머지는 전부 차단된 채로 운영할 수 있습니다.

Linux Capabilities 구조 — root 권한을 약 40개의 독립 단위(CAP_NET_BIND_SERVICE 등)로 쪼갠 것. --privileged로 전부 주는 대신, 80 포트 바인딩처럼 필요한 capability 하나만 --cap-add로 추가하고 나머지는 --cap-drop=ALL로 차단해 최소 권한 유지확대

Capabilities란?

전통적인 Unix 권한 모델에서 프로세스는 root이거나 아니거나 둘 중 하나였습니다. Linux 2.2부터 도입된 capabilities는 root 권한을 약 40개의 독립된 단위로 분리합니다.

Capability의미공격에 악용될 수 있는 경우
CAP_NET_ADMIN네트워크 인터페이스, 방화벽 설정iptables 조작, 트래픽 스니핑
CAP_SYS_ADMIN마운트, 네임스페이스, 시스템 설정거의 모든 권한 포함 (피해야 함)
CAP_SYS_PTRACE프로세스 메모리 접근/디버깅다른 프로세스 메모리 덤프
CAP_NET_BIND_SERVICE1024 미만 포트 바인딩직접적 악용 낮음
CAP_DAC_OVERRIDE파일 권한 무시임의 파일 읽기/쓰기
CAP_SETUIDUID 변경다른 사용자로 권한 상승

Docker 기본 Capabilities

Docker는 기본적으로 약 14개의 capabilities를 컨테이너에 부여합니다. 이 중에서도 많은 애플리케이션에 불필요한 것이 포함되어 있습니다.

Docker
# 컨테이너의 현재 capabilities 확인
docker run --rm --cap-add SYS_PTRACE alpine sh -c \
  "apk add -q libcap && capsh --print"

# Docker 기본 capabilities 목록 확인
docker run --rm alpine sh -c \
  "apk add -q libcap && capsh --print 2>/dev/null | grep Current"

최소 권한 적용 패턴

Docker
# 모든 capabilities 제거
docker run --rm --cap-drop ALL alpine id

# 모든 제거 후 필요한 것만 추가 (웹 서버: 80/443 바인딩만 필요)
docker run --rm \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  nginx

# 필요 없는 것만 선별 제거 (점진적 접근)
docker run --rm \
  --cap-drop NET_ADMIN \
  --cap-drop SYS_ADMIN \
  --cap-drop SYS_PTRACE \
  myapp

docker-compose.yml에서 capabilities 설정

YAML
services:
  api:
    image: myapp:latest
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    # SYS_ADMIN은 절대 추가하지 않음
💡개념

읽기 전용 파일시스템과 tmpfs — 파일시스템 공격 차단

이미지 업로드 기능이 있는 서비스에 파일 경로 조작 취약점이 발견됩니다. 공격자는 이 취약점으로 /tmp/webshell.php를 컨테이너에 업로드하고 웹 서버를 통해 실행합니다. 컨테이너 파일시스템이 쓰기 가능 상태였기 때문입니다. --read-only 플래그 하나였으면 업로드 자체가 Read-only file system 오류로 차단됐을 것입니다. 물론 애플리케이션도 /tmp에 임시 파일을 써야 합니다 — 그 경우를 위해 --tmpfs로 꼭 필요한 경로만 메모리 기반 쓰기 공간으로 열어줍니다.

읽기 전용 파일시스템과 tmpfs — --read-only로 컨테이너 파일시스템을 읽기 전용으로 만들면 웹셸 업로드 같은 쓰기 공격이 Read-only file system 오류로 차단됨. 앱이 임시 파일을 써야 하는 경로만 --tmpfs로 메모리 기반 쓰기 공간으로 열어줌확대

--read-only의 보안 효과

컨테이너 파일시스템을 읽기 전용으로 마운트하면 다음 공격이 차단됩니다.

  • 웹셸 업로드: 공격자가 취약점을 통해 악성 스크립트를 업로드해도 파일을 쓸 수 없음
  • 바이너리 교체: /usr/bin 등의 시스템 바이너리를 악성 파일로 교체 불가
  • 설정 파일 변조: 런타임에 설정을 몰래 수정하는 공격 차단
  • 로그 조작: 침해 흔적을 덮으려는 로그 파일 삭제/수정 불가
Docker
# 읽기 전용으로 실행
docker run --rm --read-only alpine sh -c "echo test > /tmp/test"
# 오류: /tmp/test: Read-only file system

# 실행은 되지만 쓰기가 차단됨 확인
docker run --rm --read-only alpine sh -c "ls /"
# 읽기는 정상 동작

tmpfs — 임시 쓰기 공간 허용

읽기 전용 파일시스템(--read-only) 가동 시의 임시 파일 쓰기 우회책

컨테이너가 악성 코드나 공격자에 의해 런타임에 오염되는 것을 원천 차단하기 위해 --read-only 플래그를 주입하면, 컨테이너 루트 파일시스템은 엄격하게 읽기 전용 상태가 됩니다.

그러나 Node.js나 Python 백엔드 앱은 실행 도중 임시 캐시, 세션, 혹은 PID 파일 등을 쓰기 위해 반드시 /tmp/var/run 영역에 쓰기 권한이 열려있어야 합니다. 그렇지 않으면 기동 즉시 ReadOnlyFileSystemError를 뱉으며 크래시가 발생합니다.

이 충돌을 해결하기 위해 --volume /host/tmp:/tmp처럼 호스트 디렉토리를 바인드 마운트하면 안 됩니다. 호스트 /tmp를 마운트하는 순간 --read-only의 보안 목적이 훼손되고, 호스트 파일시스템 노출 위험이 생깁니다. 올바른 방법은 메모리 기반 --tmpfs입니다.

대부분의 애플리케이션은 최소한 /tmp에 임시 파일을 씁니다. --tmpfs로 메모리 기반 임시 파일시스템을 특정 경로에만 허용합니다.

Docker
# /tmp는 메모리 기반으로 쓰기 허용, 나머지는 읽기 전용
docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,size=64m,noexec,nosuid \
  alpine sh -c "echo test > /tmp/test && cat /tmp/test"
# 정상 동작

# noexec: tmpfs에서 실행 파일 실행 불가 (추가 보안)
# nosuid: SUID 비트 무시
# size=64m: 64MB 제한 (디스크 고갈 공격 방지)

Node.js 애플리케이션 읽기 전용 실행 예시

Docker
# Node.js 앱에 필요한 쓰기 경로 파악
docker run --rm --read-only \
  -e NODE_ENV=production \
  myapp:latest node dist/index.js 2>&1 | grep "Read-only\|EROFS\|EACCES"

# 필요한 경로만 tmpfs로 허용
docker run -d \
  --read-only \
  --tmpfs /tmp:rw,size=64m,noexec,nosuid \
  --tmpfs /app/logs:rw,size=100m,noexec,nosuid \
  -p 3000:3000 \
  myapp:latest

docker-compose.yml에서 설정

YAML
services:
  api:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp:size=64m,noexec,nosuid,mode=1777
      - /app/tmp:size=32m,noexec,nosuid
    volumes:
      # 영구 저장이 필요한 데이터만 named volume으로
      - app-data:/app/data
💡개념

seccomp 프로파일 — 시스템 콜 수준의 격리

CVE-2022-0185가 공개됩니다. Linux 커널의 fsconfig 시스템 콜에 힙 오버플로우 취약점이 있고, 컨테이너 내에서 이를 악용하면 컨테이너 탈출이 가능합니다. non-root로 실행 중이고 capabilities도 제거했지만, fsconfig syscall 자체를 막지 않으면 이 취약점에는 무방비입니다. seccomp 프로파일은 컨테이너가 커널에 요청할 수 있는 시스템 콜을 화이트리스트로 제한합니다. 웹 API 서버가 fsconfig를 호출할 이유는 없으니 차단하면 그만입니다. 이 ConceptBlock에서는 seccomp 프로파일의 구조와 실제 적용 방법을 다룹니다.

seccomp 프로파일 구조 — 컨테이너가 커널에 요청할 수 있는 시스템 콜을 화이트리스트로 제한. non-root·capabilities 제거로도 막지 못하는 커널 syscall 취약점(예: CVE-2022-0185의 fsconfig)을, 웹 API가 쓸 일 없는 syscall을 차단해 컨테이너 탈출을 예방확대

seccomp이란?

seccomp(Secure Computing Mode)은 컨테이너가 커널에 보낼 수 있는 시스템 콜(syscall)을 제한하는 리눅스 커널 보안 기능입니다. Docker는 기본적으로 300개 이상의 시스템 콜 중 약 40여 개(예: reboot, keyctl 등 위험 시스템 콜)를 차단하는 기본 seccomp 프로파일을 적용하여 안전벨트 역할을 수행합니다.

SRE의 seccomp 기본 핵심 요약

  1. 도커 기본 보안: 특별한 설정 없이도 Docker Engine은 기본 프로파일을 컨테이너마다 기동시켜 커널 취약점 공격을 방어합니다.
  2. 커스텀 프로파일의 실무적 적용: 특수한 레거시 시스템 콜을 사용해야 하거나 권한을 극도로 차단해야 하는 금융계 등 엔터프라이즈 환경에서는 커스텀 JSON 프로파일을 만들어 --security-opt seccomp=profile.json 플래그로 데몬에 결합해 사용할 수 있습니다.

Docker Bench Security란?

CIS(Center for Internet Security) Docker Benchmark를 기반으로 Docker 환경을 자동으로 점검하는 오픈소스 도구입니다. 호스트 설정, 데몬 설정, 컨테이너 설정, 이미지 설정 등 수십 개 항목을 검사합니다.

Docker Bench Security — CIS Docker Benchmark 기반으로 호스트 설정·도커 데몬·컨테이너 런타임·이미지 등 영역별 수십 개 항목을 검사해 PASS/WARN/INFO로 분류하고, WARN 항목을 우선순위로 조치하는 자동 보안 감사 흐름확대

위험 명령어--privileged/host 네임스페이스 공유는 컨테이너 격리를 크게 약화시켜 잘못된 이미지 실행 시 호스트 노출 위험이 커집니다.

보안 감사 컨테이너의 고권한 실행 옵션 사용 시 주의

안전한 실행 조건: 공식 docker/docker-bench-security 이미지를 신뢰 가능한 환경에서 1회성 감사 목적으로만 실행할 때 사용합니다.

실행 전 반드시 확인

  • 공식 이미지 태그/다이제스트를 확인했는가?
  • 운영 트래픽에 영향 없는 점검 창구에서 실행하는가?
  • 실행 후 컨테이너가 자동 제거(--rm)되는가?
docker run --rm --privileged --pid=host --network=host docker/docker-bench-security

위 항목을 모두 확인한 후 복사할 수 있습니다

Docker
# Docker Bench Security 실행 (호스트 전체 감사)
docker run --rm \
  -v /var/lib/docker:/var/lib/docker:ro \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /etc:/host/etc:ro \
  -v /usr/lib/systemd:/host/usr/lib/systemd:ro \
  --privileged --pid=host --network=host \
  --label docker_bench_security \
  docker/docker-bench-security

출력 해석

[INFO] 4 - Container Images and Build File
[PASS] 4.1  - Ensure a user for the container has been created
[WARN] 4.2  - Ensure that containers use only trusted base images
[PASS] 4.5  - Ensure Content trust for Docker is Enabled
[WARN] 4.6  - Ensure that HEALTHCHECK instructions have been added...

[INFO] 5 - Container Runtime
[WARN] 5.4  - Ensure that privileged containers are not used
[PASS] 5.7  - Ensure privileged ports are not mapped within containers
[WARN] 5.9  - Ensure that the host's network namespace is not shared
[PASS] 5.11 - Ensure that the memory usage for containers is limited

[PASS], [WARN], [INFO] 세 가지 레벨로 표시됩니다. WARN 항목을 우선적으로 수정합니다.

주요 검사 항목과 해결 방법

로컬 터미널
# WARN: root로 실행 중인 컨테이너 확인
docker ps -q | xargs docker inspect \
  --format='{{.Id}}: User={{.Config.User}}' | grep 'User=$'

# WARN: 읽기 전용이 아닌 루트 파일시스템
docker ps -q | xargs docker inspect \
  --format='{{.Id}}: ReadOnly={{.HostConfig.ReadonlyRootfs}}'

# WARN: --privileged 컨테이너 확인
docker ps -q | xargs docker inspect \
  --format='{{.Id}}: Privileged={{.HostConfig.Privileged}}'

기본 실습

1root 컨테이너와 non-root 컨테이너 동작 비교

root와 non-root 컨테이너의 실질적 차이를 직접 확인합니다.

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part3/exam_3 && cd /tmp/docker/part3/exam_3

# non-root 사용자 컨테이너 Dockerfile 생성
cat > Dockerfile << 'EOF'
FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
EOF

이제 실습을 진행합니다.

로컬 터미널
mkdir -p ~/security-lab
cd ~/security-lab

# root 컨테이너: 호스트 파일 접근 가능성 확인
echo "=== root 컨테이너 실행 사용자 ==="
docker run --rm alpine id

# bind mount가 있을 때 root 컨테이너의 위험성
mkdir -p /tmp/test-dir
echo "sensitive data" > /tmp/test-dir/secret.txt
chmod 700 /tmp/test-dir

echo "=== root 컨테이너: 700 권한 디렉토리 접근 가능 ==="
docker run --rm -v /tmp/test-dir:/data alpine cat /data/secret.txt
# root이므로 접근 가능

echo "=== non-root 컨테이너: 권한 거부 ==="
docker run --rm -v /tmp/test-dir:/data --user 1001:1001 alpine cat /data/secret.txt
# 오류: Permission denied

# 실행 사용자 확인
docker run --rm --user 1001:1001 alpine id
# uid=1001 gid=1001
mkdir -p ~/security-lab && cd ~/security-lab
2non-root Node.js 컨테이너 Dockerfile 작성 및 빌드

실제 Node.js 서비스를 non-root로 실행하는 완전한 Dockerfile을 작성합니다.

로컬 터미널
cd ~/security-lab
mkdir -p nodeapp && cd nodeapp

cat > package.json << 'EOF'
{
  "name": "secure-node",
  "version": "1.0.0",
  "dependencies": { "express": "^4.18.2" }
}
EOF

mkdir -p src
cat > src/index.js << 'EOF'
const express = require('express');
const os = require('os');
const app = express();

app.get('/', (req, res) => {
  res.json({
    message: 'Secure Node.js container',
    uid: process.getuid(),
    gid: process.getgid(),
    user: process.env.USER || 'unknown',
    hostname: os.hostname()
  });
});

app.get('/health', (req, res) => res.status(200).send('ok'));

app.listen(3000, () => {
  console.log(`Running as UID ${process.getuid()}, listening on :3000`);
});
EOF

cat > Dockerfile << 'EOF'
FROM node:20-alpine

WORKDIR /app

# root 권한이 필요한 작업 먼저 수행
RUN addgroup -S -g 1001 nodejs && \
    adduser  -S -u 1001 -G nodejs nodeuser

COPY --chown=nodeuser:nodejs package.json ./
RUN npm install --omit=dev

COPY --chown=nodeuser:nodejs src/ ./src/

# 최종적으로 non-root 사용자로 전환
USER nodeuser

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]
EOF

docker build -t secure-node:latest .

# 실행 후 사용자 확인
docker run -d --name secure-test -p 3000:3000 secure-node:latest
sleep 2
curl -s http://localhost:3000/ | python3 -m json.tool
docker stop secure-test && docker rm secure-test
3--cap-drop ALL로 최소 권한 적용

capabilities를 최소화하고 필요한 것만 추가하는 실습입니다.

로컬 터미널
cd ~/security-lab

echo "=== 기본 capabilities 확인 ==="
docker run --rm alpine sh -c \
  "apk add -q libcap 2>/dev/null && capsh --print 2>/dev/null | grep Current || cat /proc/1/status | grep Cap"

echo ""
echo "=== --cap-drop ALL 적용 후 capabilities ==="
docker run --rm --cap-drop ALL alpine sh -c \
  "apk add -q libcap 2>/dev/null && capsh --print 2>/dev/null | grep Current || cat /proc/1/status | grep Cap"

echo ""
echo "=== ping 실행 — NET_RAW capability 필요 ==="
docker run --rm --cap-drop ALL alpine ping -c 1 8.8.8.8 2>&1 || echo "ping 실패 (NET_RAW 없음)"

echo ""
echo "=== NET_RAW 추가 후 ping ==="
docker run --rm --cap-drop ALL --cap-add NET_RAW alpine ping -c 1 8.8.8.8

# 실제 서비스에 적용: secure-node에 최소 capabilities 적용
docker run -d --name cap-test \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  -p 3000:3000 \
  secure-node:latest

sleep 2
curl -s http://localhost:3000/health
docker stop cap-test && docker rm cap-test
4--read-only 실행과 tmpfs 마운트

파일시스템을 읽기 전용으로 설정하고 임시 쓰기 공간을 허용합니다.

로컬 터미널
cd ~/security-lab

echo "=== 읽기 전용 없이 실행 — 파일 쓰기 가능 ==="
docker run --rm alpine sh -c "echo 'wrote file' > /tmp/test && cat /tmp/test"

echo ""
echo "=== --read-only 적용 — 파일 쓰기 차단 ==="
docker run --rm --read-only alpine sh -c \
  "echo 'wrote file' > /tmp/test 2>&1 || echo 'BLOCKED: Read-only filesystem'"

echo ""
echo "=== --read-only + --tmpfs /tmp — 임시 쓰기만 허용 ==="
docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,size=32m,noexec,nosuid \
  alpine sh -c "echo 'temp data' > /tmp/test && cat /tmp/test && echo 'tmpfs write OK'"

echo ""
echo "=== noexec 확인: tmpfs에서 실행 파일 실행 불가 ==="
docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,size=32m,noexec,nosuid \
  alpine sh -c "
    cp /bin/ls /tmp/ls_copy
    /tmp/ls_copy /tmp 2>&1 || echo 'BLOCKED: noexec flag prevents execution'
  "

# secure-node에 최종 보안 설정 모두 적용
docker run -d --name hardened-app \
  --read-only \
  --tmpfs /tmp:rw,size=32m,noexec,nosuid \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  -p 3000:3000 \
  secure-node:latest

sleep 2
echo "=== 강화된 컨테이너 응답 확인 ==="
curl -s http://localhost:3000/
docker stop hardened-app && docker rm hardened-app
5Docker Bench Security 감사 실행

실제 Docker 환경의 보안 취약점을 자동으로 점검합니다.

Docker
# Docker Bench Security 실행
docker run --rm -it \
  --net host \
  --pid host \
  --userns host \
  --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /etc:/etc:ro \
  -v /lib/systemd/system:/lib/systemd/system:ro \
  -v /usr/bin/containerd:/usr/bin/containerd:ro \
  -v /usr/bin/runc:/usr/bin/runc:ro \
  -v /usr/lib/systemd:/usr/lib/systemd:ro \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --label docker_bench_security \
  docker/docker-bench-security 2>/dev/null | grep -E '^\[WARN\]|^\[PASS\]|^\[INFO\] [0-9]' | head -40

# root로 실행 중인 컨테이너 목록 확인
echo ""
echo "=== root로 실행 중인 컨테이너 확인 ==="
docker ps -q | xargs -I{} docker inspect {} \
  --format='컨테이너: {{.Name}} | 사용자: {{if .Config.User}}{{.Config.User}}{{else}}root (미설정){{end}}' \
  2>/dev/null
🔍실행 후 확인할 것
  • docker inspect --format="{{.Config.User}}" 컨테이너명 출력 먼저 확인 — 빈 문자열이면 root(UID 0)로 실행 중, "1001:1001" 또는 "nodeuser"이면 non-root 적용 완료
  • Docker Bench 출력에서 CRITICAL 수 기준: WARN 0개가 목표, 1~5개는 개선 필요, 10개 이상이면 보안 감사 통과 불가 — [WARN] 5.4(privileged), [WARN] 4.1(root 사용자) 두 항목이 가장 위험
  • Privileged=true 이고 User가 비어 있으면 → 호스트 root 권한 노출 최악 조합; ReadonlyRootfs=false 이고 User가 비어 있으면 → 파일 쓰기 + root 권한으로 웹셸 업로드 가능; 둘 다 해소되면 컨테이너 탈출 시 피해 범위 대폭 축소

트러블슈팅

컨테이너를 non-root(UID 1001)로 실행했는데 호스트의 bind mount 디렉토리가 root 소유일 때 발생합니다.

원인 진단:

로컬 터미널
# 호스트 디렉토리 소유자 확인
ls -la /host/path/to/logs/
# drwxr-xr-x 2 root root 4096 ...  ← root 소유, UID 1001이 쓰기 불가

# 컨테이너 내부에서도 확인
docker run --rm -v /host/path/to/logs:/app/logs --user 1001:1001 \
  alpine ls -la /app/

해결 방법:

로컬 터미널
# 방법 1: 호스트에서 소유자 변경 (UID를 컨테이너와 맞춤)
sudo chown -R 1001:1001 /host/path/to/logs

# 방법 2: 디렉토리 권한을 777로 변경 (임시방편, 보안 약화)
# chmod 777 /host/path/to/logs  ← 권장하지 않음

# 방법 3: named volume 사용 (Docker가 소유권 자동 관리)
# docker run -v app-logs:/app/logs --user 1001:1001 myapp

# 방법 4: 초기화 컨테이너로 소유권 설정
docker run --rm -v /host/path/to/logs:/app/logs alpine \
  chown -R 1001:1001 /app/logs

Kubernetes에서는 fsGrouprunAsUser를 SecurityContext에 함께 설정하면 이 문제를 피할 수 있습니다.

non-root 사용자로 80, 443 같은 privileged port에 직접 바인딩하려 할 때 발생합니다.

원인 진단:

Docker
# non-root + cap-drop ALL 상태에서 80 포트 바인딩 시도
docker run --rm --cap-drop ALL --user 1001 \
  alpine sh -c "nc -l 80" 2>&1
# nc: bind: Permission denied

해결 방법:

Docker
# 방법 1: NET_BIND_SERVICE capability 추가
docker run --rm \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --user 1001 \
  nginx

# 방법 2 (권장): 컨테이너 내부는 3000 포트, 호스트에서 80으로 매핑
docker run -d \
  --cap-drop ALL \
  --user 1001 \
  -p 80:3000 \          # 호스트 80 → 컨테이너 3000
  myapp:latest

# 방법 3: 커널 파라미터 조정 (호스트 전체 설정 — 주의)
# sysctl -w net.ipv4.ip_unprivileged_port_start=80

포트 매핑 방식(방법 2)이 가장 깔끔합니다. capabilities를 추가하지 않아도 되고 컨테이너 내부 코드도 수정할 필요가 없습니다.

--cap-drop ALL 적용 후 애플리케이션이 특정 작업에서 실패하지만 어떤 capability가 필요한지 모를 때입니다.

원인 진단:

Docker
# strace로 실패하는 syscall과 관련 capability 추적
docker run --rm \
  --cap-add SYS_PTRACE \
  --security-opt seccomp=unconfined \
  myapp:latest \
  strace -e trace=all ./myapp 2>&1 | grep "EPERM\|EACCES" | head -20

# auditd 로그에서 capability 거부 확인 (호스트에서)
ausearch -m avc -ts recent 2>/dev/null | grep capability

해결 방법:

Docker
# 우선 --cap-drop ALL 없이 실행해서 동작 확인
docker run --rm myapp:latest

# 하나씩 제거하면서 실패 지점 찾기
for cap in NET_ADMIN SYS_ADMIN CHOWN DAC_OVERRIDE SETUID SETGID; do
  echo -n "Testing without $cap: "
  docker run --rm --cap-drop $cap myapp:latest ./test-cmd 2>&1 | \
    grep -q "error\|fail\|denied" && echo "NEEDED" || echo "not needed"
done

필요한 capability 목록이 확인되면 Dockerfile이나 docker-compose.yml에 명시적으로 기록합니다. "왜 이 capability가 필요한지" 주석으로 남기는 것도 중요합니다.

심화 — non-root로도 남는 마지막 틈: user namespace

💡개념

심화: user namespace remapping — 컨테이너 root를 호스트의 무권한 UID로

USER 1001로 non-root 실행을 해도 한 가지가 그대로 남습니다 — 그 UID는 호스트에서도 똑같은 UID 1001입니다. 컨테이너와 호스트가 같은 user namespace를 공유하기 때문입니다. 진짜 격리는 "컨테이너 안의 UID"와 "호스트의 UID"를 분리하는 데서 시작합니다.

  • 매핑의 실체: userns-remap을 켜면 Docker는 컨테이너의 UID 전체 범위(065535)를 호스트의 서브 UID 범위(/etc/subuid에 잡힌 예: 100000165535)로 통째로 옮깁니다. 그 결과 컨테이너 root(UID 0)조차 호스트에서는 100000번 무권한 사용자가 됩니다. USER 지시어를 깜빡한 root 컨테이너가 탈출해도, 호스트에서 가진 권한은 100000번 일반 사용자 수준뿐입니다.
  • non-root와의 차이: USER 1001은 "컨테이너 안에서 root가 아니게" 하는 것이고, userns-remap은 "호스트 관점에서 컨테이너 전체를 무권한 영역에 가두는" 것입니다. 층이 다릅니다 — 둘을 함께 쓰면 방어가 겹칩니다(chmod/chown으로 파일 읽기·쓰기·실행 권한 완벽 제어).
  • 한계와 비용: 이 설정은 데몬 전역(daemon.json)이라 컨테이너별로 껐다 켜기 번거롭고, --privileged·일부 --pid=host·호스트 장치 직접 접근과 충돌합니다. 무엇보다 볼륨 소유권이 매핑된 범위와 맞아야 해서, 켜는 순간 기존 볼륨 접근이 깨지는 사고가 흔합니다(아래 트러블케이스).

그래서 userns-remap은 "공짜 보안 스위치"가 아니라, 볼륨·권한 운영을 매핑 범위에 맞춰 재정비하는 프로젝트입니다. 그 대가로 얻는 건 "컨테이너 root ≠ 호스트 root"라는, capability·seccomp로는 못 주는 마지막 격리 층입니다.

상황: 보안 강화 차원에서 userns-remap을 데몬 전역으로 켰습니다. 대부분 컨테이너는 문제없이 떴는데, 볼륨에 기존 데이터를 물고 있던 서비스 두어 개가 시작 직후 EACCES/permission denied로 죽습니다. ls -n으로 호스트 볼륨을 보면 예전엔 0 0(root)이던 파일들이 100000 100000으로 보입니다.

원인: userns-remap이 켜지면 컨테이너의 UID 0(root)이 호스트의 서브 UID 베이스(예: 100000)로 매핑됩니다. 즉 컨테이너 안에서 root로 파일을 만들면 호스트에는 100000 소유로 기록되고, 반대로 예전에 host root(0) 소유로 쌓아둔 볼륨 데이터는 이제 컨테이너 입장에서 "내 것이 아닌" 파일이라 접근이 막힙니다. 파일이 사라진 게 아니라 소유 UID 기준이 통째로 이동한 것입니다.

진단: docker info | grep -i userns로 remap이 실제 적용됐는지, cat /etc/subuid로 이 데몬에 할당된 베이스 UID를 확인합니다. 그다음 호스트에서 ls -n /path/to/volume으로 파일의 숫자 UID가 매핑 베이스(예: 100000)와 어긋나는지 봅니다 — 베이스보다 낮은 0·1001 등이면 remap 이전 소유권이 그대로 남은 것입니다.

해결: 지속 데이터를 담은 볼륨은 매핑 베이스에 맞춰 소유권을 옮깁니다 — sudo chown -R 100000:100000 /path/to/volume(베이스는 /etc/subuid 값 기준). 새로 시작하는 서비스는 처음부터 named volume을 쓰면 Docker가 매핑에 맞춰 소유권을 잡아주므로 이 사고를 피합니다. remap과 도저히 안 맞는 특정 컨테이너(호스트 장치·소켓 직접 접근 등)만 예외로 --userns=host로 띄우되, 그 컨테이너는 격리가 약해진다는 걸 문서로 남깁니다.

실무 맥락

💼
실무 맥락금융·의료 서비스 컨테이너 보안 감사 대응 — PCI-DSS / HIPAA 컴플라이언스
현업 패턴

금융(PCI-DSS)과 의료(HIPAA) 서비스는 정기적인 외부 보안 감사를 받습니다. 감사 항목에는 컨테이너 보안 설정이 포함되며, 표준을 충족하지 못하면 서비스 운영 자격이 박탈될 수 있습니다.

실제 감사에서 자주 지적되는 항목:

  1. 컨테이너 root 실행 — PCI-DSS 요구사항 7(최소 권한 접근)에 직접 위배. 모든 컨테이너에 USER 지시어 필수.

  2. --privileged 컨테이너 — 컨테이너가 호스트 커널에 무제한 접근. 특별한 사유 없이는 절대 금지.

  3. 읽기/쓰기 파일시스템 — 런타임 파일 변조 가능성. --read-only + --tmpfs로 필요한 경로만 허용.

  4. 최신 CVE가 있는 베이스 이미지 — Trivy, Snyk 같은 이미지 스캐너로 빌드 파이프라인에서 자동 차단.

감사 대응을 위한 보안 체크리스트:

YAML
# docker-compose.yml 보안 강화 템플릿
services:
  payment-api:
    image: payment-service:${VERSION}

    # 1. non-root 실행
    user: "1001:1001"

    # 2. 읽기 전용 파일시스템
    read_only: true
    tmpfs:
      - /tmp:size=32m,noexec,nosuid,mode=1777

    # 3. 최소 capabilities
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

    # 4. 권한 상승 방지
    security_opt:
      - no-new-privileges:true
      - seccomp:seccomp-payment.json

    # 5. 리소스 제한 (DoS 방지)
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: '0.5'

    # 6. 헬스체크
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
로컬 터미널
# CI 파이프라인에 이미지 취약점 스캔 추가
trivy image --exit-code 1 --severity HIGH,CRITICAL payment-service:$VERSION

# 빌드가 HIGH/CRITICAL CVE가 있으면 자동으로 실패
# 감사 시 "우리는 취약점을 자동으로 탐지하고 차단합니다"를 증명

한 핀테크 팀에서 이 설정을 전 서비스에 적용한 후 PCI-DSS 레벨 1 감사에서 컨테이너 보안 관련 지적 사항이 0건으로 감사를 통과한 사례가 있습니다. 중요한 것은 개발 초기부터 보안 설정을 Dockerfile과 Compose 파일에 코드로 포함시키는 것입니다.

핵심 요약

개념명령/설정설명
non-root 사용자 생성RUN adduser -S -u 1001 appuserDockerfile에서 전용 시스템 유저 생성
사용자 전환USER appuser이후 모든 프로세스를 non-root로 실행
소유권 설정COPY --chown=user:group복사와 동시에 소유자 지정, 추가 레이어 불필요
모든 capabilities 제거--cap-drop ALLDocker 기본 14개 capabilities 모두 제거
필요한 capability만 추가--cap-add NET_BIND_SERVICE제거 후 꼭 필요한 것만 선택적 추가
읽기 전용 파일시스템--read-only런타임 파일 변조 차단
임시 쓰기 공간--tmpfs /tmp:rw,noexec,nosuid메모리 기반 임시 디렉토리, 컨테이너 종료 시 삭제
권한 상승 방지--security-opt no-new-privilegessetuid/setgid 바이너리 통한 권한 상승 차단
seccomp 프로파일--security-opt seccomp=profile.json허용할 syscall 화이트리스트 지정
보안 감사docker/docker-bench-securityCIS Benchmark 기반 자동화 감사 도구

관련 모듈로 더 깊이:

다음 모듈에서는 사내 프라이빗 레지스트리를 직접 구축하고 인증과 취약점 스캔을 적용해 안전한 이미지 배포 환경을 만드는 방법을 다룹니다.

클라우드에서 이미지 취약점 스캔·서명(cosign)·SBOM·최소 베이스 이미지로 공급망 전체를 지키는 방법은 컨테이너 이미지 보안(Cloud Engineering 트랙)에서 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

컨테이너를 root(UID 0)로 실행했을 때의 위험성으로 가장 정확한 것은?

Q2

`docker run --cap-drop ALL --cap-add NET_BIND_SERVICE` 명령어의 의미는?

Q3

`--read-only` 플래그로 컨테이너를 실행할 때 애플리케이션이 임시 파일을 써야 한다면 어떻게 해야 하는가?

Q4

seccomp 프로파일의 역할로 올바른 것은?

Q5

Dockerfile에서 `USER` 지시어 사용 시 올바른 모범 사례는?

Q6

컨테이너를 --read-only로 실행해 루트 파일시스템을 읽기 전용으로 만드는 것이 보안에 도움이 되는 이유는?

Q7

[심화] 컨테이너를 USER 1001로 non-root 실행 중인데도 컨테이너 탈출 시 호스트가 위험할 수 있는 이유와, user namespace remapping(userns-remap)이 이를 어떻게 개선하는지로 가장 정확한 것은?

Q8

[심화] daemon.json에 userns-remap을 켜고 도커를 재시작하자 잘 돌던 컨테이너 일부가 bind mount 데이터를 EACCES로 못 읽고, 호스트에서 그 파일 소유자가 100000 같은 낯선 UID로 보인다. 원인으로 가장 정확한 것은?

0 / 8 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요