Container Orchestration/Kubernetes

ingress-nginx → nginx-gateway-fabric 마이그레이션 실전 기록 (온프레미스 K8s, 11개 인스턴스)

Somaz 2026. 5. 14. 00:00
728x90
반응형

Overview

ingress-nginx가 유지보수 모드로 전환되고 Kubernetes Gateway API가 v1.2로 GA되면서, 운영 중인 온프레미스 클러스터의 ingress-nginx 11개 인스턴스를 nginx-gateway-fabric(NGF) 2.0으로 이관했다.

 

 

 

전환 대상과 규모는 다음과 같다.

항목	내용
대상 클러스터	온프레미스 Kubespray (K8s v1.34)
기존 Ingress 컨트롤러	ingress-nginx 11개 인스턴스 (기본 nginx 1개 + nginx-public-a~j 10개)
LoadBalancer	MetalLB L2 (내부 IP 14개 고정 할당)
마이그레이션 대상 앱	infra 직배포 11개 + ArgoCD ApplicationSet 9개 + 별도 레포 2개
전환 후 구조	단일 NGF 컨트롤 플레인 + 11 Gateway CR (per-Gateway Deployment)
다운타임	클래스당 약 30초~1분 (IP swap 구간만)

 

 

핵심 목표 3가지는 다음과 같다.

- 리소스/운영 부담 감축: ingress-nginx 11개 helm release → NGF 1개 설치 + 11개 Gateway CR로 단순화한다
- DNS/방화벽 무변경: MetalLB IP를 그대로 유지해 외부 라우팅 설정 재작업을 제거한다
- 롤백 용이성: 병렬 배포 후 점진 cutover 방식으로 다운타임을 최소화한다

 

 

이 글은 단순 튜토리얼이 아니라 실제 마이그레이션 중 내렸던 아키텍처 결정, 만난 문제, 그리고 해결 과정을 실측 기록과 함께 정리한 실전 문서다. 같은 전환을 앞둔 분들께 레퍼런스가 되길 바란다.

 

 

 

 

 


 

 

 

1. 왜 nginx-gateway-fabric인가

ingress-nginx 프로젝트는 유지보수 모드로 전환 방침이 공지됐고, 공식 후계 방향은 Kubernetes SIG Network에서 표준화한 Gateway API다. Gateway API 구현체는 여러 개(Istio, Envoy Gateway, Cilium, Contour, NGF 등)이지만 다음 이유로 NGF 2.0을 선택했다.

1. 기존 스택 재사용: 데이터 플레인이 그대로 nginx라서 로그·튜닝 노하우가 유지된다
2. per-Gateway Deployment 네이티브 지원: Gateway CR 하나당 nginx Deployment가 자동 프로비저닝된다. 기존 "클래스 분리 = 릴리스 분리" 모델과 궁합이 좋다
3. 단일 컨트롤 플레인 + N 데이터 플레인: helm release 11개 → 1개로 축소되면서도 IP·클래스 분리 요구는 유지된다
4. CRD 기반 정책 확장: ClientSettingsPolicy, UpstreamSettingsPolicy, BackendTLSPolicy 등으로 기존 어노테이션 대부분이 대체된다

 

Gateway API가 Ingress 대비 갖는 본질적 장점은 리소스 경계의 명확화다.

 

Ingress는 "cluster-scoped 관심사(리스너, TLS, 클래스)"와 "app-scoped 관심사(라우팅 규칙)"가 한 오브젝트에 섞여 있었는데, Gateway API는 이를 `GatewayClass` / `Gateway` / `HTTPRoute` 로 분리한다.

 

클러스터 관리자와 앱 개발자의 권한 경계가 자연스럽게 그려진다.

 

 

 

 

 

 

2. 아키텍처 결정: 1 GatewayClass + 11 Gateway

 

 

초기 오판과 수정

처음 설계 단계에서는 "ingress-nginx 11개 인스턴스 = ingressClass 11개"였으니 NGF도 자연스럽게 11 GatewayClass일 거라 가정했다. 이게 틀렸다.

 

NGF 2.x는 설치당 단일 GatewayClass만 reconcile한다. 컨트롤러 실행 시 `--gatewayclass=ngf` 플래그로 한 클래스만 지정되고, 나머지는 무시된다. 여러 GatewayClass가 필요하면 NGF를 여러 번 설치해야 한다.

 

 

설계를 다음과 같이 수정했다.

- 1개 GatewayClass: `ngf` (NGF chart가 자동 생성)
- 11개 Gateway: `ngf`, `ngf-public-a` ~ `ngf-public-j` (모두 `gatewayClassName: ngf`)
- 11개 NginxProxy CR: Gateway의 `infrastructure.parametersRef`로 참조한다. MetalLB IP를 `service.loadBalancerIP`로 고정한다
- **Tenancy boundary = Gateway 이름**. 앱의 HTTPRoute는 `parentRefs`로 특정 Gateway에 attach하면 자동으로 해당 데이터 플레인(과 IP)으로 라우팅된다

 

 

