[도커 30강] 23강. 컨테이너 보안 기본 non-root와 최소 권한

[도커 30강] 23강. 컨테이너 보안 기본 non-root와 최소 권한

컨테이너를 잘 띄우는 것과 안전하게 운영하는 것은 완전히 다른 문제입니다. 로컬 개발에서는 빨리 실행되는 게 우선이라 root로 컨테이너를 띄워도 당장 문제를 못 느끼는 경우가 많습니다. 하지만 운영 환경에서는 작은 권한 과다도 사고로 이어집니다. 예를 들어 취약한 라이브러리 하나를 통해 컨테이너 내부 코드 실행이 가능해졌을 때, 프로세스가 root 권한이라면 공격자가 파일 시스템 변경, 민감 파일 접근, 런타임 설정 변조를 훨씬 쉽게 시도할 수 있습니다.

오늘 강의에서는 “처음부터 안전한 기본값”을 만드는 데 집중합니다. 핵심 키워드는 두 가지입니다. non-root 실행과 **최소 권한(least privilege)**입니다. 이 두 가지만 습관화해도 컨테이너 보안의 기본 점수를 크게 올릴 수 있습니다. 어렵고 복잡한 보안 솔루션보다 먼저, Dockerfile과 실행 옵션에서 바로 적용 가능한 패턴을 손에 익히는 것이 목표입니다.


핵심 개념

  • non-root 실행은 컨테이너 보안의 1순위 기본값입니다. 컨테이너 내부의 프로세스가 root일 필요가 없다면 반드시 일반 사용자로 실행해야 합니다. 취약점이 발생하더라도 피해 범위를 줄일 수 있습니다.
  • 최소 권한 원칙은 “필요한 권한만 주고 나머지는 제거”하는 접근입니다. Linux capability, 파일 권한, 쓰기 가능한 경로, 네트워크 접근 범위를 모두 최소화해야 합니다.
  • 이미지 빌드 단계와 런타임 단계를 분리해야 합니다. 빌드에는 root가 필요할 수 있어도, 최종 실행 이미지는 non-root로 고정하는 것이 실무 표준입니다.
  • 읽기 전용 루트 파일시스템(read-only rootfs), capability drop, no-new-privileges 같은 런타임 하드닝 옵션은 공격 성공 가능성을 현실적으로 낮춰줍니다.
  • 보안은 “한 방 설정”이 아니라 반복 가능한 팀 규칙입니다. Dockerfile 템플릿, compose 기본값, CI 검사 규칙으로 제도화해야 유지됩니다.

기본 사용

예제 1) Dockerfile에서 non-root 사용자로 실행 고정

mkdir -p ~/docker100/lesson23 && cd ~/docker100/lesson23

cat > app.py <<'PY'
from http.server import HTTPServer, BaseHTTPRequestHandler
import os

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write(f"uid={os.getuid()} gid={os.getgid()}\n".encode())

HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
PY

cat > Dockerfile <<'DOCKER'
FROM python:3.12-slim
WORKDIR /app

# 10001:10001 고정 UID/GID로 사용자 생성
RUN groupadd -g 10001 appgroup \
 && useradd -u 10001 -g appgroup -m -s /usr/sbin/nologin appuser

COPY app.py /app/app.py
RUN chown -R 10001:10001 /app

USER 10001:10001
EXPOSE 8080
CMD ["python", "/app/app.py"]
DOCKER

docker build -t devlab/nonroot-demo:1 .
docker run --rm -p 8080:8080 devlab/nonroot-demo:1

설명:

  • USER 10001:10001로 최종 실행 사용자를 명시하면, 컨테이너 기본 root 실행을 방지할 수 있습니다.
  • UID/GID를 명시적으로 고정하면 호스트 볼륨 마운트 시 권한 이슈를 예측하기 쉬워집니다.
  • 앱이 실제로 non-root로 돌고 있는지 curl localhost:8080 응답의 uid/gid로 확인할 수 있습니다.

예제 2) 런타임 최소 권한 적용

# 모든 capability 제거 + 필요시 특정 capability만 추가
# 여기서는 단순 웹 서버이므로 추가 capability 없음

docker run --rm -p 8080:8080 \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  devlab/nonroot-demo:1

# 현재 컨테이너의 보안 옵션 확인
cid=$(docker ps -q --filter ancestor=devlab/nonroot-demo:1 | head -n1)
docker inspect "$cid" --format '{{json .HostConfig.CapDrop}} {{json .HostConfig.SecurityOpt}}'

