컨테이너 보안 강화 — 최소 권한 원칙
보안 감사 직전 점검해보니 대부분 컨테이너가 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 iddocker run --rm alpine sh -c 'apk add -q libcap && capsh --print'mkdir -p ~/security-lab && cd ~/security-labdocker pull docker/docker-bench-security별도 설치 불필요
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 등 접근 불가
→ 다른 컨테이너 침해 가능 → 피해 범위 제한적
→ 호스트 시스템 완전 장악 → 추가 공격 어려움
Dockerfile USER 지시어 — non-root 실행 구현
보안 감사팀이 운영 중인 컨테이너 이미지를 점검합니다. docker inspect 결과를 보니 서비스 컨테이너 12개 중 11개의 User 필드가 비어 있습니다. 빈 값은 root(UID 0)로 실행 중이라는 뜻입니다. 감사 보고서에 "PCI-DSS 요구사항 7 위반 — 최소 권한 원칙 미준수" 항목이 생깁니다. Dockerfile에 USER 지시어 한 줄이 빠졌을 뿐인데 컴플라이언스 위반이 됩니다. 이 ConceptBlock에서는 non-root 전용 사용자를 만들고 런타임에 안전하게 전환하는 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 계열 사용자 생성 명령어 차이
# 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 결합
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입니다. 필요한 권한 하나만 추가하면 나머지는 전부 차단된 채로 운영할 수 있습니다.

Capabilities란?
전통적인 Unix 권한 모델에서 프로세스는 root이거나 아니거나 둘 중 하나였습니다. Linux 2.2부터 도입된 capabilities는 root 권한을 약 40개의 독립된 단위로 분리합니다.
| Capability | 의미 | 공격에 악용될 수 있는 경우 |
|---|---|---|
CAP_NET_ADMIN | 네트워크 인터페이스, 방화벽 설정 | iptables 조작, 트래픽 스니핑 |
CAP_SYS_ADMIN | 마운트, 네임스페이스, 시스템 설정 | 거의 모든 권한 포함 (피해야 함) |
CAP_SYS_PTRACE | 프로세스 메모리 접근/디버깅 | 다른 프로세스 메모리 덤프 |
CAP_NET_BIND_SERVICE | 1024 미만 포트 바인딩 | 직접적 악용 낮음 |
CAP_DAC_OVERRIDE | 파일 권한 무시 | 임의 파일 읽기/쓰기 |
CAP_SETUID | UID 변경 | 다른 사용자로 권한 상승 |
Docker 기본 Capabilities
Docker는 기본적으로 약 14개의 capabilities를 컨테이너에 부여합니다. 이 중에서도 많은 애플리케이션에 불필요한 것이 포함되어 있습니다.
# 컨테이너의 현재 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"
최소 권한 적용 패턴
# 모든 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 설정
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로 꼭 필요한 경로만 메모리 기반 쓰기 공간으로 열어줍니다.