즉 "11 클래스 분리"가 아니라 "11 Gateway 분리"로 같은 멀티테넌시를 달성한다. NGF가 Gateway별로 독립된 nginx Deployment를 띄워주기 때문에 프로세스 격리까지 그대로 유지된다.

 

 

디렉토리 구조 (최종)

network/nginx-gateway-fabric/
├── Chart.yaml                      # upstream mirror (version + appVersion = single source)
├── helmfile.yaml.gotmpl            # Chart.yaml을 readFile+fromYaml로 자동 참조
├── values.yaml                     # upstream default (helm pull)
├── values.schema.json              # upstream schema
├── values/
│   └── mgmt.yaml                   # 커스텀 override (replicas, telemetry off 등)
├── cr-chart/                       # Phase 7+에서 manifests/ 리팩터 결과
│   ├── Chart.yaml
│   ├── values.yaml
│   └── templates/
│       ├── gateways.yaml           # 11 Gateway
│       ├── nginxproxies.yaml       # 11 NginxProxy (LB IP pin)
│       └── servicemonitor.yaml
└── upgrade.sh                      # external-oci canonical
  • Chart.yaml single source: `version`(chart 자체)과 `appVersion`(NGF 소프트웨어 = git tag)을 단일 소스로 두고, `helmfile.yaml.gotmpl` 에서 `readFile` + `fromYaml` 로 자동 참조한다.
  • `Chart.yaml` 만 갱신해도 `chart version` 과 `prepare hook` 의  `?ref=v<APP_VER> ` 경로가 자동 `sync` 된다.

 

 

Hook 설계 (apply/destroy)

  • prepare: 모든 명령 전 → Gateway API CRDs + NGF 자체 CRDs를 선설치한다
  • postsync: apply 후 → Gateway/NginxProxy를 적용한다 (초기엔 kubectl apply, 이후 로컬 차트로 전환한다)
  • preuninstall: destroy 전 → 커스텀 CR을 제거한다 (컨트롤러 살아있을 때 finalizer 처리)
  • postuninstall: destroy 후 → CRDs + namespace를 제거한다
 

 

 

OCI 차트 업그레이드 스크립트

NGF는 OCI registry(`oci://ghcr.io/nginx/charts/nginx-gateway-fabric`)로만 배포된다. 기존 업그레이드 canonical 스크립트는 `helm search repo` 로 최신 버전을 자동 탐지하는데, OCI는 이 명령을 지원하지 않는다.

 

그래서 `external-oci.sh` canonical을 새로 만들었다. 최신 버전 탐지 로직만 GitHub Releases API(`api.github.com/repos/$GITHUB_REPO/releases/latest`)를 사용해 `tag_name` 에서 prefix(기본 v)를 제거해 chart 버전을 추출하도록 교체했다. `GITHUB_TOKEN` 환경변수가 있으면 rate limit이 60→5,000 req/h로 완화된다.

 

 

 

 

 

 

 

3. 어노테이션 → Gateway API 변환 매핑

ingress-nginx에서 쓰던 어노테이션을 Gateway API + NGF CRD로 옮기는 표다. 실제 환경에서 쓰던 어노테이션만 정리했다.

ingress-nginx 어노테이션	Gateway API / NGF 대체	용도
force-ssl-redirect: "true"	HTTPRoute + RequestRedirect filter (HTTP 리스너에만)	HTTPS 강제 앱
force-ssl-redirect: "false"	미설정 (기본)	대부분의 앱
ssl-passthrough: "true"	TLSRoute + listener tls.mode: Passthrough	사용 앱 없음 → 제외
backend-protocol: "HTTPS"	BackendTLSPolicy (gateway.networking.k8s.io/v1)	TLS backend 앱
backend-protocol: "HTTP"	기본	일반 HTTP backend
rewrite-target: /	URLRewrite filter (ReplacePrefixMatch)	경로 재작성 앱
proxy-body-size: "500m"	ClientSettingsPolicy.spec.body.maxSize: "500m"	업로드 큰 앱
proxy-body-size: "0" (무제한)	ClientSettingsPolicy.spec.body.maxSize: "0"	컨테이너 레지스트리
proxy-read/send-timeout: "300"	HTTPRoute timeouts.request + timeouts.backendRequest	장시간 연결 앱
proxy-buffering: "off"	ProxySettingsPolicy.spec.buffering.disable	SSE 스트리밍 앱
proxy-http-version: "1.1"	NGF 기본값 → no-op, 제거	-
limit-rps: "15"	RateLimitPolicy (key, rate, burst)	레이트 리밋 앱
limit-connections: "25"	NGF native 미지원 (SnippetsFilter 우회 가능)	동시 연결 제한
configuration-snippet	SnippetsFilter CRD (NGF 1.4+)	최후 수단

 

 

 

몇 가지 실전 포인트를 짚어두면 다음과 같다.

 

 

`force-ssl-redirect` 는 sibling HTTPRoute 2개로 분리한다.

