homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-07-14istioingressegressairgapdnslab

폐쇄망에서 ingress/egress gateway 테스트 랩 — 외부 세계를 클러스터 안에 모사하기

폐쇄망(air-gapped) 클러스터에서 Istio ingress/egress gateway를 테스트할 수 있는 자기완결형 랩을 구성했다. 인터넷이 없어서 막히는 지점을 하나씩 대체하는 방식이며, 부품은 전부 홈랩에서 실측 검증을 거친 패턴(DNS/GSLB 재현 랩, 이중 egress gateway)의 재조립이다. 전체 manifest는 files/에 있고, 구성은 6절에서 한 단계씩 실행 → 확인 쌍으로 밟는다.

상태: 초안. manifest는 kubectl --dry-run + istioctl analyze(이슈 0)까지 통과했고, 조립체 전체의 트래픽 완주는 아직이다. 개별 패턴의 실측 근거는 각 절에 명시했다.

1. 문제를 셋으로 쪼갠다

폐쇄망에서 안 되는 것은 정확히 세 가지고, 각각 독립적으로 해결된다.

막히는 것 전략 수단
이미지·차트를 인터넷에서 못 받음 반입 사내 레지스트리 미러 + Helm chart tgz
외부 도메인 DNS 질의 불가 모사 전용 CoreDNS가 가짜 외부 도메인을 authoritative로 서빙
실제 외부 서버 호출 불가 모사 sidecar 미주입 nginx + 사설 CA = “mesh 밖 세계”

이렇게 쪼갤 수 있는 근거: Istio 입장에서 “외부"란 인터넷이 아니라 (a) mesh 밖(sidecar 없음) + (b) 클러스터 서비스 디스커버리에 없는 도메인 + (c) 사설이 아닌 CA의 TLS라는 세 가지 조건일 뿐이다. 세 조건을 클러스터 안에서 충족시키면 Envoy는 그것을 외부와 구별하지 못하므로, ingress/egress의 모든 시나리오가 인터넷 없이 성립한다.

등장인물의 배치는 흐름이 없는 정적 정보라 표가 정확한 도구다 (트래픽 흐름은 4절·5절에서 하나씩 그린다):

ns 구성원 역할
lab-ext — 외부 세계 (sidecar 없음) fake-dns 외부 DNS 모사 (CoreDNS hosts, ttl 5s)
ext-a · ext-b 외부 HTTPS 서버 모사 (사설 CA TLS)
ext-client 외부 사용자 모사 — ingress 트래픽 소스
istio-system — mesh 경계 ingressgateway · egressgateway 진입·진출 지점
Gateway lab-ingress · lab-egress / Secret www-lab-tls gateway 설정·인증서는 workload ns에 둔다는 규율로 통일
lab-apps — 우리 서비스 (sidecar 주입) web-a · web-b ingress 백엔드
mesh-client egress 트래픽 소스·관측 지점

namespace를 둘로 나눴다(00-namespaces.yaml). DNS 랩에서는 단일 ns + pod annotation으로 주입을 껐지만, 이 랩은 “mesh 안/밖” 경계 자체가 테스트 대상이므로 ns 경계로 명시하는 쪽이 읽는 사람에게 정직하고, blast radius도 ns 2개로 한정된다.

2. 반입 — 폐쇄망 고유 작업은 이것뿐

이 절만 폐쇄망 특유의 작업이고, 이후는 온라인 클러스터와 완전히 같다.

이미지 6종 (images.txt) — Istio 본체는 2개가 전부다 (base 차트는 CRD만이라 이미지가 없다):

docker.io/istio/pilot:1.30.0          # istiod
docker.io/istio/proxyv2:1.30.0        # gateway pod + 모든 sidecar
registry.k8s.io/coredns/coredns:v1.11.3
docker.io/library/nginx:1.27-alpine
docker.io/library/busybox:1.36
docker.io/nicolaka/netshoot:latest    # 반입 시점에 태그 고정 권장

차트 3종 + istioctl — 온라인 측에서 helm pull istio/{base,istiod,gateway} --version 1.30.0 으로 tgz를 만들어 반입하고, 폐쇄망에서는 repo add 없이 로컬 파일로 설치한다. 순서는 온라인과 동일하게 base → istiod → gateway ×2:

helm upgrade --install istio-base ./base-1.30.0.tgz -n istio-system --create-namespace
helm upgrade --install istiod ./istiod-1.30.0.tgz -n istio-system -f values-istiod.yaml
helm upgrade --install istio-ingressgateway ./gateway-1.30.0.tgz -n istio-system -f values-ingress-gateway.yaml
helm upgrade --install istio-egressgateway  ./gateway-1.30.0.tgz -n istio-system -f values-egress-gateway.yaml

values에서 이미지 소스를 바꾸는 한 줄이 폐쇄망 설치의 핵심이다:

global:
  hub: registry.corp.example/docker.io/istio   # docker.io/istio 대신 사내 미러
  tag: 1.30.0

여기서 함정 하나. hub설치 시점이 아니라 sidecar 주입 시점에 발화한다. istiod 자체는 정상 기동했는데 주입 템플릿이 여전히 docker.io를 가리키면, 이후 뜨는 모든 워크로드가 ImagePullBackOff로 죽는다 — 원인이 한 단계 떨어져 있어 추적이 늦다. 그래서 설치 직후 검증 1순위는 컨트롤플레인 상태가 아니라 주입된 이미지 주소다:

kubectl -n <ns> get pod -o jsonpath='{.items[*].spec.containers[*].image}'

istioctl도 1.30.0으로 맞춰 반입한다. 1.27 클라이언트로 1.30 컨트롤플레인을 조회하면 진단 착시가 나는 것을 실측으로 확인한 바 있다.

3. 외부 세계 모사 — 검증된 패턴 세 개의 재사용

3-1. 가짜 외부 DNS (10-fake-dns.yaml)

전용 CoreDNS 하나가 가짜 외부 도메인 3개만 authoritative(ttl 5s)로 응답하고, 나머지는 클러스터 DNS로 넘긴다:

hosts /hosts/addn api.lab.external blocked.lab.external www.lab.external {
    ttl 5
    reload 2s
    fallthrough
}
forward . __CLUSTER_DNS__

forward를 남기는 이유가 이 구성의 급소다. 이 DNS를 바라보는 pod의 sidecar는 istiod(클러스터 이름)를 계속 resolve해야 xDS를 받는다. forward가 없으면 sidecar가 아예 안 뜬다 — 자기완결형 DNS의 함정으로, DNS 랩에서 실제로 밟고 정리한 지점이다.

레코드 변경은 존파일(/hosts/addn) 재작성이고 reload 2s가 자동 재적재하므로, 운영 중 GSLB 절체 모사까지 이 장치 하나로 된다. 단 두 가지 규율: 존파일은 항상 전체 줄을 한 번에 재작성할 것(한 줄만 쓰면 나머지 소실), Corefile 자체를 바꿨으면 rollout restart가 필요하다는 것(존파일과 달리 자동 재적재 안 됨).

호스트 3개의 역할 분담:

호스트 가리키는 곳 용도
api.lab.external ext-a pod IP egress 대상 (ServiceEntry 등록됨)
blocked.lab.external ext-b pod IP REGISTRY_ONLY 차단 검증 (DNS는 되지만 SE 없음)
www.lab.external ingressgateway Service IP ingress 진입 호스트 (외부 사용자가 보는 이름)

blocked.lab.external 뒤에도 살아있는 서버를 뒀다. 차단 테스트가 실패했을 때 “서버가 없어서"인지 “정책 때문"인지 갈리지 않도록, 실패 원인을 sidecar 정책 하나로 고립시키기 위해서다.

3-2. 가짜 외부 서버 (20-ext-servers.yaml)

sidecar 미주입 nginx 2대가 사설 CA로 발급한 인증서(SAN 일치)로 TLS를 말한다. DNS 랩과 다른 결정 하나: Kubernetes Service를 만들지 않았다.

