본문 바로가기
DevOps, SRE

Community ingress-nginx 에서 F5 NIC 로 - 사전에 정의한 가설의 절반은 첫 배포에서 깨졌다

by Ramos 2026. 5. 21.

사내 솔루션 BE 차트들이 올라가는 환경 중 하나가 새로 신설되고 있다. 기존 운영 중인 alpha 환경의 공통 클러스터 는 커뮤니티 ingress-nginx(이하 Community) 위에 있고, 이건 클러스터 운영 정책상 F5 NIC 로 갈아낄 수 없는 구조다. 새로 들어오는 beta 환경의 공통 클러스터 는 처음부터 F5 NGINX Ingress Controller (이하 F5 NIC, v5.3.4) 위에 깔린다. 그 말은 한 차트가 두 컨트롤러를 동시에 만족시켜야 한다는 뜻이다. 이 글은 사전 작업 + 오늘(2026-05-21) beta 환경 첫 배포 실전 보고서다. 같은 "NGINX 기반" 이라는 이름값에 속아서 처음엔 얼마나 가볍게 봤다가, 사전 작업 때 어디서 멈칫했고, 막상 첫 배포에서는 그 사전 가설 중 어디가 깨졌고 어떤 새 함정이 튀어나왔는지에 대한 기록이다.