하나는 HTTP listener에 attach해서 `RequestRedirect filter` 만 두고, 다른 하나는 HTTPS listener에 실제 라우팅 규칙을 둔다. 301 대신 307을 쓰는 게 안전하다. 301은 `POST` 를 `GET` 으로 바꿔버려 webhook이나 API 수신자가 깨진다.

 

`backend-protocol: HTTPS` 는 BackendTLSPolicy로 해결한다.

ECK 기반 Elasticsearch의 경우 operator가 내부 Secret에 CA를 넣어주는데, 차트에서 lookup 함수로 이를 읽어 ConfigMap으로 자동 복제하도록 했다. operator가 CA를 rotate하면 다음 `helm upgrade` 에서 자동 갱신된다.

 

`limit-connections` 는 NGF 네이티브 미지원이 발목을 잡았다.

`limit_conn` directive가 NGF policy로 노출되지 않아, 해당 기능을 포기하고 앱 레벨 신뢰로 전환했다. `SnippetsFilter` 로 우회 가능하지만 표면적이 넓어져 스코프에서 제외했다.

 

 

 

 

 

4. Zero-downtime 병렬 모드 전략

가장 중요한 전략적 결정이다. "어떻게 해야 실트래픽 영향 없이, 롤백 한 번에 되돌릴 수 있게 전환할까?"

 

 

기본 원칙: ingress + httproute 동시 공존

앱 전환 전 기간에 걸쳐 모든 앱은 `ingress.enabled: true` + `httproute.enabled: true` 공존 상태로 유지했다. 다음 조건을 동시에 충족시키는 설계다.

  • DNS는 계속 기존 MetalLB IP를 가리킨다 → 사용자 트래픽은 ingress-nginx로 향한다
  • HTTPRoute는 임시 IP에만 닿는다 → 개발자가 `curl --resolve` 로만 검증할 수 있다
  • 롤백 = `httproute.enabled: false` 토글 1개로 끝난다 (ingress는 애초 건드리지 않았으므로 복구 불필요하다)
  • 전 환경(prod/dev/qa/staging) 동일 절차 → 환경별 차등이 없다
 

 

 

MetalLB 풀 확장

MetalLB 풀에 임시 검증용 IP 11개를 추가했다. 기존 서비스가 쓰던 IP와 겹치지 않도록 사전에 `kubectl get svc -A`  및 수동 예약 IP(베어메탈 노드 등)를 모두 식별한 뒤 사용 가능 구간만 선점했다. 이 임시 IP들은 Phase 6 cutover 시 실 IP로 swap된다.

 

 

검증 패턴

임시 IP로 `smoke test` 하는 표준 명령은 다음과 같다.

# NGF 임시 IP 검증
curl -sS --resolve <hostname>:80:<temp-ip> \
     -o /dev/null -w "%{http_code}\n" \
     http://<hostname>/

# 기존 ingress-nginx 대조 (실 IP)
curl -sS --resolve <hostname>:80:<real-ip> \
     -o /dev/null -w "%{http_code}\n" \
     http://<hostname>/

 

 

두 응답이 같으면 OK다. 각 Phase에서 롤백 훈련을 1회 필수로 진행했다.

  1. `httproute.enabled: false` 토글 → `helmfile apply` → 임시 IP 404, 실 IP 200 (기존 경로 무영향) 확인한다
  2. `httproute.enabled: true` 복구 → 임시 IP 200 회복한다
  3. 전 과정 각 단계 10초 내 완료되고 실트래픽은 시종 무영향이다

 

 

차트별 HTTPRoute 템플릿 통일

여러 차트에 HTTPRoute 템플릿을 추가하면서 values 스키마를 통일했다. 개별 앱 차트와 ApplicationSet 공용 base 차트가 동일한 모양을 쓰도록 했다.

httproute:
  enabled: false
  parentRefs:
    - name: ngf
      namespace: nginx-gateway
      # sectionName: https   # HTTPS only 필요 시
  hostnames: []
  rules:
    - matches:
        - path: { type: PathPrefix, value: / }
      # filters:
      #   - type: URLRewrite
      #     urlRewrite: { path: { type: ReplacePrefixMatch, replacePrefixMatch: / } }
      # timeouts:
      #   request: 300s
      #   backendRequest: 300s
      backendRefs:
        - port: 80   # name 생략 시 chart fullname 자동
  httpsRedirect:
    enabled: false   # true면 HTTP→HTTPS 301 sibling route 자동 생성
  • 이 스키마 하나로 단순 라우팅부터 rewrite, timeout, HTTPS redirect까지 전부 커버된다. 한 앱에서 먼저 패턴을 검증한 뒤 그대로 복제했다.

 

 

 

 

 

5. TLS Wildcard 단일화 + cert-manager 미래 전환 설계

 

 

현 환경 제약

