dev에 Pod 8개를 새로 띄웠다. 3개는 의도한 가용 영역의 EFS에 붙었고, 5개는 아무 데도 붙지 않았다. 실패한 Pod의 기동 로그는 이랬다.

[efs-zone] node=10.0.39.229 -> efs=10.0.2.10
mount.nfs4: Failed to resolve server fs-0123456789abcdef0.efs.ap-northeast-2.amazonaws.com: Name or service not known

첫 줄은 내가 만든 래퍼가 /etc/hosts에 EFS 주소를 한 줄 적었다는 로그다. 바로 다음 줄에서 mount는 그 이름을 못 찾았다. 적은 줄이 그 사이에 사라진 것이다. 앱은 EFS 없이 그대로 떴다.

하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서, EFS 마운트를 가용 영역별로 나누는 작업을 하다 만난 일이다. 이 글에서는 그 작업에서 만난 함정 두 개를 정리한다. 하나는 envsubst, 하나는 kubelet과 istio-proxy 사이의 경쟁이다.

먼저 밝혀 둘 것이 있다. 이 작업은 dev 검증까지 마치고 revert했고, 작업의 출발점이 된 가설은 한 달 뒤 틀린 것으로 정정됐다. 그 이야기는 마지막에 한다.

고정 IP 대신 노드 IP로 존을 고르는 래퍼

앱들은 Kubernetes 볼륨이 아니라 컨테이너 안의 start.sh가 직접 EFS를 NFS로 마운트한다. EFS가 다른 VPC에 있어서 EFS 도메인이 DNS로 풀리지 않기 때문에, 앱 yaml에 hostAliases로 마운트 타깃 IP를 박아 두고 있었다. 문제는 IP를 존 기준이 아니라 앱 그룹 기준으로 나눠 놓은 것이었다. 10개 앱은 PRIMARY, 8개 앱은 SECONDARY IP를 썼고, Pod는 2a와 2c에 고르게 퍼졌다. 그러니 절반은 늘 다른 존의 마운트 타깃에 붙었다.

8월 초 EFS 통신이 멈췄을 때 이 구조가 눈에 띄었고, 나는 cross-AZ 고정 IP를 원인으로 보고 Pod가 자기 존의 마운트 타깃에 붙게 바꾸기로 했다.

Pod가 자기 존을 알아낼 방법은 많지 않았다.

  • EC2 메타데이터(IMDS)는 hop limit이 1이라 Pod에서 닿지 않는다.

  • Downward API는 노드 IP(status.hostIP)는 내려주지만 노드의 zone 라벨은 내려주지 않는다.

  • 남은 건 노드 IP다. 클러스터 실측 결과 워커 노드 서브넷의 세 번째 옥텟이 존과 깔끔하게 맞았다. 이 글의 값으로 쓰면 10.0.16~31.x가 2a, 10.0.32~47.x가 2c다.

앱 yaml에는 이미 NODE_IP가 env로 들어가 있었다. 이 값으로 존을 판별해 /etc/hosts에 한 줄 적은 뒤 원래 시작 명령을 그대로 exec하는 래퍼를 만들고, 컨테이너의 command를 이 래퍼로 덮어썼다.

# common/efs-zone-command.yaml: 앱 컨테이너에 병합되는 조각
# __X__ 자리표시자는 배포 스크립트가 sed로 바꾼다
command: ["/bin/sh", "-c"]
args:
  - |
    EFS_HOST="__EFS_HOSTNAME__"
    EFS_2A="__EFS_IP_2A__"
    EFS_2C="__EFS_IP_2C__"
    OCT3=$(echo "$NODE_IP" | cut -d. -f3)
    case "$OCT3" in
      ''|*[!0-9]*)
        # NODE_IP가 비거나 이상하면 2a로 폴백하고 로그를 남긴다
        echo "[efs-zone] WARN: NODE_IP invalid ('$NODE_IP'), fallback to 2a"
        EFS_IP="$EFS_2A" ;;
      *)
        if [ "$OCT3" -le __ZONE_BOUNDARY__ ]; then EFS_IP="$EFS_2A"; else EFS_IP="$EFS_2C"; fi ;;
    esac
    echo "[efs-zone] node=$NODE_IP -> efs=$EFS_IP"
    # 컨테이너만 재시작되면 /etc/hosts가 남아 있으므로 중복 기록을 막는다
    grep -q "$EFS_HOST" /etc/hosts || echo "$EFS_IP $EFS_HOST" >> /etc/hosts
    cd /home/app
    exec sh start.sh   # 이미지의 원래 CMD를 그대로 재현

다른 방법도 검토했다. initContainer로 /etc/hosts를 쓰는 방법은 busybox 같은 이미지를 사내 레지스트리에 따로 올려야 했다. 앱을 존별 Deployment 두 개로 나누면 오브젝트가 두 배가 되고 Rollout과 HPA 구조가 깨진다. 가장 깔끔한 건 이미지 빌드 파이프라인이 만드는 start.sh 안에 존 판별을 넣는 것이었지만, 그 파이프라인은 우리가 고칠 수 없었다. 결국 매니페스트의 command 래퍼가 남았다. 이미지, start.sh, 빌드 파이프라인은 한 글자도 바뀌지 않는다.

