컨테이너 이미지 하드닝 실전: Multi-stage Build, Distroless, SBOM, 서명 검증까지

컨테이너는 작을수록 안전하다
Dockerfile이 동작한다고 해서 운영에 안전한 이미지는 아닙니다. 빌드 도구, 패키지 매니저, shell, curl, 테스트 파일, 캐시, 심지어 빌드 시 사용한 token이 runtime image에 남는 경우가 많습니다. 이미지는 한 번 registry에 올라가면 여러 환경으로 복제되고, 취약점 스캐너와 감사 대상이 됩니다. 작은 실수 하나가 공급망 보안 이슈가 됩니다.
컨테이너 이미지 하드닝의 목표는 공격 표면을 줄이고, 이미지 출처를 검증 가능하게 만들고, 취약한 이미지를 배포 전에 막는 것입니다. 이를 위해 multi-stage build, distroless/runtime 최소화, non-root 실행, SBOM 생성, vulnerability scan, image signing, admission policy가 함께 필요합니다.
이 글에서는 플랫폼 팀과 애플리케이션 팀이 합의할 수 있는 컨테이너 이미지 운영 기준을 정리합니다. 단순히 이미지 크기를 줄이는 팁이 아니라, 빌드부터 배포 승인까지 이어지는 supply chain 관점에서 다룹니다.
1. Multi-stage Build로 빌드 도구를 버린다
Multi-stage build는 빌드 단계와 런타임 단계를 분리합니다. 첫 단계에서는 컴파일러, 패키지 매니저, 테스트 도구를 사용해 산출물을 만듭니다. 마지막 단계에는 실행에 필요한 바이너리와 정적 파일만 복사합니다. 이렇게 하면 이미지 크기와 취약점 수가 크게 줄어듭니다.
Node.js 애플리케이션이라면 devDependencies를 런타임에 남기지 않아야 합니다. Go나 Rust 애플리케이션이라면 static binary를 만들고 runtime image에는 바이너리만 넣을 수 있습니다. Java 애플리케이션도 JRE 기반 runtime image나 jlink로 줄인 runtime을 사용할 수 있습니다. 핵심은 "빌드에 필요한 것"과 "실행에 필요한 것"을 같은 이미지에 넣지 않는 것입니다.
주의할 점은 build secret입니다. Private package registry token이나 Git credential을 ARG로 넘기면 layer history에 남을 수 있습니다. BuildKit secret mount를 사용해 layer에 저장되지 않도록 해야 합니다. 빌드 로그에도 token이 출력되지 않게 해야 합니다. Multi-stage는 파일을 줄여주지만, secret handling을 자동으로 안전하게 해주지는 않습니다.
2. Distroless와 최소 Runtime
일반 Linux distribution 기반 이미지는 shell, package manager, coreutils 등 많은 도구를 포함합니다. 운영 중 디버깅에는 편하지만 공격자에게도 편합니다. Distroless image는 애플리케이션 실행에 필요한 최소 runtime만 포함해 공격 표면을 줄입니다. Shell이 없고 패키지 매니저가 없어 취약점과 악용 가능성이 줄어듭니다.
Distroless가 항상 정답은 아닙니다. 운영 디버깅 방식이 바뀌어야 합니다. 컨테이너 안에 들어가 curl을 실행하는 습관을 버리고, ephemeral debug container, sidecar toolbox, metrics/logs/traces 기반 진단을 준비해야 합니다. 최소 이미지를 쓰면서 관측성을 준비하지 않으면 장애 대응이 어려워집니다.
Alpine은 작지만 musl libc 차이로 일부 네이티브 모듈이나 DNS 동작에서 예기치 않은 문제가 생길 수 있습니다. Debian slim, UBI minimal, distroless 중 무엇을 쓸지는 언어 런타임과 운영 도구 호환성을 보고 정합니다. 목표는 무조건 가장 작은 이미지가 아니라, 필요한 기능만 포함하고 관리 가능한 이미지입니다.
3. Non-root와 파일 시스템 권한
컨테이너가 root로 실행되면 취약점이 악용됐을 때 피해 범위가 커집니다. Dockerfile에서 전용 사용자와 그룹을 만들고 USER를 지정해야 합니다. Kubernetes에서도 runAsNonRoot, runAsUser, readOnlyRootFilesystem, allowPrivilegeEscalation: false 같은 securityContext를 적용합니다.
Read-only root filesystem은 강력한 방어선입니다. 애플리케이션이 임시 파일을 써야 한다면 /tmp나 특정 mount만 writable로 둡니다. 로그는 stdout/stderr로 보내고, 런타임에 소스 디렉터리나 설정 파일을 수정하지 않는 구조로 바꿔야 합니다. 쓰기 가능한 영역이 많을수록 공격자가 persistence를 만들 가능성이 커집니다.
Capability도 줄여야 합니다. 대부분의 웹 애플리케이션은 Linux capability가 거의 필요 없습니다. 기본 capability를 drop하고 필요한 것만 추가하는 방식이 안전합니다. Privileged container는 특별한 인프라 컴포넌트가 아니라면 금지해야 합니다.
4. SBOM과 취약점 스캔
SBOM(Software Bill of Materials)은 이미지 안에 어떤 패키지와 라이브러리가 들어 있는지 기록한 목록입니다. Log4Shell 같은 대형 취약점이 터졌을 때, 어떤 서비스가 영향을 받는지 빠르게 찾으려면 SBOM이 필요합니다. 이미지 빌드 시점에 SBOM을 생성하고 registry artifact로 함께 보관하는 것이 좋습니다.
취약점 스캔은 빌드와 배포 단계 모두에서 수행할 수 있습니다. PR 단계에서는 심각도 높은 취약점을 빠르게 잡고, main branch 빌드에서는 전체 리포트를 생성합니다. 운영 cluster admission 단계에서는 critical 취약점이 있는 이미지나 서명되지 않은 이미지를 막을 수 있습니다. 단, 스캐너 결과에는 false positive와 base image 지연이 있으므로 예외 승인 절차가 필요합니다.
취약점 수를 줄이는 가장 좋은 방법은 패키지 수를 줄이는 것입니다. 이미지에 shell과 package manager가 없으면 관련 CVE도 사라집니다. 사용하지 않는 OS 패키지를 업데이트하는 것보다 애초에 포함하지 않는 것이 낫습니다.
5. Image Signing과 Admission Policy
이미지가 안전하게 빌드됐는지 확인하려면 서명이 필요합니다. CI가 빌드한 이미지에 서명하고, cluster는 서명된 이미지와 허용된 registry만 배포하게 합니다. 이렇게 하면 누군가 registry에 같은 tag로 악성 이미지를 밀어넣거나, 로컬에서 임의로 빌드한 이미지를 production에 배포하는 위험을 줄일 수 있습니다.
Tag보다 digest를 사용하는 것도 중요합니다. latest나 prod tag는 시간이 지나며 다른 이미지를 가리킬 수 있습니다. 배포 manifest에는 immutable digest를 기록하고, release note와 SBOM도 같은 digest를 기준으로 연결합니다. 그래야 "지금 production에서 도는 이미지가 정확히 무엇인가"에 답할 수 있습니다.
Admission controller는 정책을 코드로 강제합니다. Non-root 실행, privileged 금지, 서명 검증, 허용 registry, critical CVE 차단, required label을 배포 전에 검사합니다. 정책은 너무 늦게 적용하면 개발팀의 반발이 큽니다. 먼저 audit 모드로 위반 현황을 보여주고, 예외 절차와 수정 가이드를 제공한 뒤 enforce로 전환하는 편이 현실적입니다.
실무 체크리스트
- 빌드 도구와 devDependencies가 runtime image에 남지 않는가
- Build secret이 Docker layer, 로그, history에 남지 않는가
- Runtime image가 최소 base 또는 distroless 계열로 구성되어 있는가
- 컨테이너가 non-root로 실행되고 privilege escalation이 막혀 있는가
- Root filesystem을 read-only로 두고 필요한 mount만 writable로 열었는가
- 이미지 빌드 시 SBOM을 생성하고 digest와 함께 보관하는가
- Critical 취약점, 서명 누락, 허용되지 않은 registry를 admission에서 막는가
- 배포 manifest가 mutable tag가 아니라 image digest를 사용하는가
6. Base Image 업데이트는 자동화와 검증이 함께 필요하다
컨테이너 이미지를 한 번 하드닝했다고 끝나는 것은 아닙니다. Base image에는 계속 보안 업데이트가 나오고, 언어 런타임도 패치됩니다. 문제는 base image를 자동으로 올리기만 하면 애플리케이션 호환성이 깨질 수 있고, 반대로 수동으로만 관리하면 취약한 이미지가 오래 남는다는 점입니다. 그래서 업데이트 자동화와 검증 파이프라인이 같이 필요합니다.
Dependabot이나 Renovate로 base image digest 변경 PR을 만들고, CI에서 테스트와 취약점 스캔을 실행합니다. 단순 tag 업데이트보다 digest pinning을 유지하면서 새 digest로 갱신하는 방식이 추적에 유리합니다. PR에는 이전 digest와 새 digest, 주요 패키지 변경, 취약점 감소 여부가 함께 표시되어야 리뷰가 쉬워집니다.
운영 배포 전에는 스테이징에서 런타임 동작을 확인합니다. Distroless나 slim image로 바꾸면 shell, CA bundle, timezone data, font, native library가 없어 문제가 생길 수 있습니다. "이미지가 작다"는 이유만으로 운영에 바로 넣으면 장애 대응에서 필요한 도구가 사라질 수 있습니다. 최소 이미지를 쓰되, 디버깅 경로는 별도로 준비해야 합니다.
7. 이미지 정책은 예외 절차까지 포함한다
현실적으로 모든 취약점을 즉시 제거할 수는 없습니다. 스캐너의 false positive가 있을 수 있고, upstream 패치가 아직 나오지 않았거나, base image 교체가 큰 호환성 리스크를 만들 수도 있습니다. 그래서 admission policy에는 예외 절차가 있어야 합니다. 예외는 임시적이어야 하고, 소유자와 만료일, 위험 설명, 보완 통제가 필요합니다.
예외를 코드나 정책 저장소에 남기면 감사가 쉬워집니다. 누가 어떤 이미지 digest에 대해 어떤 CVE를 왜 허용했는지 추적할 수 있습니다. 만료일이 지난 예외는 CI나 정책 엔진이 다시 실패시켜야 합니다. 예외가 영구 허용 목록으로 변하는 순간 이미지 보안 체계는 약해집니다.
이미지 정책은 개발팀이 이해할 수 있어야 합니다. "admission denied"만 보여주면 배포자는 무엇을 고쳐야 하는지 모릅니다. 실패 메시지에는 위반 항목, 수정 방법, 예외 신청 경로가 포함되어야 합니다. 보안 정책은 막는 것에서 끝나지 않고 수정 가능한 피드백을 제공해야 운영에 정착합니다.
결론: 이미지는 실행 파일이면서 공급망 증거다
컨테이너 이미지는 애플리케이션을 담는 박스가 아닙니다. 어떤 소스에서 만들어졌고, 어떤 의존성을 포함하며, 누가 서명했고, 어떤 정책을 통과했는지 보여주는 공급망 증거입니다. Multi-stage build와 distroless는 공격 표면을 줄이고, SBOM과 signing은 추적 가능성을 만들며, admission policy는 운영 cluster의 마지막 방어선이 됩니다.
운영에서 안전한 이미지는 우연히 만들어지지 않습니다. Dockerfile 한 줄, CI secret 전달 방식, base image 선택, Kubernetes securityContext, registry 정책이 모두 이어져야 합니다. 이미지를 작고 검증 가능하게 만드는 습관은 보안뿐 아니라 배포 속도와 장애 대응에도 직접적인 이득을 줍니다.