Intro

  • Community → F5 NIC 는 "표준 Ingress 호환" 만 보장한다. 어노테이션 90% 는 1:1 치환이 아니라 개념적 재작성이 필요하다. prefix(nginx.ingress.kubernetes.io/*nginx.org/*) 만 바꾸면 동작할 거란 직감은 두 번째 어노테이션에서 깨졌다.
  • 동일 host 에 Ingress 가 2개 이상이면 Community 는 자동 머지하지만 F5 NIC 는 server { } 블록 충돌 로 conf reload 자체가 거부된다. 한 host 에 BE 차트 4개를 매단 구조라 beta 환경에서는 mergeable master / minion 그룹화가 강제됐고, 마스터 전용 차트가 새로 분리됐다.
  • 사전 작업 때는 "F5 NIC 는 Hop-by-hop 헤더(Upgrade / Connection) 를 자동 전달하지 않는다" 라고 가정하고 websocket-services + proxy-set-headers 6종을 같이 박아뒀다. 첫 배포에서 admission webhook 이 proxy-set-headers$http_upgrade 를 거부했다. 정답은 websocket-services 한 줄만 두는 것websocket-services 가 명시된 서비스의 location 블록에 NIC 가 자동으로 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; 를 박는다는 사실을 그때 알았다.
  • alpha 환경은 F5 NIC 로 마이그레이션 불가다. 그래서 한 템플릿이 두 컨트롤러를 동시에 만족하도록, mergeable.role 토글 하나만 추가하고 미설정 시 기존 동작 100% 유지를 회귀 가드로 박았다. alpha 환경 영향은 0이어야 한다.
  • 사전 검증 중 BE 가 보던 클라이언트 IP 가 NIC pod IP 로 들어오는 걸 확인했다. X-Forwarded-For 신뢰 정책을 Community 시절 "자동 신뢰" 그대로 옮기지 않고 의도적으로 비활성 상태로 결정한 이유와 그 운영적 함의도 같이 적어둔다.
  • 첫 배포 당일 새로 잡은 함정 2개 - FE pod 의 nginx 가 BE 로 hairpin 호출할 때 (1) proxy_ssl_server_name on; 미설정으로 NIC 가 SNI 매칭 실패 → SSL alert 112 → 502, (2) proxy_set_header X-Forwarded-Proto $scheme 가 FE pod listen 80 환경에서 XFP=http 로 박혀 NIC redirect-to-https 가 무한 루프를 만들었다. 둘 다 values 가 아니라 FE docker container 의 nginx.conf.template 를 손대야 풀렸다.
  • fix 위치는 의도적으로 인프라(NIC) 가 아니라 애플리케이션(FE nginx) 으로 정했다. NIC ingress annotation 으로 redirect-to-https 전역 끄기나 ConfigMap 글로벌 옵션으로도 우회 가능했지만, 같은 NIC 를 쓰는 다른 ingress + alpha 환경 회귀 표면적이 같이 커진다. FE 의 hairpin location 6 곳에 두 줄 박는 게 "alpha 환경 영향 0" 원칙의 연장선상에서 최소 범위 였다.
  • 첫 배포 검증 완료. 이 글은 그 직후 정리한 기록이고, 다음 환경 신설 시점의 나에게 보내는 체크리스트다.

1. 출발점 - "같은 NGINX 인데 뭐가 다르겠어" 라는 안일함

분명히 둘 다 NGINX 다. 둘 다 Ingress 리소스를 읽는다. 둘 다 IngressClass: "nginx" 로 식별된다. 그래서 beta 환경 values 분기 작업을 시작할 때 처음 떠올린 그림은 단순했다.

"nginx.ingress.kubernetes.io/proxy-body-size 의 prefix 만 nginx.org/client-max-body-size 로 바꾸면 되겠지. 키 이름 매핑표 한 장 만들어두고 sed 한 번 돌리면 끝."

그 그림은 두 번째 어노테이션에서 깨졌다.

  • proxy-buffering: "off" → F5 NIC 에선 proxy-buffering: "false". 값 타입이 다르다. "off" 를 그대로 두면 NIC 는 에러도 안 내고 조용히 무시한다.
  • proxy-connect-timeout: "3600" → F5 NIC 에선 proxy-connect-timeout: "3600s". 단위 표기가 다르다. s 안 붙여도 받아주긴 하지만 일관성이 깨진다.
  • ssl-redirect: "true" → F5 NIC 에선 redirect-to-https: "True" + server-level. mergeable 환경에서는 master 에만 올려야 한다. minion 에 두면 무시된다.
  • affinity: cookie (sticky session) → F5 NIC OSS 에선 아예 미지원. NGINX Plus 의 sticky-cookie-services 로만 표현 가능.

여기까지 표로 정리하다가 손을 멈췄다. 매핑표를 sed 로 돌릴 수 있다는 전제부터가 틀렸다. 이건 "키 이름 변환" 이 아니라 각 어노테이션의 의미를 다시 한 번 해석해서 다른 모델로 옮겨 쓰는 작업 이다.

2. 공존 전략 - alpha 환경은 Community 로 묶여 있다는 사실에서 출발

beta 환경 신설을 한다고 해서 alpha 환경을 같이 F5 NIC 로 통일하는 빅뱅 안은 처음부터 옵션이 아니었다. alpha 환경의 공통 클러스터는 구조적으로 F5 NIC 로 갈아낄 수 없는 상태이기 때문이다. 그 결정은 내 손을 떠난 영역의 제약이었고, 마이그레이션 작업의 출발점은 "둘 다 살아 있는 한 차트가 두 컨트롤러를 동시에 만족해야 한다" 였다.
그래서 원칙을 이렇게 박았다.

원칙 의미
공존이 전제, 빅뱅은 옵션 아님 alpha 환경은 Community 그대로, beta 환경은 처음부터 F5 NIC. 한 차트의 템플릿이 두 컨트롤러를 동시에 만족해야 함.
values 분기로만 흡수 템플릿 분기를 최소화. ingress.mergeable.role 토글 하나로 동작 차이를 흡수.
회귀 가드의 핵심 role 미설정 시 기존 동작 100% 유지. alpha 환경 values 로 렌더한 결과가 작업 전후로 bit-identical 인지 항상 확인.
사람이 직접 작성 같은 어노테이션이라도 prefix 가 다르므로 템플릿에서 자동 치환하지 않는다. 환경별 values 를 사람이 직접 쓴다. 매직 치환은 디버깅 비용이 더 비싸다.

alpha 환경 쪽 그림은 평소대로 차트별 1 Ingress. beta 환경 쪽은 한 host 에 BE 4개가 동거하니 master 1 + minion 4 패턴이 강제됐다. 이걸 받아내려고 별도의 master 전용 차트(host·TLS·server-level annotation 만 책임지는 빈 껍데기)를 새로 만들었다.

3. 첫 번째 함정 - mergeable Ingress

선행 작업 중 가장 비싼 비용을 낸 부분이 mergeable 이다. Community 에서는 의식한 적 없는 개념이라 처음 부딪힐 때 한참 헤맸다.

3.1 Community 가 "조용히 자동으로" 해주던 일

Community 는 동일 host 를 가진 Ingress 가 여러 개 존재하면 자동으로 머지해서 하나의 server { } 블록으로 만들어준다. 차트별로 자기 host 를 그냥 들고 있어도, path 만 안 겹치면 알아서 합쳐졌다. alpha 환경에서는 그게 당연한 줄 알고 있었다.

3.2 F5 NIC 의 입장

F5 NIC v5.x 는 같은 host 를 가진 Ingress 가 두 개 이상이면 conf reload 가 거부된다. NGINX 의 server { } 블록 1개 제약을 그대로 들이대기 때문이다. 그래서 어느 Ingress 가 master 인지, 어느 게 minion 인지 명시해야 한다.

3.3 또 하나의 함정 - 어노테이션이 위치를 탄다

mergeable 그룹 안에서는 어노테이션마다 master 에만 적용 가능한 것 / minion 에만 적용 가능한 것 이 갈린다.

어노테이션 타입 적용 가능 위치 대표 예
server-level master 에만 (minion 에 두면 무시) redirect-to-https, server-snippets, ssl-services
location-level minion 또는 단일 Ingress 에만 (master 에 두면 무시) proxy-*, client-max-body-size, websocket-services

Community 시절에는 한 Ingress 에 다 박아도 됐다. beta values 를 짤 때 기존 어노테이션을 두 그룹으로 분류하는 것 이 사실상 첫 작업이었다. 사전 검증 중 한 번은 websocket-services 를 master 차트에 박아두고 한참 동안 "왜 WS handshake 가 안 잡히지" 를 디버깅했는데, 답은 단순했다. F5 NIC 가 그걸 조용히 무시하고 있었다. 로그조차 안 남아서 비싼 시간이었다.

3.4 토글 하나로 흡수

템플릿은 ingress.mergeable.role 토글 하나만 더했다. 미설정/"" 이면 기존 단일 Ingress 그대로(= alpha 환경 동작), "minion" 이면 mergeable annotation 자동 주입 + tls 블록 미렌더(= beta 환경 동작). 회귀 가드는 단순하다. alpha values 로 helm template 돌렸을 때 결과가 작업 전과 bit-identical 인가. 이게 보장되면 alpha 환경 영향은 0 이다.

4. 두 번째 함정 - Hop-by-hop 헤더, 그리고 사전 가설이 깨진 지점

beta 환경 차트를 처음 dry-run 으로 돌릴 때, alpha 환경에서 잘 되던 WebSocket 화면을 그대로 옮겼다가 101 Switching Protocols 가 안 뜨는 걸 발견했다. 처음엔 BE 코드를 의심했다. 한 시간쯤 뒤집어보다가 NIC 문서를 다시 읽고 멈칫했다.

Community 가 사용자 편의를 위해 내부적으로 자동 주입하던 부분

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

F5 NIC 는 이걸 모든 location 에 일괄로 처리하지 않는다. RFC 7230 6.1 의 Hop-by-hop 헤더는 hop 마다 끝나는 게 원칙이라는 입장을 더 엄격하게 지키는 쪽이다. WebSocket / SSE / HTTP/2 upgrade 가 전부 영향권이다.

여기서 사전 작업 때 박은 가설이 그래서 proxy-set-headers 어노테이션으로 Upgrade/Connection 헤더를 명시 선언해야 한다였다. 6개를 기재했다.

# 사전 작업 때 박았던 형태 (실제 배포에서 admission webhook 에 막혔다)
nginx.org/websocket-services: "<be-svc>"
nginx.org/proxy-set-headers: |
  Upgrade $http_upgrade
  Connection "upgrade"
nginx.org/proxy-read-timeout: "3600s"
nginx.org/proxy-send-timeout: "3600s"
nginx.org/proxy-connect-timeout: "3600s"
nginx.org/proxy-buffering: "false"

4.1 첫 배포에서 깨졌다

오늘 beta 환경 첫 배포 때 F5 NIC admission webhook 이 위 Ingress 를 거부했다. 에러 메시지는 명료했다. nginx.org/proxy-set-headers 의 값 Upgrade $http_upgrade 가 형식 위반.

원인을 짚어보니 proxy-set-headers정적 헤더 전용 어노테이션이다. 헤더명은 정규식 [-A-Za-z0-9]+ 만 받고, 값은 NGINX 변수($http_upgrade 같은 $ 시작 토큰) 와 공백을 허용하지 않는다. 즉 Upgrade $http_upgrade 를 거기에 적는 건 문법상 잘못된 사용이다.

그럼 어떻게 박아야 할까. 답은 한 줄이었다. websocket-services 만 두는 것.

F5 NIC 는 nginx.org/websocket-services: "<svc>" 가 박힌 service 의 location 블록에 자동으로 다음 세 줄을 주입한다.

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

websocket-services이미 자동 주입을 책임지고 있었고, 거기에 내가 proxy-set-headers 로 같은 헤더를 또 박으려 했던 게 잘못이었다. 사전 작업 때 "F5 NIC 는 Hop-by-hop 헤더를 자동 전달하지 않는다" 라는 명제는 글로벌 location 에선 맞지만, websocket-services 가 명시된 service location 에 한정해서는 틀렸다.

4.2 실제 적용된 형태

proxy-set-headers 의 Upgrade/Connection 두 줄을 제거하고 websocket-services 한 줄만 남기는 형태로 WS 를 쓰는 관련 차트 3개 (BE 1개 + FE 1개 + BFF 1개) 의 beta/prod values 6 파일을 정정했다.

# 실제 배포에 들어간 형태 (proxy-set-headers 제거)
nginx.org/websocket-services: "<be-svc>"     # 이 한 줄이 Upgrade/Connection 자동 주입
nginx.org/proxy-read-timeout: "3600s"
nginx.org/proxy-send-timeout: "3600s"
nginx.org/proxy-connect-timeout: "3600s"
nginx.org/proxy-buffering: "false"

websocket-services 값에 들어가는 서비스명도 함정이 하나 있다. values 의 backend.service.name 을 그대로 적으면 안 되고, 헬름 템플릿이 실제로 렌더한 서비스명 (예: {{ include "...fullname" $ }}-svc) 을 적어야 한다. 렌더 결과 기준 으로 적는다.

4.3 교훈

사전 가설이 틀렸다기보단 절반만 맞았다. "자동 전달 안 한다" 는 글로벌 정책이고, websocket-services 가 명시된 service 에 한정해서는 NIC 가 알아서 location 블록에 주입한다. 사전에 도큐먼트를 더 정밀히 읽었으면 잡혔을 부분.

이 한 단락이 이번 작업에서 가장 비싼 교훈이다. 첫 배포에서 admission webhook 이 에러로 띄워줬으니 망정이지, NIC 가 그냥 조용히 무시했으면 디버깅 비용이 훨씬 컸을 함정이었다.

5. 첫 배포 당일 새로 잡은 함정 - FE pod nginx 의 hairpin 두 가지

여기서부터는 사전에 전혀 고려 못 했던, 첫 배포 때 실제 트래픽이 들어가고 나서야 잡힌 두 함정이다. 둘 다 BE values 가 아니라 FE Docker 이미지 안의 nginx.conf.template 를 손대야 풀렸다.

배경: 이 시스템의 FE 는 SPA + 자체 nginx 가 들어간 docker container 다. 브라우저가 FE 로 들어오면, FE 의 nginx 가 /member/, /portal-management/, /kube-management/ 같은 API path 를 같은 클러스터의 BE 호스트로 hairpin proxy_pass 하는 구조다. 즉 FE pod nginx → (cluster-internal) → F5 NIC → BE pod 로 다시 들어간다.

이 hairpin 경로에서 NIC v5.3.4 의 두 가지 디폴트 동작이 문제를 만들었다.

5.0 fix 위치 결정 - 인프라(NIC) 가 아니라 애플리케이션(FE nginx) 에서 푼다

원인이 NIC 의 두 디폴트 동작에서 비롯되긴 했지만, fix 를 박을 위치는 의도적으로 NIC 가 아니라 FE 의 nginx.conf.template 로 정했다. 같은 증상을 인프라 레벨에서 끌 수도 있었다. 예를 들면.

  • NIC 의 ingress annotation nginx.org/redirect-to-https: "False" 로 전역 redirect 자체를 꺼버리기
  • NIC ConfigMap 에 XFP 신뢰 옵션을 켜거나 끄는 형태로 글로벌 정책 손대기
  • NIC 측에 별도 server-snippet 을 박아 hairpin 패턴만 분기 처리

세 옵션 모두 동작은 한다. 하지만 영향 범위가 너무 넓다.

  • redirect-to-https: "False" 는 클라이언트가 cleartext http://... 로 들어왔을 때의 자동 redirect 까지 같이 꺼져서 보안 정책 결정을 건드리게 된다. hairpin 한 가지 증상을 끄려고 ingress 전체 보안 동작을 바꾸는 셈.
  • ConfigMap 글로벌 옵션은 같은 클러스터의 다른 ingress 에까지 영향이 번진다. 다른 팀 ingress 의 회귀까지 같이 검증해야 한다.
  • server-snippet 분기는 NIC 가 자동 생성하는 템플릿 결과에 사람이 if 를 끼워넣는 형태라, NIC 업그레이드 시 깨지기 쉽고 운영 부담이 늘어난다.

반면 FE nginx 의 hairpin location 두 줄(X-Forwarded-Proto https; + proxy_ssl_server_name on;) 은 다음 기준을 모두 만족한다.

기준 NIC 쪽에서 풀기 FE nginx 쪽에서 풀기
영향 범위 클러스터/ingress 전체 hairpin location 6 개만
alpha 환경 회귀 위험 별도 검증 필요 FE nginx.conf 는 alpha 환경도 같은 이미지 - 같이 검증되고 community 에서도 회귀 없음(검증 완료)
다른 팀/타 서비스 영향 같은 NIC 를 쓰는 모든 ingress 0 (FE container 단독)
NIC 업그레이드 시 깨질 위험 있음 (annotation/template 동작 변경 가능) 없음 (nginx 표준 디렉티브)
롤백 비용 보안 정책 영향으로 역결정 필요 두 줄 revert 만
의도의 가시성 annotation 한 줄로 흩어짐 default.conf 의 location 안에 코멘트와 함께 명시

즉 "인프라 레벨을 무작정 수정하기보다 애플리케이션 레벨에서 푸는 게 최소 범위의 판단" 이라는 결론이었다. 이건 단순히 hairpin 함정의 fix 위치 선택이라기보단, 공존 전략의 "alpha 환경 영향은 0" 원칙의 연장선이다. NIC 측을 건드리는 순간 alpha 환경과 다른 서비스까지 회귀 표면적이 커지고, 그건 이번 작업이 처음부터 피하려 했던 방향이다.
이 결정을 하고 나서 앞선 5.1, 5.2 내용의 실제 fix 가 들어갔다.

5.1 함정 ① - SNI strict ( 누락)

증상. FE → BE hairpin 호출이 전부 502. FE pod 의 nginx upstream 로그에 다음.

SSL_do_handshake() failed (SSL: error:0A000458:SSL routines::tlsv1
  unrecognized name:SSL alert number 112)

원인. nginx proxy_pass디폴트로 백엔드 TLS handshake 에 SNI 를 보내지 않는다. F5 NIC 는 SNI 가 누락되면 cert 매칭에 실패하고 TLS alert 112 (unrecognized_name) 를 돌려준다.

alpha 환경의 community ingress-nginx 는 같은 SNI 누락 상황에서 default cert fallback 으로 살아있었다. 그래서 사전 검증 단계에서는 안 잡혔다. F5 NIC 가 더 엄격하다.

조치. FE Docker 이미지의 apps/main/nginx.conf.template 의 6개 hairpin location 모두에 한 줄 추가.

location ^~ /kube-management/ {
    proxy_pass https://<be-host>/kube-management/;
    proxy_ssl_server_name on;   # ← 이 한 줄
    proxy_http_version 1.1;
    proxy_set_header Host $proxy_host;
    ...
}

proxy_ssl_server_name on; 만 켜면 nginx 는 디폴트 proxy_ssl_name $proxy_host 를 SNI 로 자동 전송한다. 별도 SNI 값을 박지 않아도 proxy_pass 의 호스트가 그대로 들어간다. alpha 환경 (community ingress) 에서도 동일 적용 후 TLS 1.3 handshake 정상 / 200 OK 확인 → 회귀 없음.

5.2 함정 ② - 가 만든 redirect-to-https 무한 루프

5.1 의 SNI fix 직후, BE 까지 트래픽 도달은 정상화됐다. 그런데 브라우저에 새 증상.

HTTP/1.1 301 Moved Permanently
Location: http://<api-host>/member/api/auth/login-url

https://<console-host>/ 로 들어왔는데 응답이 http://<api-host>/... 로 떨어지고, 브라우저가 그걸 따라가면 NIC 가 다시 https 로 보내고, 그게 다시 FE 의 hairpin 으로 들어가 또 같은 301... 무한 루프.

원인 분석에 한 시간 정도 들어갔다. 결론은 세 가지 동작의 합작이었다.

(1) F5 NIC 가 자동 생성한 server { } 블록에 두 개의 redirect if 가 OR 로 박혀있다

redirect-to-https: "True" 일 때 NIC 가 ingress 마다 만들어내는 conf 를 NIC pod 안에서 직접 꺼내보면 다음 두 블록이 같이 들어있다.

server {
    listen 80;
    listen 443 ssl;
    server_name <api-host>;

    # (a) cleartext 진입용 — 일반적인 http→https redirect
    if ($scheme = http) {
        return 301 https://$host:443$request_uri;
    }
    # (b) ★ hairpin trigger — XFP 만 봐도 redirect
    if ($http_x_forwarded_proto = 'http') {
        return 301 https://$host$request_uri;
    }
    location / { ... }
}

여기서 핵심은 (b) 블록. NIC 는 $scheme=https 여도, 즉 외부 진입이 정상 TLS 였어도 X-Forwarded-Proto 헤더가 http 면 별개의 if 로 또 검사해서 redirect 를 만든다. 두 if 가 OR 인 셈이다. 이걸 끌 수 있는 ConfigMap 옵션은 없고 (전역 redirect-to-https: "False" 로 둘 다 꺼버리는 것만 가능), 비활성 토글이 따로 없다.

(2) FE pod 는 listen 80 으로만 받는다

TLS 는 NIC 에서 종단되고 FE 로는 http 로 들어온다. 그래서 FE pod 안에서 $scheme = http 다. 기존 nginx.conf 는 proxy_set_header X-Forwarded-Proto $scheme; 였으니, hairpin 할 때 XFP=http 가 박혀나갔다.

(3) hairpin 2nd hop 에서 위 (b) 가 발화

FE pod 가 proxy_pass https://<api-host>/... 로 호출하면 cluster 내부에서 자기가 속한 NIC LB 로 재진입한다. 2nd hop 에서 NIC 는 $scheme=https (외부 TLS) 인 채로 (a) 는 통과하지만, FE 가 박은 XFP=http 를 보고 (b) 가 매치 → 301. 그 301 응답이 FE 의 proxy_redirect default 를 거쳐 host + scheme 둘 다 다운그레이드되어 클라이언트로 돌아간다.

왜 alpha 환경의 community 에서는 같은 코드가 멀쩡했나

community ingress-nginx 의 redirect 결정은 lua 함수 redirect_to_https() 안에서 pass_access_scheme 라는 단일 변수만 검사한다. pass_access_scheme 는 ConfigMap use-forwarded-headers: true 일 때만 XFP 를 신뢰하고, default 값(false) 에서는 $scheme 만 본다. 즉 community 는 default 에서 XFP 헤더를 redirect 판정에 아예 쓰지 않는다. 그래서 alpha 환경의 FE → BE hairpin 에서 같은 X-Forwarded-Proto $scheme 가 들어가도 redirect 가 안 만들어졌던 것이고, beta 환경 신설 전 검증 단계에서 이 함정이 안 잡혔던 진짜 이유다.

항목 F5 NIC OSS 5.3.4 community ingress-nginx (default)
redirect 트리거 변수 $scheme $http_x_forwarded_proto (두 if 가 OR) pass_access_scheme 단일 (default 에서는 $scheme 만)
XFP 신뢰 여부 항상 신뢰 (템플릿에 if 박힘, 비활성 옵션 없음) use-forwarded-headers: true 일 때만 신뢰 (default false)
$scheme=https 인데 XFP=http 케이스 301 redirect 발생 redirect 미발생
끄는 방법 전역 redirect-to-https: "False" 만 가능 (개별 옵션 없음) use-forwarded-headers: false 가 default 라 기본 안전

흐름을 정리하면.

Browser ──https──> NIC ──http──> FE pod (listen 80, $scheme=http)
   ↑                                  │
   │                                  └─ proxy_pass https://<be-host>/...
   │                                     with: X-Forwarded-Proto: http
   │                                            ↓
   │                                          NIC (master ingress)
   │                                            ↓ XFP=http 보고 redirect 발동
   │                                          301 Location: https://<be-host>/...
   │                                            ↓
   │           FE 의 `proxy_redirect default` 가 host (<be-host>→<console-host>)
   │           + scheme (https→http) 양쪽 다 다운그레이드
   │                                            ↓
   └── Browser 에 301 Location: http://<console-host>/... 전달
       → 브라우저가 http 로 따라감 → NIC 가 다시 https 로 → 무한 루프

FE pod 내부 curl 로 단독 검증.

XFP 헤더 응답
X-Forwarded-Proto: http 301 + Location: https://<api-host>/...
X-Forwarded-Proto: https 200 OK
헤더 없음 200 OK

조치. FE Docker 이미지의 nginx.conf.template 의 6개 hairpin location 의 XFP 헤더를 $scheme 가 아니라 https 로 고정.

location ^~ /kube-management/ {
    proxy_pass https://<be-host>/kube-management/;
    proxy_ssl_server_name on;
    proxy_http_version 1.1;
    proxy_set_header Host $proxy_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;   # $scheme → https 강제
    ...
}

검증 : 외부 진입은 NIC SSL 종단 후 FE pod 에 항상 http 로 도달하지만, client→NIC 구간은 항상 https 이므로 BE 가 알아야 할 "원래 scheme" 은 https 가 맞다. alpha 환경 (community ingress) 도 외부 진입이 항상 https 라 의미가 동일 → 회귀 없음.

5.3 교훈

values 만 손대면 끝날 줄 알았던 마이그레이션이, 실은 FE Docker 이미지 안의 nginx.conf 까지 들어가 두 줄을 박아야 풀리는 작업이었다. SNI 와 XFP 모두 "Community 가 너그러워서 살아 있던 것" 이라는 공통점이 있다. 두 함정 다 사전 작업 때 잡히지 않았다.

다만, 앞선 섹션 5 의 결정처럼 FE Docker 이미지까지 손을 댄 것이 "values 보다 더 깊이 들어간 작업" 으로 보이지만, 사실은 NIC 쪽 (annotation/ConfigMap/snippet) 을 건드리는 것보다 영향 범위가 훨씬 작은 선택이었다. 같은 NIC 를 쓰는 다른 ingress 와 alpha 환경의 회귀 표면적을 건드리지 않고, FE container 두 줄로 닫힌다.

특히 NIC 가 listen scheme 검사 외에 XFP 검사용 if 를 별도로 또 박아둔다는 사실 은 공식 docs 에 옵션 설명으로 한 줄 적혀있을 뿐, 템플릿 레벨까지 들여다보지 않으면 안 보인다. NIC pod 의 /etc/nginx/conf.d/<...>.conf 를 직접 꺼내봤을 때 두 if 가 같이 들어있는 것을 확인하고 나서야 무한 루프의 원인이 명확해졌다.

5.4 더 깨끗한 장기 대안 - 그러나 지금은 채택 안 함

근본적으로 보면 이 두 함정은 "FE 가 외부 도메인을 hairpin 으로 다시 NIC 로 보내는 토폴로지" 자체에서 비롯된다. FE 가 같은 클러스터 안의 BE 를 호출할 때 굳이 외부 도메인 → NIC LB → NIC → BE 로 재진입할 게 아니라, in-cluster Service FQDN (http://<be-svc>.<be-namespace>.svc.cluster.local/...) 으로 직접 호출하면 NIC 재진입이 사라지고 SNI / XFP / redirect-to-https 함정 셋 다 한 번에 풀린다.

그런데도 이번 배포에서 그 길을 안 택한 이유는 두 가지다.

  • alpha 환경의 community 환경과 코드 일관성을 유지해야 한다. FE 의 location 매핑을 svc 좌표로 갈아끼우면 환경별 분기가 늘어나고, alpha 환경 회귀 검증 비용이 또 한 번 든다.
  • 사업장 prod 별로 cluster 토폴로지가 달라질 가능성이 있다. svc DNS 가 항상 같은 형태로 노출된다는 보장이 약하다.

그래서 지금은 FE nginx.conf 두 줄(X-Forwarded-Proto https; + proxy_ssl_server_name on;) 박는 것 으로 두고, 장기적으로 alpha 환경 의존이 사라지고 사업장 토폴로지가 표준화되는 시점에 svc FQDN 직접 호출로 마이그레이션하는 걸 로드맵에 박아뒀다.

6. 세 번째 함정 - X-Forwarded-For 신뢰가 기본값이 아니다

사전 검증 단계에서 BE 가 보는 클라이언트 IP 가 NIC pod IP 로 들어오는 걸 확인했다. alpha 환경의 Community 에서는 ConfigMap 의 use-forwarded-headers: "true" 같은 옵션으로 X-Forwarded-For 가 자동 신뢰되어 진짜 클라이언트 IP 가 들어왔는데, F5 NIC 는 기본값이 다르다.

여기서 한 번 고민했다. 그냥 alpha 환경 시절 동작을 그대로 들고 갈 것인가, 아니면 이 기회에 신뢰 정책을 명시적으로 결정할 것인가.
들고 가는 쪽이 편하다. 짧은 시간으로 끝난다. 그런데 결정을 안 한 상태로 활성화하면 다음과 같은 위험이 따라온다.

  • 외부에서 임의로 X-Forwarded-For 헤더를 넣어 보내면 그게 BE 로 그대로 들어간다. IP allowlist / 로깅 / 감사 로직이 클라이언트가 조작 가능한 값을 신뢰하게 된다.
  • beta 환경 토폴로지에 따라 NIC 앞단에 L4 LB / WAF / 리버스 프록시가 더 있을 수도 없을 수도 있다. 어디까지가 신뢰 영역인지 결정 안 하면 set-real-ip-from CIDR 을 어디까지 적어야 할지도 모호해진다.
  • OIDC 콜백, 헤어핀(loopback) 트래픽 같은 코너 케이스가 X-Forwarded-* 와 어떻게 상호작용하는지 BE 별로 다시 검증해야 한다.

결국 결정은 명시 비활성. README 의 "환경별 정책" 표와 각 환경 values 의 ingress annotation 코멘트에 "X-Forwarded-* 신뢰는 의도적으로 활성화하지 않음" 을 박아두고, BE 측은 getRemoteAddr() 자리에 NIC pod IP 가 들어온다는 사실을 전제로 IP 사용 로직을 정리했다 (server.forward-headers-strategy 점검 포함).

지금 시점에서 돌아보면, beta 환경 신설할 때 "alpha 환경에서 자동으로 되던 동작을 그대로 들고 가지 않는다" 는 결정 한 줄이 이 작업에서 가장 중요한 의사결정 중 하나였다. 자동 신뢰는 편하지만 책임이 분산되고, 사고가 났을 때 "왜 그렇게 동작하지" 의 답이 컨트롤러 디폴트라는 위치로 흩어진다.

7. 그 외 잔 함정들 - 다음 환경 신설 때 자가 점검용

사전/배포 작업하면서 한 번 이상 직접 빠뜨렸던 것들. 다음번 환경 신설할 때 아래 체크리스트는 반드시 염두에 둬야할 것 같다.

  • proxy-buffering 값을 "off""false" 로 바꾸지 않고 그대로 두기. NIC 가 에러 없이 조용히 무시한다.
  • timeout 값에 s 안 붙이기. 받아주긴 하는데 다른 값들과 표기가 엇갈려서 PR 리뷰에서 매번 지적된다.
  • master 차트 만들고 ArgoCD Application 등록 누락 → 새 host owner 가 클러스터에 안 올라옴. mergeable 그룹 미성립 시 minion 들이 전부 무시된다.
  • mergeable.role: minion 만 추가하고 tls 블록 안 지우기. 일부 차트는 가드가 박혀 있지만 신규 차트 합류 시 따로 점검 필요.
  • WebSocket 어노테이션을 master 에 박기. location-level 이라 NIC 가 조용히 무시한다. 로그에 안 남아서 디버깅 비용이 최악.
  • nginx.org/proxy-set-headers$http_upgrade / $connection_upgrade 같은 변수 박기. admission webhook 에서 거부된다. websocket-services 한 줄이면 자동 주입이 끝난다.
  • FE pod 의 nginx hairpin location 에 proxy_ssl_server_name on; 누락. F5 NIC 는 SNI strict 라 502. community ingress 에서는 default cert fallback 으로 살아있어서 사전 검증 통과.
  • FE pod 의 nginx hairpin 에 X-Forwarded-Proto $scheme 그대로 두기. FE listen 80 이라 XFP=http → NIC redirect-to-https 가 무한 루프.
  • alpha 환경 values 까지 같이 수정해버리기. Community 는 nginx.org/* 를 무시하므로 동작은 하지만, 의도 불명확한 코드가 남는다. git diff -- '*/values-alpha*.yaml' 로 항상 회귀 검사.
  • rewrite / regex capture 사용 화면이 있다면 NIC 의 rewrites 어노테이션은 정규식 캡처가 없다. 복잡하면 VirtualServer CRD 로 가야 할 수도.

이번 첫 배포에서는 NIC 함정 외에 차트 자체의 잔 함정도 같이 잡혔다. 본 마이그레이션 범위는 아니지만 같이 메모.

  • BE values 의 DB 사용자명을 환경 간 검증 없이 복붙. alpha 환경의 admin 을 beta 환경에 그대로 넣었더니 Flyway 초기화에서 password authentication failed for user "admin" 으로 crash loop. beta DB 실제 사용자명은 postgres 였다. 환경별 DB 자격증명은 첫 기동 전에 한 번 SELECT 로 직접 확인.
  • ArgoCD sync-wave annotation 을 RBAC 리소스(ClusterRole / ClusterRoleBinding) 에 박아둔 채로 두기. wave 게이팅으로 의도치 않은 sync 지연이 생긴다. 필요 없으면 빼는 게 낫다.

8. 검증 절차 - "조용히 무시" 가 가장 무섭다

이번 작업에서 배운 가장 큰 운영적 교훈은 F5 NIC 는 잘못된 어노테이션을 케이스별로 다르게 다룬다는 점이다.

  • conf 가 깨질 정도면 reload 가 거부되고 기존 conf 로 운영을 계속한다. → 모니터링 없으면 놓친다.
  • admission webhook 단계에서 잡히는 잘못된 값(proxy-set-headers$변수 같은 것) 은 친절하게 에러를 띄워준다. → 그나마 빠르게 잡힌다.
  • 키 이름이 다르거나 값 타입이 다른 정도는 그냥 무시하고 지나간다. → 사람은 "어, 박았는데 왜 안 되지" 단계에서 한참 헤맨다. 이게 제일 비싸다.

그래서 검증 단계는 3-Layer 로 짰다.

  • L1: alpha 환경 회귀 가드. helm template 결과가 작업 전후로 같지 않으면 alpha 환경 클러스터로 가서 회귀 사고가 난다. 이게 1차 게이트.
  • L2: F5 NIC 가 mergeable 그룹을 인식했는지 확인. master 의 kubectl describe ing Events 에 minion 추가 메시지가 떠야 정상. admission webhook 거부는 이 단계에서 표면화됐다 (proxy-set-headers).
  • L3: 실제 트래픽. WS 의 101 헤더는 브라우저 DevTools Network 탭에서 즉시 확인된다. BE 로그의 클라이언트 IP 가 의도대로 (NIC pod IP) 들어오는지도 같이 본다. SNI 502 와 redirect 무한 루프는 정확히 이 단계에서 잡혔다. 사전 dry-run 에서 안 보이던 게 실제 TLS 와 실제 listen-80 환경에서 노출됐다.

NIC pod 의 nginx -t 실패는 reload 만 실패하고 기존 conf 로 계속 운영되기 때문에 모니터링이 없으면 놓친다. Reload 빈도(nginx_ingress_nginx_reloads_total) + 에러 카운트는 Grafana 패널로 따로 노출해뒀다. alpha 환경에서 Community 기반으로 만들어두었던 대시보드는 메트릭 이름·라벨이 호환되지 않아서 beta 환경용으로 별도 재작성이다.

9. 보강 로드맵 / 결론

첫 배포 시점의 결론은 단순하다. "같은 NGINX" 라는 표면만 보고 환경 신설을 가볍게 보지 말 것. 어노테이션은 prefix 변환이 아니라 의미 재해석이고, mergeable 은 자동에서 명시로 옮겨가는 작업이며, Hop-by-hop 헤더와 X-Forwarded-For 는 사용자 편의에서 운영자 책임으로 권한이 이동한다. 그리고 values 만으로 안 끝나고 FE docker container 의 nginx 까지 들어가야 풀리는 함정(SNI, XFP) 이 따라온다.
사전 가설 점검 결과로 보면.

사전에 박았던 가설 첫 배포 결과
어노테이션 prefix 1:1 치환은 어렵다, 의미 재해석 필요 ✅ 그대로 사실
mergeable master/minion 패턴 강제 ✅ 그대로 사실
Hop-by-hop 헤더 자동 전달 안 함 → proxy-set-headers 로 명시 선언 ⚠️ 절반만 사실websocket-services 가 명시된 service location 에서는 NIC 가 자동 주입. proxy-set-headers 는 정적 헤더 전용이라 변수 사용 시 admission 거부.
X-Forwarded-For 신뢰 정책 명시 비활성 ✅ 그대로 사실
(사전엔 없던 항목) FE pod nginx 의 SNI / XFP hairpin 처리 🆕 첫 배포에서 새로 발견 - Docker 이미지 수정.

그 다음 단계로 단계적으로 풀려는 부분은 세 가지.

  1. FE → in-cluster Service FQDN 직접 호출로 마이그레이션. 앞서 적은 장기 대안. FE 가 외부 도메인 hairpin 을 쓰는 한 SNI / XFP / redirect-to-https 의 세 함정은 잠재적으로 살아있다. svc 좌표로 갈아끼우면 NIC 재진입 자체가 사라져 셋 다 같이 해소된다. alpha 환경 의존 + 사업장 토폴로지 표준화 합의가 선행 조건.
  2. VirtualServer / VirtualServerRoute CRD 로의 단계 이행 검토. rewrite regex capture 가 필요한 화면이 생기거나 sticky / rate limit 같은 정책 요구가 들어오면 더 미룰 수 없다. 다만 alpha 환경과 차트를 공유 못 하게 되므로 alpha 환경 의존이 사라지는 시점에 맞춰 진입.
  3. ConfigMap 영역의 글로벌 정책 정리. annotation 만으로 표현 못 하는 워커 프로세스 수, keepalive timeout, real-ip CIDR 같은 키들은 NIC 설치 차트와 BE 차트 사이의 책임이 모호한 상태다. 누가 무엇을 소유하는지 한 번 처리해야 한다.

이 글이 "F5 NIC 마이그레이션이 끝났다" 는 보고서가 아니라 "새 환경을 F5 NIC 위에 신설할 때 무엇이 비싸고 무엇이 조용히 무시되고 어디서 사전 가설이 깨지는지" 의 합본 기록으로 다음 환경 신설 시점의 나에게 전달되면 좋겠다. 다음번에 또 한 번 proxy-set-headers$http_upgrade 를 박아 admission 거부를 보고 싶지 않고, FE pod 의 nginx 에서 SNI 두 글자가 빠져 502 를 또 한 시간 디버깅하고 싶지도 않다.

References