envsubst는 래퍼의 셸 변수까지 지운다

래퍼를 앱 템플릿에 넣으려다 첫 번째 함정을 만났다. 테넌트 배포 스크립트(onboarding-v6.sh, app-update.sh)는 앱 yaml을 envsubst로 렌더링한다. 호출은 이랬다.

# 화이트리스트 없이 호출한다
envsubst < "${APP_YAML}" > "${APP_YAML}.tmp"

인자 없는 envsubst는 파일 안의 모든 $VAR, ${VAR}를 치환하고, 환경에 없는 변수는 빈 문자열로 바꾼다. 템플릿 변수(${EFS_PRIMARY_IP} 등)와 런타임 셸 변수($NODE_IP, $OCT3)를 이름으로 구분해 봐야 소용이 없다. 래퍼를 템플릿에 그대로 넣으면 렌더링 뒤에 이렇게 된다.

# 템플릿                                  # envsubst 이후
EFS_2A="${EFS_PRIMARY_IP}"                EFS_2A="10.0.1.10"        # 의도대로
OCT3=$(echo "$NODE_IP" | cut -d. -f3)     OCT3=$(echo "" | cut ...) # NODE_IP 소멸
EFS_IP="$EFS_2A"                          EFS_IP=""                 # 소멸
echo "$EFS_IP $EFS_HOST" >> /etc/hosts    echo " " >> /etc/hosts    # 빈 줄만 기록

배포 시점에 치환할 변수와 컨테이너 안에서 풀릴 변수가 한 파일에 섞이면, 무제한 envsubst는 둘을 구분하지 못한다. 이건 배포 전에 스크립트를 읽다가 잡았다. 그대로 나갔다면 /etc/hosts에는 빈 줄이 적히고 mount는 조용히 실패했을 것이다.

막는 방법은 세 가지였다.

방법내용판단
envsubst 화이트리스트envsubst '${TENANT_ID} ${APP} ...'처럼 치환할 변수를 나열기존 템플릿이 쓰는 변수를 전수 조사해야 하고, 하나라도 빠지면 다른 배포가 깨진다
envsubst 밖에서 주입래퍼를 별도 파일로 두고 envsubst 이후 yq로 병합채택. yq는 $를 건드리지 않는다
센티널 exportenvsubst 직전에 export NODE_IP='$NODE_IP'동작은 하지만 나중에 읽는 사람이 이해하기 어렵다

래퍼는 envsubst를 타지 않는 common/efs-zone-command.yaml에 두고, 환경별 값(EFS IP, 경계값)은 고유한 자리표시자만 sed로 바꾼 뒤 yq로 병합했다. 요지만 옮기면 이렇다.

envsubst < "${APP_YAML}" > "${APP_YAML}.tmp"

# envsubst 이후에 주입한다. 제외 목록(EFS_ZONE_EXCLUDE_APPS)에 있는 앱은 건너뛴다
yq eval 'del(.spec.template.spec.hostAliases)' -i "${APP_YAML}.tmp"
yq eval '.spec.template.spec.containers[0] += load("/dev/stdin")' -i "${APP_YAML}.tmp" \
  < <(sed -e "s/__EFS_HOSTNAME__/${EFS_HOSTNAME}/g" \
          -e "s/__EFS_IP_2A__/${EFS_IP_2A}/g" \
          -e "s/__EFS_IP_2C__/${EFS_IP_2C}/g" \
          -e "s/__ZONE_BOUNDARY__/${ZONE_BOUNDARY}/g" \
          "${TEMPLATE_COMMON}/efs-zone-command.yaml")

경계값과 제외 목록은 환경별 profile에 넣었다(ZONE_BOUNDARY: "31", EFS_ZONE_EXCLUDE_APPS: "web-portal"). 경계값은 dev, stg, 운영을 포함한 클러스터 다섯 곳을 실측해 모두 같았다. 앱 19개 중 EFS를 쓰지 않는 web-portal 하나만 뺐다.

8개 중 5개에서 적은 줄이 사라졌다

렌더링은 정상이었는데, 첫 배포에서 mo-back Pod 8개가 뜨자 글 맨 앞의 결과가 나왔다. 3개는 자기 존 타깃에 붙었고 5개는 마운트가 없었다.

처음에는 래퍼 스크립트부터 의심했다. 실패한 Pod의 Spec에는 래퍼 command가 제대로 들어 있었고, 로그에도 [efs-zone] 줄이 찍혀 있었다. 그런데 /etc/hosts를 열어 보니 EFS 줄이 없었다.

# Kubernetes-managed hosts file.
127.0.0.1	localhost
::1	localhost ip6-localhost ip6-loopback
fe00::0	ip6-localnet
fe00::0	ip6-mcastprefix
fe00::1	ip6-allnodes
fe00::2	ip6-allrouters
10.0.45.164	tenant-a-mo-back-5888499ccc-qvps9

