Alpine도 불안하다: Distroless로 도커 이미지 CVE 98% 줄인 실전 기록

월요일 아침, 출근하자마자 CI/CD 파이프라인의 Docker 빌드 단계가 실패했다는 알림을 받았습니다. 원인은 보안 스캐너(Trivy)가 뱉어낸 'Critical' 등급의 CVE(보안 취약점) 경고였습니다. 문제는 해당 취약점이 우리가 작성한 애플리케이션 코드가 아닌, 베이스 이미지로 사용하던 node:lts-alpine의 시스템 라이브러리(OpenSSL)에서 발견되었다는 점입니다. 프로덕션 배포가 막힌 상황에서 단순히 OS 패치를 기다리거나 apt-get upgrade를 실행하는 것은 근본적인 해결책이 아니었습니다. 이 글은 공격 표면(Attack Surface)을 물리적으로 제거하여 Docker 보안 수준을 극단적으로 끌어올린 Distroless 도입기입니다.

OS 패키지의 딜레마와 공격 표면 분석

당시 우리 팀은 Kubernetes 환경에서 약 50여 개의 마이크로서비스를 운영하고 있었습니다. 초기에는 컨테이너 최적화를 위해 대부분의 이미지를 Debian Slim이나 Alpine Linux 기반으로 구축했습니다. Alpine은 확실히 가볍지만(약 5MB), 운영하다 보니 몇 가지 치명적인 한계에 부딪혔습니다.

첫째, musl-libc 호환성 문제입니다. Alpine은 표준 glibc 대신 가벼운 musl을 사용하는데, 특정 Python 라이브러리나 C++ 기반 네이티브 모듈을 사용할 때 성능 저하가 발생하거나 아예 빌드가 되지 않는 세그멘테이션 오류(Segmentation Fault)를 자주 겪었습니다. 둘째, 패키지 매니저(apk)와 쉘(/bin/sh)이 존재한다는 것 자체가 보안 리스크였습니다. 해커가 만약 애플리케이션의 RCE(Remote Code Execution) 취약점을 뚫고 들어왔을 때, 쉘이 있다면 추가적인 악성 코드를 다운로드하거나 내부망을 정찰하기가 훨씬 수월해집니다.

Critical Risk: 컨테이너 내부에 curl, wget, sh가 설치되어 있다면, 공격자는 단 한 줄의 명령어로 리버스 쉘(Reverse Shell)을 열 수 있습니다.

우리는 DevSecOps 파이프라인을 강화하기 위해 "필요하지 않은 것은 아예 포함하지 않는다"는 원칙을 세웠습니다. OS가 업데이트될 때마다 발생하는 수백 개의 CVE 알림에 피로감을 느끼던 차에, Google이 내부적으로 사용한다는 개념을 접하게 되었습니다.

잘못된 접근: 수동으로 패키지 제거하기