설명:

  • --cap-drop ALL은 불필요한 커널 기능 권한을 제거합니다.
  • no-new-privileges는 실행 중 권한 상승 경로(setuid 등)를 제한해 공격 난이도를 높입니다.
  • “일단 다 열어두고 필요하면 막기”가 아니라 “일단 다 막고 꼭 필요한 것만 열기”가 안전한 방식입니다.

예제 3) 읽기 전용 루트 파일시스템 + 쓰기 경로만 tmpfs 허용

docker run --rm -p 8080:8080 \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  devlab/nonroot-demo:1

# 동작 확인
curl -s localhost:8080

설명:

  • 루트 파일시스템을 읽기 전용으로 두면 악성 코드가 바이너리/설정 파일을 영구 변조하기 어려워집니다.
  • 애플리케이션이 임시 파일을 꼭 써야 한다면 /tmp 같은 최소 경로만 tmpfs로 허용합니다.
  • 실무에서는 앱이 쓰기 필요한 디렉터리를 먼저 파악한 뒤, 허용 경로를 점진적으로 좁혀야 장애 없이 적용됩니다.

예제 4) Compose에서 팀 공통 보안 기본값 선언

cat > compose.yaml <<'YAML'
services:
  web:
    image: devlab/nonroot-demo:1
    ports:
      - "8080:8080"
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
YAML

docker compose up -d
docker compose exec web sh -c 'id && ls -ld / /app /tmp'

설명:

  • 개인 습관에 맡기지 말고 Compose에 보안 기본값을 코드로 넣어두면 팀 전체 품질이 올라갑니다.
  • 코드 리뷰 시 user, cap_drop, read_only 누락 여부를 체크 항목으로 두면 회귀를 막을 수 있습니다.

자주 하는 실수

실수 1) USER를 Dockerfile 마지막에 넣지 않고 중간에만 설정

  • 원인: 빌드 중 일부 명령에서 root가 필요해 USER를 다시 root로 바꾼 뒤 복구를 깜빡함.
  • 문제: 최종 이미지가 의도치 않게 root 실행으로 배포됨.
  • 해결: Dockerfile 마지막 실행 단계에서 USER를 다시 명시하고, CI에서 docker inspect로 실행 유저를 검사합니다.

실수 2) non-root로 바꾸고도 파일 권한(chown/chmod) 정리를 안 함

  • 원인: 앱 코드/캐시/로그 디렉터리 소유권이 root로 남아 있음.
  • 문제: 런타임에서 Permission denied가 발생해 급하게 root로 롤백하는 악순환이 생김.
  • 해결: 빌드 시점에 필요한 경로 소유권을 명확히 조정하고, 쓰기 경로를 최소화합니다.

실수 3) 보안 옵션을 운영에서만 수동으로 적용

  • 원인: 개발 환경에서는 귀찮다는 이유로 생략.
  • 문제: dev/stage/prod 환경 차이가 커져 운영 배포 후에만 문제 발견.
  • 해결: 개발 단계부터 같은 보안 옵션을 기본 적용하고, 필요한 예외만 문서화합니다.

실수 4) capability를 이해하지 않고 NET_ADMIN 같은 강한 권한을 기본 허용

  • 원인: “문제 생기면 권한부터 열자”는 습관.
  • 문제: 침해 시 lateral movement 및 네트워크 조작 위험 증가.
  • 해결: 기본은 cap_drop: [ALL], 정말 필요한 capability만 근거와 함께 추가합니다.

실무 패턴

보안은 기능과 충돌하는 제약이 아니라, 장애와 침해의 비용을 줄이는 운영 전략입니다. 특히 컨테이너 환경에서는 “컨테이너는 격리되어 있으니 안전하다”는 오해를 버리고, 취약점 발생을 전제로 피해 축소 설계를 해야 합니다.

첫째, 이미지 템플릿 표준화가 중요합니다. 팀에서 사용하는 언어별 베이스 Dockerfile 템플릿에 USER, 최소 패키지 설치, 불필요 셸 제거, 캐시 정리 규칙을 고정하세요. 신규 서비스가 생겨도 보안 기본값이 자동 상속됩니다.

둘째, 보안 하드닝 체크리스트를 PR 템플릿에 포함하세요. 예: “non-root 실행인가?”, “read-only rootfs 적용 가능한가?”, “capability 최소화했는가?”, “민감 파일 권한이 적절한가?” 같은 질문이 코드 리뷰 품질을 올립니다.