사용 중인 도메인이 DNS API를 제공하지 않는 registrar로 관리된다. 이 제약이 결정적이었다.

  • cert-manager + Let's Encrypt DNS-01 challenge 불가 (registrar가 DNS API를 제공하지 않는다)
  • HTTP-01은 wildcard 불가 (ACME 스펙상)
  • → self-signed wildcard cert 수동 관리가 사실상 유일한 옵션이다
  • 기존에는 앱마다 개별 self-signed Secret을 두고 있었다. 이를 단일 wildcard Secret 중앙 관리로 통합했다.

 

 

 

단일 Secret 중앙 관리 구조

Secret "wildcard-tls" (nginx-gateway ns)
  └─ CN=*.example.com
     SAN: example.com, *.example.com
     10년 validity
  │
  ▼
Gateway "ngf" listeners:
  - name: http          (port 80)
  - name: https         (port 443, hostname: "*.example.com",
                         certificateRefs: wildcard-tls)
  │
  ▼
각 앱 HTTPRoute:
  parentRefs[*].sectionName 생략 (기본): HTTP + HTTPS 양쪽 listener 자동 부착
  parentRefs[*].sectionName: https        : 명시적 HTTPS only
  parentRefs[*].sectionName: http         : httpsRedirect sibling route 전용

 

 

차트 역할 = 아무것도 안 함 (중요)

여기가 가장 오래 고민한 지점이다. TLS를 어디서 소유하게 할 것인가?

 

결론: 차트는 TLS-agnostic 유지한다. TLS는 Gateway layer 책임이다.

  • 차트에 Certificate CR 템플릿을 넣지 않는다 → cert-manager 강제 의존을 회피하고 Gateway API 철학 위배를 방지한다
  • 차트에 Secret/secretName values를 넣지 않는다 → Gateway layer가 소유한다
  • `httproute.parentRefs[].sectionName` + `httpsRedirect.enabled` 옵션만 유지한다 → 앱별로 listener 선택과 HTTPS 강제 여부만 토글한다
 

 

 

이렇게 하면 나중에 registrar를 옮기거나 별도 DNS 서비스로 이관해 cert-manager를 도입해도 전환 비용이 거의 0이 된다.

  • Gateway 변경 0 (Secret 이름/ns 동일 유지)
  • 차트 변경 0 (애초에 Secret을 모른다)
  • 앱 values 변경 0
  • 작업량: cert-manager ca issuer + Certificate CR 1개를 추가한다. 기존 Secret을 덮어쓰도록 설정하면 자동 갱신을 획득한다

 

수동 갱신 부담도 확정적으로 감소했다. 앱별 self-signed Secret 여러 장 → wildcard 1장(10년 유효)으로 단일화된다.

 

 

 

 

 

통일 vs 보존 방침

"이제 wildcard가 있으니 모든 앱에 `sectionName: https` + `httpsRedirect: true` 통일하자"는 거절했다. 이유는 세 가지다.

  1. cert-manager 전환 편의성은 Secret 이름/ns 고정만으로 이미 확보된다 — sectionName 통일과 무관하다
  2. 내부 API 클라이언트가 301/307 redirect를 안정적으로 따르는지 앱마다 검증하는 부담이 발생한다
  3. 외부 포트 포워딩 경로는 redirect로 깨진다 (RequestRedirect는 scheme만 바꾸고 host/port는 유지되기 때문이다)

 

원칙은 "기존 Ingress 동작 보존"이다. HTTP였던 앱은 HTTP 유지, HTTPS 강제였던 앱만 HTTPS 강제로 간다.

 

 

 

 

 

 


 

 

 

 

 

 

6. Phase 6 Cutover: 실전 트러블슈팅

여기가 가장 많이 배운 구간이다. 11개 클래스를 영향도 낮은 순으로 cutover하고, 프로덕션 핵심 클래스를 마지막에 처리했다.

 

 

원래 가정

처음엔 단순하게 가정했다.

  1. `ingress-nginx controller replicas=0` 으로 축소 → IP 해제된다
  2. NginxProxy의 loadBalancerIP를 실 IP로 수정 → `helmfile apply`

 

첫 클래스부터 연이어 문제가 터졌다. 총 8개 이슈를 발견·해결한 과정을 정리한다.

 

 

 

 

이슈 1: replicas=0으로는 MetalLB IP가 해제되지 않는다

`kubectl scale deploy/<release>-controller --replicas=0` 으로 pod를 0으로 만들어도 MetalLB가 IP를 계속 점유한다. pool 포화 상태라 NGF가 같은 IP를 받을 수 없었다.

 

 

원인: `externalTrafficPolicy: Cluster` + `spec.loadBalancerIP` 지정 + pool 포화 조합에서는 Service 객체가 살아있는 한 MetalLB가 IP를 반환하지 않는다. pod 유무는 무관하다.

 

해결: Service를 ClusterIP로 `patch` 하고 loadBalancerIP 필드를 명시적으로 제거한다.

kubectl patch svc <release>-controller -n ingress-nginx --type=json -p='[
  {"op":"replace","path":"/spec/type","value":"ClusterIP"},
  {"op":"remove","path":"/spec/loadBalancerIP"}
]'
  • 이걸 `cutover.sh` Step 2로 영구화했다.

 

 

 

 

 