--read-only의 보안 효과
컨테이너 파일시스템을 읽기 전용으로 마운트하면 다음 공격이 차단됩니다.
- 웹셸 업로드: 공격자가 취약점을 통해 악성 스크립트를 업로드해도 파일을 쓸 수 없음
- 바이너리 교체:
/usr/bin등의 시스템 바이너리를 악성 파일로 교체 불가 - 설정 파일 변조: 런타임에 설정을 몰래 수정하는 공격 차단
- 로그 조작: 침해 흔적을 덮으려는 로그 파일 삭제/수정 불가
# 읽기 전용으로 실행
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로 메모리 기반 임시 파일시스템을 특정 경로에만 허용합니다.
# /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 애플리케이션 읽기 전용 실행 예시
# 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에서 설정
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(Secure Computing Mode)은 컨테이너가 커널에 보낼 수 있는 시스템 콜(syscall)을 제한하는 리눅스 커널 보안 기능입니다. Docker는 기본적으로 300개 이상의 시스템 콜 중 약 40여 개(예: reboot, keyctl 등 위험 시스템 콜)를 차단하는 기본 seccomp 프로파일을 적용하여 안전벨트 역할을 수행합니다.
SRE의 seccomp 기본 핵심 요약
- 도커 기본 보안: 특별한 설정 없이도 Docker Engine은 기본 프로파일을 컨테이너마다 기동시켜 커널 취약점 공격을 방어합니다.
- 커스텀 프로파일의 실무적 적용: 특수한 레거시 시스템 콜을 사용해야 하거나 권한을 극도로 차단해야 하는 금융계 등 엔터프라이즈 환경에서는 커스텀 JSON 프로파일을 만들어
--security-opt seccomp=profile.json플래그로 데몬에 결합해 사용할 수 있습니다.
Docker Bench Security란?
CIS(Center for Internet Security) Docker Benchmark를 기반으로 Docker 환경을 자동으로 점검하는 오픈소스 도구입니다. 호스트 설정, 데몬 설정, 컨테이너 설정, 이미지 설정 등 수십 개 항목을 검사합니다.
보안 감사 컨테이너의 고권한 실행 옵션 사용 시 주의
안전한 실행 조건: 공식 docker/docker-bench-security 이미지를 신뢰 가능한 환경에서 1회성 감사 목적으로만 실행할 때 사용합니다.
실행 전 반드시 확인
- 공식 이미지 태그/다이제스트를 확인했는가?
- 운영 트래픽에 영향 없는 점검 창구에서 실행하는가?
- 실행 후 컨테이너가 자동 제거(--rm)되는가?
docker run --rm --privileged --pid=host --network=host docker/docker-bench-security위 항목을 모두 확인한 후 복사할 수 있습니다
# 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}}'
기본 실습
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실제 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
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
파일시스템을 읽기 전용으로 설정하고 임시 쓰기 공간을 허용합니다.
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
실제 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에서는 fsGroup과 runAsUser를 SecurityContext에 함께 설정하면 이 문제를 피할 수 있습니다.
non-root 사용자로 80, 443 같은 privileged port에 직접 바인딩하려 할 때 발생합니다.
원인 진단:
# 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
해결 방법:
# 방법 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가 필요한지 모를 때입니다.
원인 진단:
# 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
해결 방법:
# 우선 --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) 서비스는 정기적인 외부 보안 감사를 받습니다. 감사 항목에는 컨테이너 보안 설정이 포함되며, 표준을 충족하지 못하면 서비스 운영 자격이 박탈될 수 있습니다.
실제 감사에서 자주 지적되는 항목:
-
컨테이너 root 실행 — PCI-DSS 요구사항 7(최소 권한 접근)에 직접 위배. 모든 컨테이너에
USER지시어 필수. -
--privileged 컨테이너 — 컨테이너가 호스트 커널에 무제한 접근. 특별한 사유 없이는 절대 금지.
-
읽기/쓰기 파일시스템 — 런타임 파일 변조 가능성.
--read-only+--tmpfs로 필요한 경로만 허용. -
최신 CVE가 있는 베이스 이미지 — Trivy, Snyk 같은 이미지 스캐너로 빌드 파이프라인에서 자동 차단.
감사 대응을 위한 보안 체크리스트:
# 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 appuser | Dockerfile에서 전용 시스템 유저 생성 |
| 사용자 전환 | USER appuser | 이후 모든 프로세스를 non-root로 실행 |
| 소유권 설정 | COPY --chown=user:group | 복사와 동시에 소유자 지정, 추가 레이어 불필요 |
| 모든 capabilities 제거 | --cap-drop ALL | Docker 기본 14개 capabilities 모두 제거 |
| 필요한 capability만 추가 | --cap-add NET_BIND_SERVICE | 제거 후 꼭 필요한 것만 선택적 추가 |
| 읽기 전용 파일시스템 | --read-only | 런타임 파일 변조 차단 |
| 임시 쓰기 공간 | --tmpfs /tmp:rw,noexec,nosuid | 메모리 기반 임시 디렉토리, 컨테이너 종료 시 삭제 |
| 권한 상승 방지 | --security-opt no-new-privileges | setuid/setgid 바이너리 통한 권한 상승 차단 |
| seccomp 프로파일 | --security-opt seccomp=profile.json | 허용할 syscall 화이트리스트 지정 |
| 보안 감사 | docker/docker-bench-security | CIS Benchmark 기반 자동화 감사 도구 |