ECS/EKS 환경에서 컨테이너 이미지 선택이 운영에 미치는 영향: Alpine vs Distroless

이군·2026년 4월 12일

Alpine 기반 이미지에서 Distroless로 전환하면 보안은 강화되지만, DNS 동작 차이부터 디버깅 체계까지 예상치 못한 곳에서 문제가 터집니다. ECS와 EKS 각각의 환경에서 어떤 영향이 있는지 실무 관점에서 정리했습니다.


왜 이미지를 바꾸려 하는가

컨테이너 보안의 기본 원칙은 공격 표면(Attack Surface)을 최소화하는 것입니다. Alpine Linux는 musl libc 기반의 경량 이미지로 오랫동안 사실상의 표준처럼 사용되어 왔지만, 쉘(/bin/sh)과 패키지 매니저(apk)가 포함되어 있어 공격자가 RCE(Remote Code Execution)에 성공했을 때 후속 행동(lateral movement, 툴 다운로드 등)이 가능하다는 한계가 있습니다.

Distroless 이미지(Google의 gcr.io/distroless/* 또는 Chainguard Images)는 쉘, 패키지 매니저, 불필요한 바이너리를 모두 제거하여 이 문제를 근본적으로 해결합니다. 하지만 “쉘이 없다”는 한 가지 변화가 운영 전반에 걸쳐 연쇄적인 영향을 만들어냅니다.

이 글에서는 Node.js와 Java(JVM) 워크로드를 기준으로, ECS와 EKS 환경에서 Alpine → Distroless 전환 시 발생하는 영향을 계층별로 분석합니다.


1. 런타임 호환성: JNI와 네이티브 라이브러리

Alpine(musl)에서의 문제

Alpine은 glibc 대신 musl libc를 사용합니다. 대부분의 JVM 네이티브 라이브러리(.so 파일)는 glibc 기준으로 빌드·배포되기 때문에, Alpine 환경에서는 다음과 같은 패키지에서 링킹 실패가 발생할 수 있습니다.

  • Netty epoll transport (libnetty_transport_native_epoll_x86_64.so)
  • gRPC netty-tcnative (BoringSSL 네이티브 바인딩)
  • RocksDB JNI
  • Snappy / Zstd 네이티브 압축 라이브러리
  • Datadog continuous profiler의 libdatadog

이를 해결하기 위해 gcompat 패키지를 설치하거나, musl 전용 빌드를 찾아야 하는 번거로움이 있었습니다.

Distroless(glibc)에서의 개선

Distroless 이미지는 Debian 기반이므로 glibc가 기본 탑재되어 있습니다. 위에 열거한 네이티브 라이브러리들이 별도 조치 없이 정상 로드됩니다. Alpine에서 Distroless로의 전환은 JNI 호환성 측면에서 개선 방향입니다.

Node.js의 경우

dd-trace-js는 순수 JavaScript 모듈이므로 libc 종류와 무관하게 동작합니다. 다만 sharp, bcrypt 같은 native addon을 사용하는 경우, Alpine용 prebuilt binary가 아닌 glibc용으로 전환되므로 빌드 파이프라인에서 npm rebuild 또는 멀티스테이지 빌드 조정이 필요합니다.


2. DNS 동작 차이: 가장 위험한 잠복 이슈

Alpine → Distroless 전환에서 런타임 에러보다 더 위험한 것이 DNS resolver 동작 차이입니다. musl과 glibc의 DNS 구현이 근본적으로 다르기 때문입니다.

2-1. 동시 A/AAAA 쿼리와 간헐적 5초 타임아웃

이 이슈는 EKS 환경에서 특히 치명적입니다.

glibc는 도메인을 조회할 때 A(IPv4) 레코드와 AAAA(IPv6) 레코드를 동일한 소켓에서 병렬로 전송합니다. 이것은 glibc 2.9부터 도입된 의도된 동작이며, 현재까지 변경 계획이 없습니다. 쿠버네티스의 CoreDNS로 향하는 이 두 UDP 패킷이 동일한 source port와 destination을 갖게 되면, Linux conntrack 모듈에서 race condition이 발생하여 패킷 하나가 드랍됩니다. 드랍된 쿼리는 응답을 받지 못하고 5초 DNS timeout 후 재시도됩니다.

musl은 A와 AAAA 쿼리를 순차적으로 전송하기 때문에 이 conntrack race condition을 우연히 회피하고 있었습니다.

커널 레벨 수정은 되었지만, 완전하지 않습니다

이 문제에 대한 Linux 커널 수정이 두 차례 있었습니다.

  • 커널 5.0 / 4.19.29+: nf_conntrack: resolve clash for matching conntracks — NAT가 없는 환경에서 동일한 conntrack 엔트리 간 충돌을 감지하면 드랍하지 않고 병합하도록 수정
  • 동일 시점 추가 패치: nf_nat: skip nat clash resolution for same-origin entries — NAT 환경(kube-proxy iptables 모드 등)에서도 같은 origin의 중복 엔트리에 대해 새 소스 포트를 할당하지 않도록 수정

이 커널 패치 이후 문제가 크게 완화되었으나, 완전히 해소되지는 않았습니다. 그 이유는 다음과 같습니다.

  1. glibc 자체 동작은 불변: glibc의 getaddrinfo()는 여전히 동일 소켓에서 A/AAAA를 병렬 전송합니다. 커널이 아무리 개선되어도 glibc가 이 동작을 바꾸지 않는 한 race의 가능성 자체는 존재합니다
  2. 환경 조합에 따른 재현: kube-proxy iptables 모드 + 특정 CNI 플러그인 + 높은 DNS QPS 조합에서 2025년에도 간헐적 DNS 실패가 보고되고 있습니다
  3. glibc Transaction ID 충돌: glibc 2.33 이전 버전에서는 A와 AAAA 쿼리에 동일한 DNS Transaction ID가 할당되어, conntrack과 무관하게 응답 매칭이 실패하는 별도의 5초 타임아웃 이슈가 존재했습니다. 이 문제는 glibc 2.33에서 수정되었으나, Distroless 베이스 이미지의 Debian 버전에 따라 영향을 받을 수 있으므로 glibc 버전 확인이 필요합니다

결과적으로 Alpine에서는 한 번도 겪지 못했던 간헐적 5초 지연이 Distroless 전환 후 갑자기 나타날 수 있습니다. 최신 커널(5.0+)과 최신 glibc(2.33+)를 사용하면 발생 빈도가 크게 줄지만, kube-proxy ipvs 모드 전환이나 NodeLocal DNSCache 없이 “완전히 안전하다”고 단정하기는 어렵습니다. 서비스 간 호출이 많은 마이크로서비스 환경에서는 이 5초가 cascading failure로 이어질 수 있으므로, 방어적 설정을 반드시 적용해야 합니다.

권장 해결 방법:

# EKS Pod spec에 dnsConfig 추가
spec:
  dnsConfig:
    options:
      - name: single-request-reopen  # A/AAAA 쿼리를 서로 다른 소켓에서 전송
      - name: ndots
        value: "2"

추가로 다음 조치를 병행하는 것을 권장합니다.

  • NodeLocal DNSCache DaemonSet 배포: 노드 레벨에서 DNS를 캐싱하여 CoreDNS로 향하는 UDP 트래픽 자체를 줄이고, conntrack 엔트리 생성을 회피합니다
  • kube-proxy ipvs 모드 전환: iptables DNAT에서 발생하는 NAT conntrack race를 근본적으로 제거합니다
  • 노드 커널 버전 확인: EKS AMI의 커널이 5.0 이상인지 확인합니다 (최신 EKS AMI는 5.10+ 커널을 사용하므로 대부분 해당)

2-2. ndots와 search domain 처리 차이

쿠버네티스 Pod의 기본 resolv.confndots:5로 설정됩니다. 도메인에 점이 5개 미만이면 search domain을 먼저 붙여서 질의하라는 의미입니다.

api.external.com을 조회하는 경우(점 2개 < ndots 5):

1) api.external.com.default.svc.cluster.local  → NXDOMAIN
2) api.external.com.svc.cluster.local           → NXDOMAIN
3) api.external.com.cluster.local                → NXDOMAIN
4) api.external.com.                             → 성공

glibc는 이 순서를 충실히 따르므로, 외부 도메인 호출 시 불필요한 DNS 쿼리가 3~4회 추가 발생합니다. musl의 일부 버전은 이 동작이 불일치하거나 search domain 시도를 생략하는 경우가 있었습니다. Alpine에서 빠르게 resolve 되던 외부 도메인이 Distroless에서 눈에 띄게 느려질 수 있습니다.

ndots 값을 2로 낮추면 외부 도메인(점 2개 이상)은 바로 FQDN으로 질의하게 되어 불필요한 쿼리를 줄일 수 있습니다. 단, 클러스터 내부 서비스 호출 시 FQDN(service.namespace.svc.cluster.local)으로 명시해야 합니다.

2-3. TCP fallback 동작

DNS 응답이 512바이트를 초과하면 truncated 플래그와 함께 TCP 재시도가 필요합니다. glibc는 TCP fallback을 충실히 수행하지만, musl 일부 버전은 truncated 응답을 그대로 사용하거나 TCP 재시도가 불완전했습니다. 서비스 메시 환경에서 SRV 레코드가 큰 경우, Alpine에서는 일부 엔드포인트만 보이던 것이 Distroless에서 정상적으로 전체가 조회될 수 있습니다.

ECS vs EKS에서의 차이

항목ECS (Fargate/EC2)EKS
DNS resolverVPC DNS(AmazonProvidedDNS) 사용CoreDNS Pod 경유
conntrack 이슈해당 없음 (CoreDNS 미사용)커널 5.0+에서 완화되었으나 iptables 모드에서 여전히 발생 가능
ndots 기본값OS 기본(보통 1)5 (kubelet 설정)
search domain없거나 최소4개 자동 추가
영향 수준상대적으로 낮음높음 — 반드시 사전 테스트 필요

ECS 환경에서는 CoreDNS를 거치지 않으므로 conntrack race condition 이슈가 발생하지 않고, ndots도 낮게 설정되어 있어 DNS 관련 영향이 상대적으로 적습니다. EKS가 전환 시 DNS 이슈의 주요 타격 대상입니다. 최신 EKS AMI(커널 5.10+)를 사용하더라도, kube-proxy가 iptables 모드이고 DNS QPS가 높은 환경에서는 single-request-reopen + NodeLocal DNSCache 조합을 방어적으로 적용하는 것이 안전합니다.


3. 디버깅과 트러블슈팅

EKS 환경

Alpine에서는 kubectl exec -it <pod> -- /bin/sh로 컨테이너에 진입하여 curl, nslookup, cat /etc/resolv.conf 등으로 즉석 디버깅이 가능했습니다. Distroless에서는 이 모든 것이 불가능합니다.

대안:

# Ephemeral container를 사용한 디버깅 (Kubernetes 1.23+)
kubectl debug -it <pod-name> \
  --image=nicolaka/netshoot \
  --target=<container-name> \
  -- /bin/bash

Ephemeral container는 대상 Pod의 네트워크 네임스페이스를 공유하므로, netshoot 같은 네트워크 디버깅 이미지를 붙여서 동일한 네트워크 환경에서 트러블슈팅할 수 있습니다. EKS 1.23 이상에서 기본 활성화되어 있습니다.

ECS 환경

ECS는 ECS Exec(aws ecs execute-command)을 통해 컨테이너에 접근하는데, 이 기능이 내부적으로 SSM Agent + /bin/sh에 의존합니다. Distroless 이미지에서는 ECS Exec이 동작하지 않습니다.

이것은 ECS 운영에서 매우 큰 제약입니다. 대안으로는 다음을 고려해야 합니다.

  • 사이드카 컨테이너: 디버그용 사이드카를 태스크 정의에 포함하되, 평소에는 리소스를 최소화하고 필요 시 활성화
  • 로그 기반 디버깅 전면 전환: 애플리케이션 로그와 Datadog APM/트레이스에 전적으로 의존하는 옵저버빌리티 체계 구축
  • X-Ray / Datadog Network Performance Monitoring: 네트워크 레벨 이슈는 에이전트 기반 모니터링으로 대체

4. 이미지 빌드와 CI/CD 파이프라인

멀티스테이지 빌드 필수

Distroless에는 패키지 매니저가 없으므로, 빌드 스테이지에서 모든 의존성을 설치하고 런타임 스테이지로 복사하는 멀티스테이지 빌드가 필수입니다.

# Java 예시
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar

# 타임존 데이터 준비
RUN cp /usr/share/zoneinfo/Asia/Seoul /tmp/Seoul

FROM gcr.io/distroless/java17-debian12:nonroot
COPY --from=builder /app/build/libs/app.jar /app.jar
COPY --from=builder /tmp/Seoul /etc/localtime
COPY dd-java-agent.jar /dd-java-agent.jar
ENV TZ=Asia/Seoul
ENTRYPOINT ["java", "-javaagent:/dd-java-agent.jar", "-jar", "/app.jar"]
# Node.js 예시
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

FROM gcr.io/distroless/nodejs20-debian12:nonroot
COPY --from=builder /app /app
WORKDIR /app
CMD ["server.js"]

사설 CA 인증서 처리

내부 PKI를 사용하는 환경에서는 빌드 시 인증서를 주입해야 합니다.

COPY --from=builder /usr/local/share/ca-certificates/internal-ca.crt \
     /etc/ssl/certs/internal-ca.crt

인증서 갱신 시마다 이미지 리빌드가 필요하므로, CI/CD 파이프라인에서 인증서 갱신을 트리거로 자동 빌드하는 체계를 갖춰야 합니다.

이미지 크기 비교

이미지대략적인 크기
node:20-alpine~180MB
gcr.io/distroless/nodejs20-debian12~220MB
eclipse-temurin:17-jre-alpine~100MB
gcr.io/distroless/java17-debian12~230MB

Distroless가 보안성은 높지만, Debian 기반이라 이미지 크기는 오히려 커집니다. 경량화와 보안을 동시에 원한다면 Chainguard Images(cgr.dev/chainguard/jre, cgr.dev/chainguard/node)가 대안이 될 수 있습니다.


5. 보안 취약점 스캐닝

CVE 탐지 방식의 변화

Alpine에서는 Trivy, Grype 같은 스캐너가 apk 패키지 DB를 읽어서 OS 레벨 CVE를 탐지합니다. Distroless에는 패키지 매니저가 없으므로 전통적인 방식의 스캐닝이 제한됩니다.

최신 스캐너들은 Distroless 이미지의 /var/lib/dpkg/status.d/ 디렉토리를 파싱하여 설치된 패키지 정보를 추출할 수 있지만, 스캐너 버전에 따라 탐지율이 다를 수 있습니다. CI/CD 파이프라인에서 사용 중인 스캐너가 Distroless를 정상 지원하는지 반드시 확인해야 합니다.

패치 적용 방식

패키지 매니저로 개별 패키지를 업데이트할 수 없으므로, 베이스 이미지 자체를 새 버전으로 교체하고 전체 리빌드하는 것이 유일한 패치 방법입니다. 베이스 이미지 업데이트를 자동으로 감지하고 리빌드하는 파이프라인(예: Renovate, Dependabot)이 필수적입니다.


6. ECS vs EKS 전환 영향 요약

영향 항목ECSEKS
DNS 동작 변화낮음 (VPC DNS 직접 사용)높음 (CoreDNS + conntrack 이슈)
디버깅 제약높음 (ECS Exec 불가)중간 (ephemeral container 가능)
JNI/네이티브 호환성개선됨개선됨
이미지 빌드 복잡도증가 (동일)증가 (동일)
타임존/로케일수동 처리 필요 (동일)수동 처리 필요 (동일)
CA 인증서빌드 시 주입 필요 (동일)빌드 시 주입 필요 (동일)

전환 체크리스트

전환을 검토하고 있다면, 아래 항목을 순서대로 점검하는 것을 권장합니다.

  1. JNI 의존성 감사: 사용 중인 Java 라이브러리 중 네이티브 .so를 로드하는 것을 목록화하고, glibc 환경에서의 정상 동작을 확인합니다
  2. DNS 부하 테스트 (EKS): 스테이징 환경에서 Distroless Pod를 배포하고, 외부 도메인 resolve latency의 p99를 측정합니다. single-request-reopen 옵션 적용 전후를 비교합니다. 커널 5.0+ 패치로 conntrack race가 완화되었으나 iptables 모드에서는 여전히 발생 가능하므로, 커널 버전과 무관하게 방어적 설정을 권장합니다
  3. ECS Exec 대체 체계 구축 (ECS): 로그 기반 디버깅 + 사이드카 전략을 먼저 구축하고 나서 전환합니다
  4. Datadog 트레이서 전체 기능 테스트: 기본 APM 트레이싱뿐 아니라, continuous profiler, ASM(Application Security Monitoring) 등 네이티브 의존 기능을 모두 검증합니다
  5. 타임존·인증서 빌드 파이프라인 반영: Asia/Seoul tzdata 복사, 사설 CA 인증서 주입을 Dockerfile에 반영합니다
  6. CVE 스캐너 호환성 확인: 사용 중인 스캐너가 Distroless 이미지를 정상 파싱하는지 확인합니다
  7. 점진적 전환: 트래픽이 낮은 비핵심 서비스부터 적용하고, 안정성이 확인된 후 핵심 서비스로 확대합니다

마무리

Alpine에서 Distroless로의 전환은 단순한 베이스 이미지 교체가 아닙니다. libc 교체에서 오는 DNS 동작 차이, 네이티브 라이브러리 호환성 변화, 그리고 쉘 제거로 인한 운영 체계 전면 재설계가 수반됩니다.

보안 이점은 분명합니다. 쉘이 없으면 RCE 이후 공격자의 행동 반경이 극도로 제한되고, 불필요한 패키지가 없으니 CVE 자체가 줄어듭니다. 하지만 그 이점을 실현하려면 옵저버빌리티 체계, CI/CD 파이프라인, 디버깅 전략이 먼저 성숙해 있어야 합니다.

EKS 환경이라면 DNS 이슈를, ECS 환경이라면 디버깅 접근성을 최우선으로 검증하고, 비핵심 워크로드부터 단계적으로 전환하는 것이 현실적인 접근입니다.

profile
이군의 보안, 그리고 생각을 다룹니다.

0개의 댓글