이슈 2: helmfile apply로 manifests/ 변경이 적용 안 된다

`manifests/nginxproxies.yaml` 에서 IP만 수정하고 `helmfile apply` 를 돌렸는데 `hook` 이 실행되지 않았다.

 

 

원인: helmfile은 chart 자체 diff가 없으면 `release-level postsync hook` 을 건너뛴다. `manifests/` 파일은 chart 밖 raw kubectl 대상이라 helmfile 관점에선 변경 없음으로 판단된다.

 

해결: manifest-only 변경은 `kubectl diff` -f + `kubectl apply -f` 를 직접 사용한다.

kubectl diff -f manifests/nginxproxies.yaml
kubectl apply -f manifests/nginxproxies.yaml
  • 이후 이 문제를 구조적으로 해결하기 위해 `manifests/` 디렉토리를 로컬 CR chart로 리팩터했다.
  • 이제 `helmfile diff` 가 Gateway/NginxProxy IP 변경까지 완전 커버한다. 이 차트는 Phase 7+ 정리 단계에서 공개 Helm 차트로 별도 분리해 배포했다. 자세한 내용은 7.5절에서 다룬다.

 

 

 

 

 

이슈 3: ValidatingWebhookConfiguration이 Ingress UPDATE/DELETE를 막는다

첫 클래스 cutover 후 ArgoCD prune이 Ingress를 삭제하려는데 2분 timeout이 걸렸다.

 

 

원인: `<release>-admission ValidatingWebhookConfiguration(VWC)` 이 살아있는데, endpoint Service는 방금 ClusterIP로 patch됐고 pod는 0개다. 즉 admission webhook 호출이 무응답 → Ingress UPDATE/DELETE가 전부 실패한다(ArgoCD prune 포함).

 

해결: cutover 과정에서 VWC를 선삭제한다.

kubectl delete validatingwebhookconfiguration <release>-admission
  • 선제 삭제가 필수다. 안 하면 후속 이슈 4까지 연쇄 발생한다.

 

 

 

 

이슈 4: foregroundDeletion finalizer stuck

첫 클래스에서 Ingress 리소스가 삭제 명령 후에도 Terminating 상태로 영구 대기했다.

 

 

원인: `ArgoCD cascade delete` 가 `foregroundDeletion finalizer` 를 부여하는데, GC가 dependent 없는 Ingress인데도 제거하지 못한다.

 

해결: finalizer를 직접 제거한다.

kubectl get ingress <name> -n <ns> -o jsonpath='{.metadata.finalizers}'
# [foregroundDeletion] 확인되면:
kubectl patch ingress <name> -n <ns> -p '{"metadata":{"finalizers":null}}' --type=merge
  • 핵심 관찰: 이슈 3(VWC 선삭제)를 먼저 처리하면 이 finalizer stuck 자체가 발생하지 않는다. 이후 클래스에선 재발이 없었다. 근본 원인은 VWC, finalizer stuck은 그 증상 중 하나였던 셈이다.

 

 

 

 

이슈 5: 내부 pod → 퍼블릭 도메인 → LB IP 경로가 2분 timeout된다

여섯 번째 클래스 cutover에서 발견했다. 내부 CI generator 스테이지가 멈추기 시작했다. 외부 클라이언트에선 정상, 클러스터 내부 pod → LB IP 경로만 깨졌다.

 

 

원인: NGF chart default로 `NginxProxy.kubernetes.service.externalTrafficPolicy: Local` 이다. 내부 pod → LB IP 경로는 kube-proxy가 해당 노드에 local endpoint가 있을 때만 포워딩한다. NGF 데이터플레인 pod가 `replica=1` 이라 대부분 노드에서 miss가 발생했다. 기존 ingress-nginx는 Cluster였기에 영향이 없었다.

 

해결: 11개 NginxProxy에 `service.externalTrafficPolicy: Cluster` 를 명시한다.

apiVersion: gateway.nginx.org/v1alpha1
kind: NginxProxy
metadata:
  name: ngf-public-f
  namespace: nginx-gateway
spec:
  kubernetes:
    service:
      type: LoadBalancer
      externalTrafficPolicy: Cluster   # ← 이 한 줄
      loadBalancerIP: <real-ip>
  • Trade-off 기록: ETP: Cluster는 client source IP를 SNAT한다. HTTPRoute backend에서 원본 IP가 필요하면 `X-Forwarded-For` 헤더를 사용해야 한다(NGF 기본 주입).
  • 컨테이너 레지스트리 audit log의 source_ip 컬럼처럼 client IP 기반 기능은 XFF 기반으로 재설정 확인이 필요하다.

 

 

 

 

이슈 6: 라우터 ARP 캐시

MetalLB pool이 포화 상태라 한 임시 IP가 외부 클라이언트에서만 RST, 내부에선 정상인 이상한 현상이 있었다.

 

원인: 라우터의 stale ARP 캐시다.

 

