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 전환 시 발생하는 영향을 계층별로 분석합니다.
Alpine은 glibc 대신 musl libc를 사용합니다. 대부분의 JVM 네이티브 라이브러리(.so 파일)는 glibc 기준으로 빌드·배포되기 때문에, Alpine 환경에서는 다음과 같은 패키지에서 링킹 실패가 발생할 수 있습니다.
libnetty_transport_native_epoll_x86_64.so)이를 해결하기 위해 gcompat 패키지를 설치하거나, musl 전용 빌드를 찾아야 하는 번거로움이 있었습니다.
Distroless 이미지는 Debian 기반이므로 glibc가 기본 탑재되어 있습니다. 위에 열거한 네이티브 라이브러리들이 별도 조치 없이 정상 로드됩니다. Alpine에서 Distroless로의 전환은 JNI 호환성 측면에서 개선 방향입니다.
dd-trace-js는 순수 JavaScript 모듈이므로 libc 종류와 무관하게 동작합니다. 다만 sharp, bcrypt 같은 native addon을 사용하는 경우, Alpine용 prebuilt binary가 아닌 glibc용으로 전환되므로 빌드 파이프라인에서 npm rebuild 또는 멀티스테이지 빌드 조정이 필요합니다.
Alpine → Distroless 전환에서 런타임 에러보다 더 위험한 것이 DNS resolver 동작 차이입니다. musl과 glibc의 DNS 구현이 근본적으로 다르기 때문입니다.
이 이슈는 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 커널 수정이 두 차례 있었습니다.
nf_conntrack: resolve clash for matching conntracks — NAT가 없는 환경에서 동일한 conntrack 엔트리 간 충돌을 감지하면 드랍하지 않고 병합하도록 수정nf_nat: skip nat clash resolution for same-origin entries — NAT 환경(kube-proxy iptables 모드 등)에서도 같은 origin의 중복 엔트리에 대해 새 소스 포트를 할당하지 않도록 수정이 커널 패치 이후 문제가 크게 완화되었으나, 완전히 해소되지는 않았습니다. 그 이유는 다음과 같습니다.
getaddrinfo()는 여전히 동일 소켓에서 A/AAAA를 병렬 전송합니다. 커널이 아무리 개선되어도 glibc가 이 동작을 바꾸지 않는 한 race의 가능성 자체는 존재합니다결과적으로 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"
추가로 다음 조치를 병행하는 것을 권장합니다.
쿠버네티스 Pod의 기본 resolv.conf는 ndots: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)으로 명시해야 합니다.
DNS 응답이 512바이트를 초과하면 truncated 플래그와 함께 TCP 재시도가 필요합니다. glibc는 TCP fallback을 충실히 수행하지만, musl 일부 버전은 truncated 응답을 그대로 사용하거나 TCP 재시도가 불완전했습니다. 서비스 메시 환경에서 SRV 레코드가 큰 경우, Alpine에서는 일부 엔드포인트만 보이던 것이 Distroless에서 정상적으로 전체가 조회될 수 있습니다.
| 항목 | ECS (Fargate/EC2) | EKS |
|---|---|---|
| DNS resolver | VPC 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 조합을 방어적으로 적용하는 것이 안전합니다.
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 Exec(aws ecs execute-command)을 통해 컨테이너에 접근하는데, 이 기능이 내부적으로 SSM Agent + /bin/sh에 의존합니다. Distroless 이미지에서는 ECS Exec이 동작하지 않습니다.
이것은 ECS 운영에서 매우 큰 제약입니다. 대안으로는 다음을 고려해야 합니다.
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"]
내부 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)가 대안이 될 수 있습니다.
Alpine에서는 Trivy, Grype 같은 스캐너가 apk 패키지 DB를 읽어서 OS 레벨 CVE를 탐지합니다. Distroless에는 패키지 매니저가 없으므로 전통적인 방식의 스캐닝이 제한됩니다.
최신 스캐너들은 Distroless 이미지의 /var/lib/dpkg/status.d/ 디렉토리를 파싱하여 설치된 패키지 정보를 추출할 수 있지만, 스캐너 버전에 따라 탐지율이 다를 수 있습니다. CI/CD 파이프라인에서 사용 중인 스캐너가 Distroless를 정상 지원하는지 반드시 확인해야 합니다.
패키지 매니저로 개별 패키지를 업데이트할 수 없으므로, 베이스 이미지 자체를 새 버전으로 교체하고 전체 리빌드하는 것이 유일한 패치 방법입니다. 베이스 이미지 업데이트를 자동으로 감지하고 리빌드하는 파이프라인(예: Renovate, Dependabot)이 필수적입니다.
| 영향 항목 | ECS | EKS |
|---|---|---|
| DNS 동작 변화 | 낮음 (VPC DNS 직접 사용) | 높음 (CoreDNS + conntrack 이슈) |
| 디버깅 제약 | 높음 (ECS Exec 불가) | 중간 (ephemeral container 가능) |
| JNI/네이티브 호환성 | 개선됨 | 개선됨 |
| 이미지 빌드 복잡도 | 증가 (동일) | 증가 (동일) |
| 타임존/로케일 | 수동 처리 필요 (동일) | 수동 처리 필요 (동일) |
| CA 인증서 | 빌드 시 주입 필요 (동일) | 빌드 시 주입 필요 (동일) |
전환을 검토하고 있다면, 아래 항목을 순서대로 점검하는 것을 권장합니다.
.so를 로드하는 것을 목록화하고, glibc 환경에서의 정상 동작을 확인합니다single-request-reopen 옵션 적용 전후를 비교합니다. 커널 5.0+ 패치로 conntrack race가 완화되었으나 iptables 모드에서는 여전히 발생 가능하므로, 커널 버전과 무관하게 방어적 설정을 권장합니다Asia/Seoul tzdata 복사, 사설 CA 인증서 주입을 Dockerfile에 반영합니다Alpine에서 Distroless로의 전환은 단순한 베이스 이미지 교체가 아닙니다. libc 교체에서 오는 DNS 동작 차이, 네이티브 라이브러리 호환성 변화, 그리고 쉘 제거로 인한 운영 체계 전면 재설계가 수반됩니다.
보안 이점은 분명합니다. 쉘이 없으면 RCE 이후 공격자의 행동 반경이 극도로 제한되고, 불필요한 패키지가 없으니 CVE 자체가 줄어듭니다. 하지만 그 이점을 실현하려면 옵저버빌리티 체계, CI/CD 파이프라인, 디버깅 전략이 먼저 성숙해 있어야 합니다.
EKS 환경이라면 DNS 이슈를, ECS 환경이라면 디버깅 접근성을 최우선으로 검증하고, 비핵심 워크로드부터 단계적으로 전환하는 것이 현실적인 접근입니다.