infra
Platform

모듈 맵

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

0 / 27 완료

펼치기
0 / 27 완료0%

Docker · 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 컨테이너의 위험성

컨테이너와 호스트의 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 등 접근 불가
→ 다른 컨테이너 침해 가능         → 피해 범위 제한적
→ 호스트 시스템 완전 장악         → 추가 공격 어려움
💡개념

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

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

Dockerfile USER 지시어 패턴

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

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 구조

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의 보안 효과

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

  • 웹셸 업로드: 공격자가 취약점을 통해 악성 스크립트를 업로드해도 파일을 쓸 수 없음
  • 바이너리 교체: /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 프로파일 구조

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 환경을 자동으로 점검하는 오픈소스 도구입니다. 호스트 설정, 데몬 설정, 컨테이너 설정, 이미지 설정 등 수십 개 항목을 검사합니다.

위험 명령어--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
🔍실행 후 확인할 것
  • 컨테이너 실행 사용자를 root에서 non-root로 전환했는지 검증했는가?
  • `--cap-drop ALL` 기준에서 필요한 capability만 최소로 추가했는가?
  • read-only 파일시스템과 보안 감사 결과(WARN)를 근거로 개선 항목을 도출했는가?

트러블슈팅

컨테이너를 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가 필요한지" 주석으로 남기는 것도 중요합니다.

실무 맥락

💼
실무 맥락금융·의료 서비스 컨테이너 보안 감사 대응 — 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 기반 자동화 감사 도구

지식 확인

퀴즈 — 5문제

Q1

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

Q2

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

Q3

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

Q4

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

Q5

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

0 / 5 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

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

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

이것도 배워보세요

docker고급 · 55
[Docker] 대규모 빌드 속도를 10배 끌어올리는 캐시 튜닝 가이드
Docker 트랙 계속
networking입문 · 45
[Network] OSI 7계층과 TCP/IP 4계층 모델 실무적 관점 분석
Networking 트랙 시작점