처음에는 무식한 방법으로 접근했습니다. Debian 이미지를 기반으로 하되, Dockerfile의 마지막 단계에서 rm -rf /bin/* 같은 명령어로 쉘과 유틸리티를 삭제하려 했습니다. 하지만 이 방식은 매우 위험했습니다. 애플리케이션 구동에 필수적인 라이브러리(shared object files)가 무엇인지 정확히 파악하기 어려워, 배포 후 런타임에 "File not found" 에러를 뿜으며 컨테이너가 죽는 경우가 허다했습니다. 의존성 지옥(Dependency Hell)을 해결하려다 운영 지옥에 빠진 꼴이었습니다.

해결책: Google Distroless 이미지 도입

이 문제를 해결하기 위해 도입한 것이 바로 Distroless 이미지입니다. Google Container Tools에서 관리하는 이 이미지는 애플리케이션 실행에 필요한 최소한의 런타임만 포함하고, 패키지 매니저, 쉘, 기타 OS 유틸리티를 모두 제거한 상태로 제공됩니다.

아래는 기존의 Alpine 기반 Dockerfile을 이미지 경량화 및 보안 강화를 위해 Distroless 기반의 멀티 스테이지 빌드(Multi-stage Build)로 리팩토링한 코드입니다.

# [Stage 1] 빌드 단계: 필요한 도구가 모두 있는 환경
# node:18-bookworm을 사용하여 glibc 호환성 확보
FROM node:18-bookworm AS builder

# 작업 디렉토리 설정
WORKDIR /usr/src/app

# 의존성 설치 (CI 환경에서는 ci 명령어 사용 권장)
COPY package*.json ./
RUN npm ci --only=production

# 소스 코드 복사 및 빌드 (TypeScript 등 사용 시)
COPY . .
RUN npm run build

# --------------------------------------------------------

# [Stage 2] 실행 단계: Distroless로 갈아타기
# gcr.io/distroless/nodejs20-debian12 이미지를 사용
FROM gcr.io/distroless/nodejs20-debian12

WORKDIR /usr/src/app

# 빌드 스테이지에서 생성된 필수 아티팩트만 복사
# node_modules와 빌드된 dist 폴더만 가져옵니다.
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist

# 중요: Distroless에는 'sh'가 없으므로 CMD는 배열 형태("exec form")로 작성해야 함
# npm start 같은 쉘 스크립트 기반 명령어는 작동하지 않을 수 있음
CMD ["dist/main.js"]

위 코드의 핵심은 COPY --from=builder입니다. 빌드 도구(gcc, python, npm 등)가 포함된 무거운 이미지는 버리고, 실행에 필요한 파일만 골라 담아 최종 이미지를 생성합니다. 특히 CMD 명령어를 작성할 때 주의해야 합니다. 쉘이 없으므로 CMD npm start 처럼 작성하면 내부적으로 /bin/sh -c를 호출하려다 실패합니다. 반드시 실행 파일 경로를 직접 지정하는 Exec Form을 사용해야 합니다.

성능 및 보안 벤치마크 분석

Distroless 적용 전후를 비교했을 때, 단순한 수치 이상의 운영상 이점을 확인할 수 있었습니다. 다음은 동일한 Node.js 애플리케이션을 기준으로 측정한 결과입니다.

지표 (Metric) Node:18 (Standard) Node:18-alpine Distroless (Optimized)
이미지 크기 980 MB 180 MB 65 MB
CVE (High/Critical) 124개 5개 0개
쉘 접근 (Shell) 가능 (/bin/bash) 가능 (/bin/sh) 불가능 (None)
glibc 호환성 완벽 불안정 (musl) 완벽 (Debian 기반)

결과적으로 이미지 크기를 약 93% 감소시켰을 뿐만 아니라, CVE 취약점 스캔에서 'Clean' 상태를 유지하게 되었습니다. 특히 보안 감사(Audit) 때마다 지적받던 "불필요한 바이너리 존재" 항목을 원천적으로 해결할 수 있었습니다. 이미지 경량화는 단순히 스토리지 비용 절감이 아니라, 공격자가 활용할 수 있는 도구를 제거하는 보안 전략입니다.

Google Distroless 예제 코드 확인하기

주의사항 및 디버깅의 어려움 (Edge Cases)

Distroless가 만능은 아닙니다. 도입 전 반드시 고려해야 할 부작용(Side Effects)이 있습니다. 가장 큰 장벽은 디버깅의 어려움입니다. 쉘이 없기 때문에 kubectl exec -it my-pod -- /bin/bash 명령어가 작동하지 않습니다. 컨테이너 내부를 들여다볼 수 없다는 것은 장애 발생 시 원인 분석을 어렵게 만듭니다.

Tip: Kubernetes v1.23 이상을 사용 중이라면 kubectl debug 기능을 활용하세요. 실행 중인 파드에 디버깅용 컨테이너(Ephemeral Container)를 사이드카 형태로 붙여 프로세스를 검사할 수 있습니다.

또한, Python이나 Java 애플리케이션의 경우 런타임 환경 변수 설정이나 경로 설정이 일반적인 OS와 미묘하게 다를 수 있습니다. 로컬 개발 환경에서는 일반 이미지를 사용하고, 스테이징 및 프로덕션 환경의 CI/CD 파이프라인에서만 Distroless로 빌드하는 DevSecOps 전략을 추천합니다.

Result: 도입 후 6개월간 컨테이너 침해 사고 0건, 보안 스캔 통과율 100%를 달성했습니다.

결론

Docker 컨테이너의 보안은 방화벽을 세우는 것보다 이미지 자체를 견고하게 만드는 것에서 시작합니다. Distroless 이미지는 편의성을 조금 희생하는 대신, 보안성과 효율성이라는 두 마리 토끼를 잡을 수 있는 가장 확실한 방법입니다. 지금 운영 중인 서비스가 중요 보안 데이터를 다루거나, 규제 준수(Compliance)가 중요하다면 다음 배포 때는 쉘 없는 이미지를 고려해 보시기 바랍니다.

Post a Comment