해결: 실 IP로 cutover되면서 자동 해결됐다. 임시 IP의 ARP 엔트리는 캐시 만료되면서 정리됐다.

 

부가 발견: 라우터 UI를 실측한 결과 모든 포워드 규칙이 MetalLB LB IP:80 기반이었다. Phase 6에서 동일 IP를 NGF에 재할당하므로 라우터 설정 변경 불필요, 자동 전환이 된다. 포워드 규칙 전부 무수정이었다.

 

 

 

 

 

이슈 7: ArgoCD values 토글 누락 시 Ingress 재생성

ApplicationSet 앱에서 cutover 후 `ingress.enabled: false` 로 내리지 않으면 ArgoCD가 Ingress를 계속 재생성한다.

 

 

해결: cutover Step 5에 영구 등록한다.

# applicationset/values/<project>/<service>/<env>.values.yaml
ingress:
  enabled: false   # Phase 6 cutover 후 필수
httproute:
  enabled: true
  # ...

 

 

변경 커밋·푸시 후 ArgoCD refresh를 즉시 트리거하려면 다음을 쓴다.

kubectl annotate application <name> -n argocd \
        argocd.argoproj.io/refresh=normal --overwrite
  • 기본 auto-sync polling이 3분 주기라 수 분간 반영이 안 되는 것처럼 보이는 함정을 피할 수 있다.

 

 

 

 

 

이슈 8: cutover.sh smoke 출력이 "HTTP 2000"으로 깨져 보인다

`$(curl ... || echo "0")` 에서 curl이 성공했을 때 `success stdout` 과 `echo "0"` 가 합쳐져 이상한 출력이 나왔다.

 

해결: `${CODE:-000}` 기본값과 `HTTP_CODE` 분리 검사로 정리했다. 스크립트 이슈라 사소하지만, 한밤중 cutover 중 출력 혼란을 일으키는 타입의 버그라 기록해둘 만하다.

 

 

 

 

최종 cutover 절차 (cutover.sh 요약)

위 8개 이슈를 모두 반영한 클래스당 표준 절차다.

  1. 대기 확인: `kubectl get svc -n ingress-nginx <release>-controller -o wide`
  2. ingress-nginx Service → ClusterIP patch + loadBalancerIP 제거 (이슈 1)
  3. `ingress-nginx deployment replicas=0`
  4. `manifests/nginxproxies.yaml` 해당 Gateway IP를 실 IP로 수정
  5. `kubectl diff -f manifests/nginxproxies.yaml` → `kubectl apply -f` (이슈 2)
  6. ApplicationSet values / infra values에서 `ingress.enabled: false` 토글 (이슈 7)
  7. VWC 선삭제: `kubectl delete validatingwebhookconfiguration <release>-admission` (이슈 3)
  8. MetalLB 재할당 확인 + smoke test (`curl --resolve <host>:80:<real-ip>`)
  9. finalizer stuck 잔존 Ingress 있으면 패치로 제거한다 (이슈 4)
  10. 24시간 안정 모니터링한다. 비핵심 클래스는 다음 클래스 cutover 병행이 가능하다. 핵심 클래스만 1~2일 누적 관측 후 진입한다

 

실 다운타임은 Step 2 patch 실행 ~ Step 5 NGF Service IP swap 완료까지 약 30초~1분 구간만 해당한다.

 

 

아래의 레포를 참고해보길 바란다.

https://github.com/somaz94/network/tree/main/nginx-gateway-fabric

 

 

 

 

 


 

 

 

 

 

7. 결과 및 교훈

 

 

최종 상태

지표 Before After
helm release 11 (ingress-nginx) 1 (NGF)
Ingress 리소스 24개 0개
HTTPRoute 리소스 0 15개
Gateway CR 0 11개
MetalLB 할당 IP 동일 규모 유지 동일 규모 유지
self-signed TLS Secret 앱별 개별 1 (wildcard)
cert 갱신 주기 앱별 개별 10년 1장
  • MetalLB pool은 cutover 완료 후 축소 조정했다. 검증용 임시 IP 대부분을 반납하고 여유 IP를 소량 남겼다.

 

 

 

 

Phase 7+ 후속 작업

원래 1주 대기 후 정리할 예정이었던 작업들을 당일 연속 처리했다. 판단 근거는 "롤백 비용은 drift 상태든 destroyed 상태든 동일(`helmfile apply` 1회)"였다.

  • ingress-nginx 11개 `helm release helmfile destroy` 를 완료한다 (namespace까지 clean)
  • `network/ingress-nginx/` → `network/_optional/ingress-nginx/` 로 이동한다 (git mv + README에 archive 상태 명시)
  • MetalLB pool을 축소한다
  • NGF manifests/ → local CR chart로 전환한다 (helm ownership 라벨/annotation 수동 부여 후 adopt)
  • 컨테이너 레지스트리를 chart-native HTTPRoute로 전환한다 (zero-downtime 4단계)
  • per-app self-signed TLS Secret을 폐기한다

 

 

 

chart-native HTTPRoute 전환은 zero-downtime 기법이 재미있었다.

 