Service를 만들면 ClusterIP가 mesh service registry에 올라가고 모든 sidecar에 per-VIP 전용 리스너가 생긴다. SE 호스트가 그 VIP로 resolve되면 L4(SNI passthrough) 경로에서 VIP 리스너가 0.0.0.0:443 캐치올보다 먼저 매치되어, 트래픽이 SE cluster가 아닌 in-mesh 경로를 타 버린다. curl은 200이 나오기 때문에 무증상인 채로 실험이 무효가 되는 함정 — DNS 랩에서 실제로 밟았다. 그래서 fake-dns의 A 레코드는 pod IP를 직접 가리키고, “서비스 디스커버리에 없는 외부 서버"라는 모사 취지에도 이쪽이 충실하다. 대가는 pod 재시작 시 레코드가 낡는 것인데, 6절 ⑤단계(A 레코드 3줄 재기록)를 다시 실행하면 된다.

3-3. 사설 CA (6절 ①·②단계에서 직접 생성)

CA 하나를 만들어 외부 서버 인증서(SAN=api/blocked.lab.external)와 ingress 인증서(SAN=www.lab.external)를 같은 체인으로 발급한다. 폐쇄망은 공인 CA가 애초에 불가능하므로 사설 CA가 제약이 아니라 정상 경로다. ca.crt는 ConfigMap으로 양쪽 ns에 배포해 클라이언트가 --cacert검증을 생략하지 않고 테스트한다 — DNS 랩은 insecureSkipVerify로 우회했지만, gateway 테스트에서는 인증서 체인 자체가 검증 대상이므로 이번엔 검증을 포함시켰다.

클라이언트 pod의 DNS 연결은 dnsPolicy: None + dnsConfig.nameservers=[fake-dns]다 (30·40-*.yaml). app 컨테이너와 istio-proxy가 같은 resolv.conf를 공유하므로 Envoy(STRICT_DNS의 c-ares)도 자동으로 fake-dns를 본다. searches의 cluster domain은 하드코딩하지 않고 6절 ⑥단계에서 coredns ConfigMap을 조회해 주입한다 — cluster.local을 하드코딩했다가 커스텀 도메인 클러스터에서 sidecar가 안 뜨는 장애를 실제로 겪었다.

4. Ingress 테스트 (50-ingress.yaml)

폐쇄망에서 ingress의 유일한 고민은 “외부 클라이언트가 없다"인데, 답은 egress와 대칭이다: sidecar 미주입 pod가 곧 외부 클라이언트다. ext-client는 fake-dns를 바라보므로 www.lab.external을 실제 DNS 경로로 resolve해서 들어온다.

sequenceDiagram
    autonumber
    participant C as ext-client (mesh 밖)
    participant D as fake-dns
    participant G as ingressgateway
    participant W as web-a / web-b
    C->>D: www.lab.external A?
    D-->>C: ingressgateway IP (ttl 5s)
    C->>G: https 요청 (SNI: www.lab.external)
    Note over G: TLS 종단 - credentialName: www-lab-tls
    G->>W: HTTP 라우팅 (/ 는 web-a, /b 는 web-b)
    W-->>C: 200 "web-a"
# T1. host/path 분기 (평문) — 기대: / -> web-a, /b -> web-b
kubectl -n lab-ext exec deploy/ext-client -- curl -sS http://www.lab.external/
kubectl -n lab-ext exec deploy/ext-client -- curl -sS http://www.lab.external/b
# T2. TLS termination + 사설 CA 검증 포함 — 기대: 200
kubectl -n lab-ext exec deploy/ext-client -- \
  curl -sS --cacert /etc/lab-ca/ca.crt https://www.lab.external/

HTTP:80과 HTTPS:443을 둘 다 열었다. 80이 되고 443이 안 되면 인증서·secret 쪽, 둘 다 안 되면 라우팅 쪽 — 실패 원인을 반씩 자르는 용도다. TLS 종단 인증서는 credentialName: www-lab-tls로 참조하는데, 이 secret은 gateway workload의 ns(istio-system) 에 있어야 한다. 같은 규율로 Gateway 리소스(lab-ingress·lab-egress)도 istio-system에 통일했다 — 다른 ns에 있는 VS는 istio-system/lab-ingress 정규화 형식으로 Gateway를 참조한다.

한 가지 용도 구분: curl --resolve로도 SNI/Host 분기는 테스트할 수 있지만, 그건 DNS 경로를 우회한다. DNS 동작 자체(절체 전파 등)를 검증하는 시나리오에서는 쓰면 안 된다. 클러스터 밖(폐쇄망 내 다른 VM)에서 진짜 L4 경로까지 보려면 NodePort로 같은 테스트를 반복하면 된다.