셋째, 예외를 허용하되 만료일을 둔 예외 관리를 하세요. 일부 레거시 앱은 당장 read-only나 non-root 전환이 어렵습니다. 이때 예외 사유와 종료 목표일을 기록하지 않으면 영구 부채가 됩니다. 운영에서는 예외 자체보다, 예외를 통제하지 못하는 상황이 더 위험합니다.

넷째, 보안 스캔과 런타임 정책을 분리해서 생각하세요. 이미지 취약점 스캔은 “들어있는 패키지” 문제를 찾고, 런타임 최소 권한은 “침해 이후 행동 범위”를 제한합니다. 둘 중 하나만 해서는 반쪽짜리 보안입니다.

다섯째, 관측성(로그/메트릭)과 함께 적용해야 합니다. 권한을 줄인 뒤 오류가 늘어났는지, 특정 경로 권한 에러가 반복되는지 관찰할 수 있어야 안정적으로 정착합니다. 보안 설정을 적용하고 원인 분석이 어려우면 팀은 쉽게 설정을 되돌립니다.

핵심은 단순합니다. “루트로 돌면 편하다”는 단기 편의를 버리고, “non-root + 최소 권한”을 기본 운영 체계로 올리는 것입니다. 이 기준이 잡히면 이후 Kubernetes 보안 컨텍스트, 정책 엔진(OPA/Gatekeeper), 이미지 서명 같은 고급 주제로 확장하기도 훨씬 쉬워집니다.

오늘의 결론

한 줄 요약: 컨테이너 보안의 시작은 non-root 실행과 최소 권한이며, Dockerfile·Compose·CI에 기본값으로 고정해야 운영에서 실제로 지켜진다.

연습문제

  1. 현재 운영 중이거나 학습 중인 컨테이너 하나를 선택해 USER를 명시한 Dockerfile로 전환하고, 실행 UID를 검증해보세요.
  2. 동일 컨테이너에 --cap-drop ALL, --security-opt no-new-privileges:true, --read-only를 적용했을 때 실패하는 지점을 기록하고 필요한 최소 예외를 정의해보세요.
  3. 팀 표준 Compose 스니펫(사용자, read_only, tmpfs, cap_drop, security_opt)을 만들어 신규 프로젝트 템플릿에 반영해보세요.

이전 강의 정답

22강 연습문제 해설:

  • stdout JSON 로그 전환의 핵심은 “사람이 읽기 쉬운 로그”보다 “도구가 해석 가능한 로그”를 우선하는 것입니다. timestamp, level, service, message, trace_id를 고정하면 검색과 상관분석 속도가 크게 개선됩니다.
  • 로그 회전 정책은 선택이 아니라 안전장치입니다. max-size, max-file을 설정하지 않으면 단일 호스트에서 디스크 고갈로 서비스 장애가 발생할 수 있습니다. 작은 값으로 시작해 트래픽에 맞게 조정하는 방식이 안전합니다.
  • DB timeout 같은 시나리오에서 trace_id를 끝까지 전달하면 “어디서 지연이 시작됐는지”를 빠르게 좁힐 수 있습니다. 로그 설계의 목적은 저장이 아니라 장애 대응 시간 단축이라는 점을 잊지 않는 것이 중요합니다.

실습 환경/재현 정보

  • OS: macOS 15+ 또는 Ubuntu 22.04+
  • Docker 버전: Docker Engine/Desktop 25.x 이상
  • 테스트 앱: Python 3.12 slim 기반 HTTP 샘플
  • 실행 순서:
    1. app.py, Dockerfile 작성 후 이미지 빌드
    2. non-root 사용자(UID/GID 고정) 실행 확인
    3. capability 제거, no-new-privileges, read-only rootfs 적용
    4. Compose 공통 보안 기본값으로 재현
    5. 권한 에러 지점 점검 후 최소 예외만 허용
  • 재현 체크:
    • 컨테이너 프로세스가 root(uid=0)가 아닌가?
    • 불필요 capability가 모두 제거되었는가?
    • no-new-privileges가 적용되었는가?
    • 루트 파일시스템이 read-only로 동작하는가?
    • 앱이 필요한 최소 쓰기 경로(/tmp 등)만 허용받는가?
    • 팀 템플릿/리뷰 규칙으로 반복 가능하게 관리되는가?