컨테이너 학습 노트 목차

이미지 크기와 보안 — 같은 문제의 두 얼굴

이미지를 줄이는 일과 안전하게 만드는 일은 대부분 같은 작업이다. 안 쓰는 것을 빼면 크기도 줄고 공격 표면도 준다.


1. 크기가 왜 중요한가

  • 배포가 느려진다 — 노드마다 pull 해야 한다
  • 레지스트리 저장·전송 비용
  • 취약점이 늘어난다 — 안 쓰는 패키지도 CVE 를 갖는다
  • 공격 표면이 넓어진다 — 셸·컴파일러·패키지 매니저가 있으면
    • 침투 후 할 수 있는 일이 많아진다

2. 베이스 이미지 선택

  • full (debian · ubuntu) — 수백 MB. 도구가 다 있다. 디버깅이 쉽다
  • slim — 불필요한 패키지를 뺀 것. 대부분 여기서 시작한다
  • alpine — 약 5~8MB. musl libc · busybox
  • distroless — 셸도 패키지 매니저도 없다. 런타임만
  • scratch — 아무것도 없다. 정적 바이너리 전용

alpine 은 만능이 아니다

  • 장점 매우 작다

  • 주의

    • musl libc 를 쓴다 — glibc 로 빌드한 바이너리가 안 돈다
      • 증상: "not found" 인데 파일은 있다 (동적 링커를 못 찾는 것)
    • DNS 해석 동작이 glibc 와 미묘하게 다르다
    • 언어 런타임에 따라 성능 차이가 보고된 사례가 있다
      • (musl 의 메모리 할당자 특성)
  • '작다' 만 보고 고르지 않는다. 런타임과의 궁합을 확인한다

  • 자바처럼 JRE 를 얹는 경우는 slim 계열이 무난하다

distroless·scratch 의 트레이드오프

  • 얻는 것 — 셸·패키지 매니저가 없다 → 침투해도 할 수 있는 것이 거의 없다

  • 잃는 것 — docker exec 로 들어갈 수 없다

    • curl·ps 같은 진단 도구가 없다
    • 디버깅은 임시 컨테이너(kubectl debug · docker debug)로 한다
  • 운영 성숙도가 필요하다 — 디버깅 경로를 먼저 갖춘 뒤에 옮긴다

3. 크기를 줄이는 순서

  • 멀티스테이지 — 가장 효과가 크다. 빌드 도구를 통째로 뺀다
  • 베이스 교체 — full → slim → alpine/distroless
  • 레이어 정리 — 설치와 정리를 같은 RUN 에
  • .dockerignore — 빌드 컨텍스트에서 불필요한 것 제외
  • 언어별 기법 — 자바 jlink · Go 정적 빌드 · Node 프로덕션 의존만
# ③ 설치와 정리는 같은 레이어에서
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends 만으로도 수십~수백 MB 가 준다 apt 인덱스(/var/lib/apt/lists)를 안 지우면 그대로 이미지에 남는다 다음 레이어에서 지우면 소용없다 (02편 — 삭제는 표식일 뿐)

# ⑤ 자바 — 필요한 모듈만 담은 런타임을 만든다
FROM eclipse-temurin:21-jdk AS jre-build
RUN jlink --add-modules java.base,java.logging,java.sql \
          --strip-debug --no-man-pages --no-header-files --compress=2 \
          --output /javaruntime

FROM debian:12-slim
COPY --from=jre-build /javaruntime /opt/java
ENV PATH="/opt/java/bin:${PATH}"

4. 루트로 돌리지 않는다

RUN addgroup -g 10001 app && adduser -u 10001 -G app -D app
USER 10001:10001

기본은 root 다 — 명시하지 않으면 컨테이너 안 프로세스가 root 로 돈다

왜 위험한가

  • 컨테이너 탈출 취약점이 나오면 호스트에서도 root 다
  • 볼륨 마운트한 호스트 파일을 마음대로 고칠 수 있다
  • 컨테이너 안에서 패키지를 설치하거나 시스템을 바꿀 수 있다

이름이 아니라 숫자 UID 로 지정하는 편이 낫다 쿠버네티스의 runAsNonRoot 검증이 숫자 UID 를 요구한다 이름만 있으면 판정할 수 없다

1024 미만 포트는 root 만 bind 할 수 있다

  • 컨테이너 안에서 8080 을 쓰고, 밖에서 -p 80:8080 으로 매핑한다
  • 애플리케이션이 80 을 직접 열 이유가 없다

읽기 전용 루트

docker run --read-only --tmpfs /tmp myapp

루트 파일시스템을 읽기 전용으로 만든다

  • 침투해도 바이너리를 심거나 설정을 바꿀 수 없다
  • 쓰기가 필요한 곳만 tmpfs·볼륨으로 연다

애플리케이션이 어디에 쓰는지 파악해야 하므로 도입에 품이 든다 그만큼 효과는 확실하다

5. 권한을 더 좁힌다

docker run \
  --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
  --security-opt=no-new-privileges \
  --read-only \
  myapp
  • capabilityroot 권한을 쪼갠 것. 기본으로 몇 개가 켜져 있다

    • 전부 끄고 필요한 것만 켠다
  • no-new-privileges — setuid 바이너리로 권한이 올라가는 것을 막는다

  • seccomp — 허용할 시스템 콜 목록. 도커는 기본 프로파일을 적용한다

    • --privileged 를 쓰면 이 모든 방어가 통째로 꺼진다