5. Egress 테스트 (60-egress.yaml, 65-sidecar-registry-only.yaml)

가동 검증된 이중 egress gateway 랩의 passthrough 패턴을 복제하되, 전용 gateway 대신 공용 istio-egressgateway를 썼다 — 폐쇄망 첫 검증은 추가 gateway 반입·배포 없이 시작하는 게 부담이 적다. 체인은 SE(resolution: DNS, exportTo로 client ns + istio-system만) + PASSTHROUGH Gateway + VS 2단(mesh route / gateway route)이다.

sequenceDiagram
    autonumber
    participant M as mesh-client (+ sidecar)
    participant D as fake-dns
    participant E as egressgateway
    participant A as ext-a (api.lab.external)
    M->>D: api.lab.external A?
    D-->>M: ext-a pod IP (ttl 5s)
    M->>E: TLS/SNI - sidecar의 VS mesh route가 gateway로 보냄
    E->>A: SNI passthrough - SE cluster(STRICT_DNS)
    A-->>M: 200 "ext-a" (TLS는 client와 ext-a의 end-to-end)
    Note over M: blocked.lab.external은 그림에 없다 - SE 미등록이라<br/>sidecar가 차단(connection reset), 트래픽이 mesh를 떠나지 않음

REGISTRY_ONLY는 전역 meshConfig가 아니라 Sidecar 리소스로 ns에만 걸었다. 공유 테스트 클러스터에서 전역 REGISTRY_ONLY는 다른 워크로드의 외부 호출까지 일괄 차단하기 때문이다:

apiVersion: networking.istio.io/v1beta1
kind: Sidecar
metadata: { name: default, namespace: lab-apps }
spec:
  outboundTrafficPolicy:
    mode: REGISTRY_ONLY
# T3. egress gateway 경유 — 기대: ext-a 응답
kubectl -n lab-apps exec deploy/mesh-client -c netshoot -- \
  curl -sS --cacert /etc/lab-ca/ca.crt https://api.lab.external/
# T4. 미등록 외부 차단 — 기대: 실패(connection reset) = PASS
kubectl -n lab-apps exec deploy/mesh-client -c netshoot -- \
  curl -sS --cacert /etc/lab-ca/ca.crt https://blocked.lab.external/

T4의 실패 형태는 HTTP 502가 아니라 connection reset이다. 이 경로는 L4(SNI)라서 sidecar가 HTTP 응답을 만들어 줄 계층이 없다. 경유·반영은 명령으로 직접 확인한다:

istioctl proxy-config listeners deploy/mesh-client -n lab-apps --port 443
istioctl proxy-config endpoints deploy/mesh-client -n lab-apps \
  --cluster "outbound|443||api.lab.external"     # 기대: fake-dns A레코드 = ext-a pod IP
kubectl -n istio-system logs deploy/istio-egressgateway --tail=20

부수 효과 하나 — 이 구조는 gateway 테스트를 넘어 GSLB 절체 시나리오로 그대로 확장된다. 존파일의 api.lab.external을 ext-a → ext-b로 재기록하면 STRICT_DNS의 endpoint 갱신·drain 거동을 폐쇄망 안에서 재현할 수 있다. 그 자체의 실측 결과는 DNS/GSLB 재현 랩 참조.

6. 구성 순서 — 하나씩 확인하며 올리기

전 과정을 손으로 밟는 절차다. 각 단계는 실행 → 확인 한 쌍이고, 확인이 실패하면 다음 단계로 넘어가지 않는 것이 규율이다 — 뒤로 갈수록 원인이 겹쳐서 추적이 어려워진다. manifest는 files/에서 받는다.

폐쇄망이라면 apply 전에 이미지 참조 앞에 미러 주소를 붙인다. manifest의 이미지가 전부 fully-qualified라 prepend만 하면 Harbor proxy-cache 레이아웃이 그대로 나온다 (flat 미러면 image를 직접 수정하거나 containerd registry mirror 설정 사용):

sed -E 's#^([[:space:]]*image:[[:space:]]*)#\1registry.corp.example/#' <manifest>.yaml | kubectl apply -f -