새 route를 먼저 생성해 기존과 동일 hostname 중복 상태로 공존시키고(older-wins 규칙으로 기존이 트래픽 유지), 새 Policy는 TargetConflict 상태로 대기한다. 기존 HTTPRoute 삭제 순간 conflict 해소 → 새 `Policy Accepted=True` 로 즉시 전환된다.

 

트래픽이 transparent하게 swap된다.

 

 

 

 

 

 

 

7.5 nginx-gateway-cr 차트 공개 배포

Phase 7+에서 가장 공 들인 작업은 manifests/ 디렉토리를 로컬 Helm 차트로 리팩터한 뒤, 그 차트를 아예 공개 저장소로 분리 배포한 것이다.

 

 

배경. 내부 `manifests/` 디렉토리 방식은 이슈 2(`helmfile apply` 가 chart 자체 diff 없으면 hook skip)를 피하기 위해 `kubectl apply -f` 로 우회해야 했다. 구조적으로 helmfile 파이프라인 밖에 있는 리소스가 된다. Phase 6 cutover 중 IP swap할 때마다 `kubectl diff` + `kubectl apply` 를 수동으로 섞어야 하는 게 불편했다.

 

 

왜 공개로 분리했나.

  • NGF 자체 차트는 OCI registry로 활발히 릴리스되고 있고 템플릿 20개 이상을 수동 추적하기엔 부담이 크다
  • 우리가 override하려는 건 NGF 내부 템플릿이 아니라 NGF 컨트롤러가 reconcile하는 별도 CR들(Gateway, NginxProxy)이다
  • 이 리소스들은 내 환경만의 특수성이 없다 — "Gateway + NginxProxy(LB IP pin) + ServiceMonitor"는 per-Gateway 멀티테넌시가 필요한 모든 NGF 사용자에게 공통적이다
  • 내부 레포에만 둘 이유가 없고, Helm 차트 관습대로 공개 저장소에 두는 게 버전 관리·재사용·문서화 모두에 유리하다

차트 저장소: https://github.com/somaz94/helm-charts/tree/main/charts/nginx-gateway-cr

 

 

 

 

차트가 생성하는 리소스

  • Gateway — 11개든 1개든 values에 배열로 선언한 만큼 생성된다. 각 Gateway는 `gatewayClassName: ngf` 기본값을 따른다
  • NginxProxy — 각 Gateway의 `infrastructure.parametersRef` 대상. `service.loadBalancerIP` 로 MetalLB/클라우드 LB의 고정 IP를 pin한다. `externalTrafficPolicy: Cluster` 기본값 (이슈 5 반영)
  • ServiceMonitor — NGF chart가 생성해주지 않는 Prometheus 메트릭 수집용 리소스. Prometheus Operator 설치된 환경에서 바로 수집 가능하다

 

 

 

 

사용 방법 (helmfile 기준)

# helmfile.yaml.gotmpl (두 release를 needs로 연결)
repositories:
  - name: somaz
    url: https://charts.somaz.blog

releases:
  - name: nginx-gateway-fabric
    namespace: nginx-gateway
    chart: oci://ghcr.io/nginx/charts/nginx-gateway-fabric
    version: 2.5.1
    # ... NGF 컨트롤러 설정

  - name: nginx-gateway-cr
    namespace: nginx-gateway
    chart: somaz/nginx-gateway-cr
    version: <chart-version>
    needs:
      - nginx-gateway/nginx-gateway-fabric
    values:
      - values/cr.yaml

 

 

values 예시 (per-Gateway 멀티테넌시 시나리오):

# values/cr.yaml — 실제 필드명은 차트 README 확인
gateways:
  - name: ngf
    loadBalancerIP: <main-ip>
    listeners:
      - name: http
        port: 80
        protocol: HTTP
      - name: https
        port: 443
        protocol: HTTPS
        tls:
          certificateRefs:
            - name: wildcard-tls
  - name: ngf-public-a
    loadBalancerIP: <public-a-ip>
    # ...

 

 

이 차트가 맞는 사용자

  • per-Gateway 멀티테넌시가 필요한 경우 — 즉 Gateway마다 별도 LoadBalancer IP를 할당하고 싶은 환경
  • 기존 ingress-nginx 멀티 인스턴스 패턴(ingressClass별 분리)에서 이관하려는 경우
  • 베어메탈/온프레미스 + MetalLB 조합에서 IP를 수동 pin해야 하는 경우
  • ServiceMonitor까지 한 번에 자동 생성되길 바라는 경우

 

 

이 차트가 오버킬인 사용자: Gateway 1개만 필요하거나 클라우드 LB를 동적 할당 받는 게 문제없는 환경이라면, NGF 공식 차트의 nginxGateway.gateway 섹션만으로도 충분할 가능성이 높다. v2.5부터 NGF 자체 차트도 Gateway provisioning을 어느 정도 지원한다.

 

 

 