같은 Pod의 istio-proxy 컨테이너에서 봐도 EFS 줄은 0개였고, 성공한 Pod는 앱과 istio-proxy 양쪽에 모두 줄이 있었다. 컨테이너별 startedAt을 보니 8개 Pod 모두 앱과 istio-proxy가 같은 초에 시작돼 있었다.

파일 첫 줄이 답이었다. Pod의 /etc/hosts는 kubelet이 만드는 파일이고, kubelet은 Pod 안의 컨테이너를 하나 띄울 때마다 이 파일을 새로 쓴다. 앱 컨테이너와 istio-proxy가 거의 동시에 뜨면 순서에 따라 결과가 갈린다.

[성공] kubelet이 istio-proxy를 띄우며 hosts를 다시 씀 → 래퍼가 append → 이후 재작성 없음 → mount 성공
[실패] 래퍼가 append → 직후 kubelet이 istio-proxy를 띄우며 hosts를 다시 씀 → 줄 소멸 → mount 이름 해석 실패

밀리초 단위 경쟁이라 Pod마다 결과가 달랐고, 그게 3 대 5라는 무작위 분포로 나타났다. 기존 hostAliases에서 이런 일이 없었던 이유도 여기서 풀린다. hostAliases는 kubelet이 파일을 다시 쓸 때마다 스스로 포함시키는 정식 필드다. 컨테이너 안에서 /etc/hosts에 직접 쓰는 방식은 사이드카가 있는 Pod라면 언제든 이 경쟁에 걸린다. Kubernetes 문서도 hostAliases가 아닌 방법으로 이 파일을 고치지 말라고 적어 두었다.

해결은 순서를 고정하는 것이었다. Istio의 holdApplicationUntilProxyStarts를 켜면 istio-proxy가 먼저 뜨고 준비될 때까지 앱 컨테이너 시작이 미뤄진다. 이미 있던 Pod 어노테이션에 한 줄을 더했다.

spec:
  template:
    metadata:
      annotations:
        proxy.istio.io/config: |
          terminationDrainDuration: 15s
          holdApplicationUntilProxyStarts: true   # 추가: istio-proxy가 준비된 뒤에 앱 시작

istio-proxy 기동에 따른 마지막 재작성이 앱 시작 전에 끝나므로, 래퍼가 적은 줄은 그 뒤로 지워지지 않는다. 대안으로 래퍼에서 append 뒤 몇 초간 줄이 살아 있는지 확인하며 다시 쓰는 방법도 있었다. 고칠 파일은 하나뿐이지만 시간에 기대는 방식이라 확률을 줄이는 데 그쳐서 접었다. 앱 기동이 proxy 준비 시간만큼 늦어지는 비용은 있지만, startupProbe가 60초 뒤부터 보기 때문에 문제 되지 않는다고 봤다.

다시 배포한 결과는 이랬다.

노드 존Pod 수붙은 EFS 마운트 타깃
2a (세 번째 옥텟 31 이하)410.0.1.10 (2a)
2c (세 번째 옥텟 32 이상)410.0.2.10 (2c)

8개 모두 자기 존 타깃에 붙었고 누락은 0개였다. 실제 붙은 서버는 컨테이너 안에서 grep nfs4 /proc/mounts의 addr= 값으로 확인했다. 도메인으로 마운트해도 실제 접속한 IP가 여기에 남는다.

이 작업은 revert됐고, 출발점이 된 가설도 틀렸다

8/8을 확인한 그날 오후에 이 작업을 revert했다. 의도대로 동작했지만 운영까지 반영하려면 여러 유관 부서와 협의가 필요했기 때문이다. 코드와 설계는 협의만 끝나면 다시 적용할 수 있는 상태로 두었다. 그때도 이 작업이 EFS가 멈췄을 때 좀비 컨테이너가 생기는 문제를 근본적으로 없애지는 못하고, 확률과 영향 범위만 줄인다는 한계는 알고 있었다.

한 달 뒤 같은 좀비가 dev에서 다시 났고, 팩트체크를 하면서 원래 가설이 틀린 것으로 정정됐다. 진짜 원인은 cross-AZ 고정 IP가 아니었다. istio-init이 NFS 트래픽(2049)까지 envoy로 가로채고 있었고, Pod가 종료될 때 istio-proxy가 먼저 죽으면서 앱의 EFS flush가 끝없이 대기했다. 이 과정은 Terminating에서 멈춘 Pod를 다룬 글에서 따로 다룬다.

그러니 존별 마운트는 원래 문제를 풀지 못하는 작업이었다. 돌아보면 holdApplicationUntilProxyStarts가 고정한 것은 시작 순서였고, 좀비를 만든 건 종료 순서였다. 같은 사이드카를 들여다보고 있었는데 반대쪽 끝만 본 셈이다.

다만 이 글의 두 함정은 가설과 상관없이 유효하다. 배포 템플릿에 셸 스크립트를 넣는 일, 사이드카가 있는 Pod에서 /etc/hosts를 고치는 일은 이 작업이 아니어도 다시 만날 수 있다.