① 사설 CA와 인증서 — CA 하나, 서버 인증서 둘

openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout ca.key -out ca.crt -subj "/CN=Airgap Lab CA"
# 외부 서버용 (ext-a/ext-b가 같은 인증서 공유 — SAN에 두 호스트)
openssl req -newkey rsa:2048 -nodes -keyout ext.key -out ext.csr -subj "/CN=api.lab.external"
openssl x509 -req -in ext.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -out ext.crt \
  -extfile <(echo "subjectAltName=DNS:api.lab.external,DNS:blocked.lab.external")
# ingress 종단용
openssl req -newkey rsa:2048 -nodes -keyout www.key -out www.csr -subj "/CN=www.lab.external"
openssl x509 -req -in www.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -out www.crt \
  -extfile <(echo "subjectAltName=DNS:www.lab.external")

확인 — SAN이 비어 있으면 이후 curl이 전부 인증서 오류로 죽는다:

openssl x509 -in ext.crt -noout -ext subjectAltName   # 기대: api + blocked 두 호스트

② namespace + secret/ConfigMap

kubectl apply -f 00-namespaces.yaml
kubectl -n lab-ext    create secret tls ext-tls     --cert=ext.crt --key=ext.key
kubectl -n istio-system  create secret tls www-lab-tls --cert=www.crt --key=www.key
kubectl -n lab-ext    create configmap lab-ca --from-file=ca.crt
kubectl -n lab-apps   create configmap lab-ca --from-file=ca.crt

www-lab-tls만 istio-system에 만드는 이유: credentialName은 Gateway 리소스의 ns가 아니라 gateway workload의 ns에서 secret을 찾는다.

③ fake-dns — cluster DNS를 발견해서 주입

__CLUSTER_DNS__ 자리에 클러스터 DNS의 ClusterIP를 넣는다. forward 대상이 틀리면 sidecar가 istiod를 resolve하지 못해 아예 뜨지 않으므로, 값부터 눈으로 확인한다:

CLUSTER_DNS=$(kubectl -n kube-system get svc coredns -o jsonpath='{.spec.clusterIP}')  # 없으면 kube-dns
echo $CLUSTER_DNS
sed "s/__CLUSTER_DNS__/$CLUSTER_DNS/g" 10-fake-dns.yaml | kubectl apply -f -
kubectl -n lab-ext rollout status deploy/fake-dns

④ 외부 서버 모사

kubectl apply -f 20-ext-servers.yaml
kubectl -n lab-ext rollout status deploy/ext-a
kubectl -n lab-ext rollout status deploy/ext-b

⑤ A 레코드 기록 — pod IP를 조회해서 3줄 일괄로

Service가 없으므로(3-2절) pod IP를 직접 넣는다. 3줄을 한 번에 쓰는 게 규율이다 (한 줄만 쓰면 나머지 레코드 소실):

IP_API=$(kubectl -n lab-ext get pod -l app=ext-a -o jsonpath='{.items[0].status.podIP}')
IP_BLOCKED=$(kubectl -n lab-ext get pod -l app=ext-b -o jsonpath='{.items[0].status.podIP}')
IP_INGRESS=$(kubectl -n istio-system get svc istio-ingressgateway -o jsonpath='{.spec.clusterIP}')
kubectl -n lab-ext exec deploy/fake-dns -c writer -- sh -c \
  "printf '%s api.lab.external\n%s blocked.lab.external\n%s www.lab.external\n' \
   $IP_API $IP_BLOCKED $IP_INGRESS > /hosts/addn"

확인:

kubectl -n lab-ext exec deploy/fake-dns -c writer -- cat /hosts/addn   # 기대: 3줄

ext pod를 재시작했다면 IP가 바뀌므로 이 단계만 다시 반복하면 된다.

⑥ 클라이언트 — fake-dns IP와 cluster domain을 발견해서 주입

cluster domain은 하드코딩하지 않고 coredns ConfigMap에서 발견한다. cluster.local을 박아 넣었다가 커스텀 도메인 클러스터에서 sidecar가 안 뜨는 장애를 실제로 겪었다(3-3절):