얻은 것

  • `helmfile diff` 하나로 Gateway/NginxProxy IP 변경까지 전부 볼 수 있다
  • cutover가 "values 한 줄 수정 → helmfile apply" 루틴으로 축소된다
  • 내부 manifests/ 디렉토리 + kubectl 수동 조합이 사라진다
  • 다음 클러스터에서 같은 패턴을 재사용할 때 values만 갈아끼우면 된다

 

 

 

처음부터 공개 차트로 만들었다면 Phase 1~6도 조금 더 깔끔했을 것이다. 하지만 실전에서 `manifests/` → local chart → 공개 chart 순서로 진화시킨 덕분에 "어느 추상화 수준에서 어떤 문제가 해결되는지"를 단계별로 확인할 수 있었다.

 

결과물만 놓고 보면 공개 차트부터 시작하는 게 맞겠지만, 그 결과물에 도달하기까지는 앞선 두 단계가 필요했다.

 

 

 

 

 

 

 

배운 것들

설계 가정은 문서보다 먼저 의심해야 한다. "11 인스턴스니까 11 GatewayClass"는 NGF 2.x 공식 문서를 제대로 안 읽은 채 기존 멘탈 모델에 끼워 맞춘 결과였다. 실측 전에 구현체별 제약을 1차 소스로 확인하는 습관이 필요하다.

 

병렬 모드의 가치는 롤백이 아니라 심리적 안전감이었다. 실제로 병렬 모드 기간 중 롤백을 trigger한 적은 없다. 하지만 "토글 하나로 되돌릴 수 있다"는 사실이 있었기에 금요일 저녁에도 cutover를 진행할 수 있었다. 이 점은 다음 전환 작업에서도 반복할 가치가 있다.

 

문제의 80%는 cutover 1시간에 몰려 있다. 설계, 차트 작성, 병렬 모드 검증은 상대적으로 평온했다. 실제 IP swap 시점에 이슈 1~6이 연달아 터졌고, 특히 이슈 5(ETP: Local)는 내부 서비스가 특정 시간 동안 timeout을 내는 silent failure라 발견이 늦었을 수 있다. Cutover 시점에 CI, webhook, cross-service 호출 같은 내부 트래픽 모니터링을 함께 걸어두는 게 좋다.

 

ETP 같은 기본값 하나가 운영 전체를 바꾼다. NGF chart default가 ETP: Local인 건 보안·성능 관점에서 합리적 기본값이지만, 기존 ingress-nginx Cluster 환경에서 옮겨오면 내부 호출 경로가 silent하게 깨진다. 전환 도구의 default와 기존 환경의 가정 차이를 항목별로 체크하는 표를 만들어두는 게 다음엔 더 좋겠다.

 

TLS 소유권은 Gateway layer에 둬야 한다. 차트에 cert/secret values를 넣고 싶은 유혹이 강하다. 참아야 한다. Gateway API의 리소스 경계 철학을 따르면, 차트는 HTTP 라우팅만 알고 TLS는 모르는 게 맞다. 이 원칙 덕분에 cert-manager 미래 전환 시 차트 코드 변경 0으로 끝낼 수 있다.

 

 

 

 

 


 

 

 

마무리글

ingress-nginx → nginx-gateway-fabric 마이그레이션은 단순히 새 컨트롤러로 갈아타는 작업이 아니었다. Kubernetes의 L7 라우팅 모델이 Ingress 시대의 "한 덩어리"에서 Gateway API 시대의 "명확한 리소스 경계"로 진화한다는 사실을, 11개 인스턴스를 옮기면서 체감하는 과정이었다.

 

결과적으로 helm release 11개가 1개로, 개별 self-signed cert가 wildcard 1장으로, 흩어져 있던 어노테이션이 CRD 기반 정책으로 정리됐다. 외부에서 보면 DNS도 방화벽도 그대로지만, 내부 운영 부담은 뚜렷하게 줄었다.

 

특히 병렬 모드 전략TLS Gateway layer 소유 원칙은 다른 전환 작업에도 그대로 재활용할 수 있는 패턴이 됐다. 병렬 모드는 롤백 비용을 values 토글 수준으로 낮췄고, TLS 경계 분리는 cert-manager로의 미래 전환 비용을 0에 가깝게 만들었다.

 

 

Phase 6의 이슈 1~8은 공식 문서 어디에도 정리되어 있지 않은, 실제 운영 환경에서만 드러나는 함정들이었다. 특히 externalTrafficPolicy: Local 기본값 차이는 cutover 한참 뒤에 드러날 수도 있었던 silent failure라 가장 아찔했다. 비슷한 전환을 앞둔 분들이 이 글을 읽고 최소 1시간은 아낄 수 있기를 바란다.

 

 

다음 과제는 X-Forwarded-For 기반 audit log 복원과 Gateway API v1.3 신규 기능(특히 GRPCRoute 정식 활용) 검토다. 이것도 기록으로 남길 예정이다.

 

 

 

 

 

 

 

 


Reference

 

 

 

Somaz | DevOps Engineer | Kubernetes & Cloud Infrastructure Specialist

728x90
반응형