--privileged 는 사실상 호스트 권한이다

--privileged 는 '조금 더 많은 권한' 이 아니다

  • 모든 capability
  • seccomp·AppArmor 해제
  • 호스트 장치 접근

컨테이너 탈출이 어렵지 않아진다 필요한 이유를 설명할 수 없다면 쓰지 않는다 필요하면 그 능력 하나만 --cap-add 로 준다

도커 소켓 마운트는 곧 호스트 장악이다

-v /var/run/docker.sock:/var/run/docker.sock

이 한 줄이면 그 컨테이너는 호스트에 아무 컨테이너나 띄울 수 있다

  • --privileged 로 호스트 루트를 마운트한 컨테이너를 만들면 끝이다

CI 러너·모니터링 도구에서 흔히 보이는 설정이다 대안 — 소켓 프록시로 필요한 API 만 노출 · rootless 모드 · DinD 대신 Kaniko/Buildah

6. 비밀을 이미지에 넣지 않는다

이미지에 남는 경로

  • COPY 로 넣은 설정 파일 (.env · 키 파일)

  • ARG 로 넘긴 값 → docker history 에 명령이 남는다

  • ENV → docker inspect 로 그대로 보인다

  • 앞 레이어에 넣고 뒤에서 지운 파일 → 레이어에 남아 있다

  • 빌드 시점 비밀 — RUN --mount=type=secret (03편)

  • 런타임 비밀 — 환경변수 주입 · 시크릿 매니저 · 파일 마운트

.dockerignore 에 .env · *.pem · .git 을 넣는 것이 최소 방어다

# 이미지에 무엇이 들었는지 확인
docker history --no-trunc myapp:1.0
docker save myapp:1.0 | tar -tv | head -50

7. 취약점 스캔

docker scout cves myapp:1.0
trivy image myapp:1.0
grype myapp:1.0

스캐너는 이미지 안 패키지 목록을 뽑아 CVE 데이터베이스와 대조한다

결과를 다루는 법

  • 전부 0 으로 만들려 하지 않는다 — 현실적으로 불가능하다
  • Critical/High 부터, 그중 '실제로 쓰는 경로' 를 먼저 본다
  • 대부분은 베이스 이미지를 최신으로 올리면 함께 해결된다
  • 안 쓰는 패키지를 빼는 것이 가장 확실한 해결이다 (그래서 크기와 같은 문제다)

CI 에 넣되 처음부터 빌드를 막지 않는다

  • 기준선을 정하고 '새로 늘어난 것' 만 실패시키는 편이 정착이 쉽다

SBOM

이미지에 무엇이 들었는지의 목록이다

docker buildx build --sbom=true --provenance=true --push .

Log4Shell 때 드러난 문제가 "우리가 무엇을 쓰는지 목록이 없다" 였다 SBOM 이 있으면 새 CVE 가 나왔을 때 영향 범위를 검색으로 찾는다 공급망 보안의 출발점이다

8. 기준선 — CIS Benchmark

CIS Docker Benchmark · CIS Kubernetes Benchmark 비상업적 용도로 PDF 를 무료로 받을 수 있다 (간단한 정보 입력 필요) 호스트·데몬·이미지·런타임 항목별 점검 기준을 제공한다

자동 점검
  Docker Bench for Security   호스트·데몬 설정
  kube-bench                  쿠버네티스 클러스터

"무엇을 점검해야 하는지" 를 처음부터 만들 필요가 없다 기준선으로 시작해 조직에 맞게 예외를 정리하는 순서가 효율적이다


한눈에 정리

  • 크기와 보안 — 같은 문제다. 안 쓰는 것을 빼면 둘 다 좋아진다
  • 베이스 — slim 이 무난. alpine 은 musl 궁합 확인. distroless 는 디버깅 경로 먼저
  • 줄이는 순서멀티스테이지 → 베이스 교체 → 레이어 정리 → dockerignore → 언어별
  • USER — 기본이 root 다. 숫자 UID 로 지정한다 (runAsNonRoot 검증)
  • 읽기 전용 — --read-only + tmpfs. 침투 후 할 수 있는 일을 없앤다
  • 권한 — cap-drop=ALL 후 필요한 것만. no-new-privileges
  • --privileged — 방어가 통째로 꺼진다. 이유를 설명 못 하면 쓰지 않는다
  • docker.sock — 마운트하면 곧 호스트 장악이다. 프록시·rootless·Kaniko
  • 비밀 — history·inspect·레이어에 남는다. 빌드는 시크릿 마운트로
  • 스캔 — Critical/High 부터. 대부분 베이스 업데이트로 해결
  • SBOM — 새 CVE 가 나왔을 때 영향 범위를 찾는 근거
  • CIS — 무료 기준선 + Docker Bench · kube-bench 로 자동 점검

출처 — Docker Docs: "Best practices for writing Dockerfiles"(USER·베이스 선택) · "Build secrets" · "Runtime privilege and Linux capabilities" · "Docker Scout" · CIS Docker/Kubernetes Benchmarks · CNCF "Kubernetes hardening with kube-bench"

실행과 운영 — PID 1·볼륨·네트워크·로그트러블슈팅 — 증상에서 원인으로