LABDNS_IP=$(kubectl -n lab-ext get svc fake-dns -o jsonpath='{.spec.clusterIP}')
CLUSTER_DOMAIN=$(kubectl -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' \
                 | grep -oE 'kubernetes[[:space:]]+[^ ]+' | awk '{print $2}')
echo "$LABDNS_IP / $CLUSTER_DOMAIN"    # 둘 다 값이 있는지 먼저 확인
for f in 30-ext-client.yaml 40-mesh-client.yaml; do
  sed -e "s/__LABDNS_IP__/$LABDNS_IP/g" -e "s/__CLUSTER_DOMAIN__/$CLUSTER_DOMAIN/g" $f \
    | kubectl apply -f -
done

확인 두 가지 — 이 단계가 이 랩에서 가장 잘 깨지는 곳이다:

# (a) mesh-client가 2/2인가 — sidecar가 떴다 = istiod resolve가 fake-dns forward 경로로 성공했다는 증거
kubectl -n lab-apps get pod -l app=mesh-client
# (b) 가짜 도메인이 fake-dns에서 풀리는가
kubectl -n lab-ext exec deploy/ext-client -- dig +short www.lab.external   # 기대: ingressgateway IP

(a)가 1/2에서 멈추면 십중팔구 ③의 forward 대상이나 ⑥의 cluster domain이 틀린 것이다.

⑦ ingress 시나리오 → 4절의 T1·T2로 검증

kubectl apply -f 50-ingress.yaml
istioctl analyze -n lab-apps && istioctl analyze -n istio-system   # 경고 0 확인 후 트래픽

⑧ egress + REGISTRY_ONLY 시나리오 → 5절의 T3·T4로 검증

kubectl apply -f 60-egress.yaml
kubectl apply -f 65-sidecar-registry-only.yaml
# 트래픽 전에 설정 반영부터 — SE cluster의 endpoint가 ⑤에서 넣은 ext-a pod IP인가
istioctl proxy-config endpoints deploy/mesh-client -n lab-apps \
  --cluster "outbound|443||api.lab.external"

(참고) airgap-lab-setup.sh는 위 ①~⑧과 동일한 절차의 자동화 버전이다. 이 랩의 기본 경로는 어디까지나 위처럼 파일을 하나씩 apply 하며 확인하는 것이고, 스크립트는 랩을 여러 번 재구축하게 됐을 때만 참고.

7. 함정 목록 (실측 기반)

  1. global.hub 오설정은 주입 시점 발화 — istiod 정상인데 신규 pod 전멸. 2절.
  2. cluster domain 하드코딩 금지 — searches가 틀리면 istiod resolve 실패 → sidecar 안 뜸.
  3. ext 서버에 Service를 만들면 실험 무효 — VIP 리스너 선점, curl 200이라 무증상. 3-2절.
  4. Corefile 변경은 rollout restart — 존파일만 reload 2s 자동.
  5. 존파일은 전체 줄 일괄 재기록 — 부분 기록 시 나머지 레코드 소실.
  6. ext pod 재시작 = A 레코드 낡음 — 6절 ⑤단계 재실행으로 재동기화.
  7. VS/DR destination은 cluster.local 고정 — Istio service registry는 kubelet clusterDomain과 무관하게 *.svc.cluster.local로 등록한다. pod resolv.conf의 search 도메인(커스텀 도메인)과 별개 축이라, 커스텀 도메인 클러스터에서도 destination은 cluster.local로 쓴다.

첨부

파일 내용
README.md 시나리오 운영 문서(반입 체크리스트·합격선·설계 결정)
00-namespaces.yaml ns 2개 — mesh 안/밖 경계
10-fake-dns.yaml 가짜 외부 DNS (CoreDNS hosts, ttl 5s)
20-ext-servers.yaml 가짜 외부 HTTPS 서버 ×2 (Service 없음)
30-ext-client.yaml / 40-mesh-client.yaml 외부/내부 클라이언트
50-ingress.yaml ingress 백엔드·Gateway·VS
60-egress.yaml SE + PASSTHROUGH Gateway + VS 2단
65-sidecar-registry-only.yaml ns 스코프 REGISTRY_ONLY
airgap-lab-setup.sh (참고) 6절 ①~⑧ 동일 절차의 자동화 — 반복 재구축용
images.txt 반입 이미지 체크리스트

Files