쿠버네티스 Ingress Nginx 설정 완전판: TLS·경로 라우팅·Rate Limit·Canary 실전

목차

쿠버네티스 Ingress Nginx 설정은 단순히 헬름 차트 한 줄로 끝나는 작업이 아니다. ingress-nginx는 NGINX를 백엔드로 쓰는 쿠버네티스 공식 Ingress 컨트롤러로, 외부 트래픽을 클러스터 내부 서비스로 라우팅하는 L7 게이트웨이 역할을 한다. 트래픽이 몰리는 환경에서는 TLS 종료, 경로 기반 분기, Rate Limiting, Canary 분기까지 다 이 한 컴포넌트로 처리하게 된다(2026년 3월 시점, ingress-nginx v1.11.x 기준).

이 글은 스테이징 클러스터에 ingress-nginx를 처음 깔다가 503을 마주한 뒤로 운영 단계까지 끌고 가면서 부딪힌 케이스들을 모은 것이다. helm install 한 줄로 끝나는 줄 알았는데 그 다음 주에 알람이 울렸다. 이유부터 정리한다.

기본 설치를 그대로 믿었다가 503이 떨어진 이유

특히, 처음에는 공식 가이드대로 따라갔다. 헬름 레포 추가하고, ingress-nginx/ingress-nginx 차트를 기본값으로 설치했다. 파드는 떴고, kubectl get svc -n ingress-nginx도 LoadBalancer 외부 IP를 정상으로 잡았다. 그래서 끝난 줄 알았다.

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

# 처음에 그냥 이렇게만 깔았다 — 이게 문제의 시작
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx --create-namespace

그런데, 문제는 트래픽이 들어오기 시작한 뒤였다. 내부 어드민 페이지로 파일 업로드를 했더니 413 Request Entity Too Large가 떴다. 그 다음 외부 사용자 한 명이 잘못된 헤더를 보내자 502가 반복됐다. 가장 황당했던 건 백엔드 파드가 멀쩡한데 503이 나오는 케이스였다.

게다가, 원인은 세 가지가 겹쳐 있었다.

첫째, 기본 proxy-body-size가 1m으로 잡혀 있었다. 이미지나 PDF 업로드가 통과할 리 없다. 둘째, keep-alive 타임아웃과 백엔드 파드의 keepAliveTimeout이 어긋나 있었다. NGINX가 보낸 재사용 연결을 백엔드가 먼저 끊으면 502가 난다. 셋째, 파드 readiness 프로브가 너무 헐겁게 잡혀 있어 종료 중인 파드로 트래픽이 들어갔다.

기본값을 바꾸려면 ConfigMap을 건드려야 한다. 헬름 values.yaml로 정리하면 이렇다.

controller:
  config:
    proxy-body-size: "20m"
    proxy-connect-timeout: "10"
    proxy-read-timeout: "60"
    proxy-send-timeout: "60"
    keep-alive: "75"
    keep-alive-requests: "1000"
    use-forwarded-headers: "true"
    enable-real-ip: "true"
    log-format-escape-json: "true"
    log-format-upstream: '{"time":"$time_iso8601","remote_addr":"$remote_addr","request":"$request","status":$status,"upstream":"$upstream_addr","upstream_status":"$upstream_status","upstream_time":"$upstream_response_time","request_time":$request_time}'
  service:
    externalTrafficPolicy: Local   # 클라이언트 IP 보존
  metrics:
    enabled: true
    serviceMonitor:
      enabled: true

externalTrafficPolicy: Local은 클라이언트 IP를 보존하기 위한 설정인데, 부작용으로 파드가 없는 노드는 트래픽을 받지 못한다. 그래서 ingress-nginx 컨트롤러는 보통 DaemonSet 또는 노드 수와 비슷한 replica로 깔아두는 게 무난하다. 작은 클러스터에서는 Cluster로 두고 X-Forwarded-For로 IP를 받아도 된다(개인적으로 노드 수가 6대 이하면 Cluster가 운영이 더 편한 것 같다).

TLS 자동 갱신 — cert-manager가 해주지만 발급은 한 번 막혔다

다음으로 막힌 건 TLS였다. Let’s Encrypt 인증서를 수동으로 갱신하는 건 운영에서 곧바로 사고로 이어진다. cert-manager(v1.14.5 기준)를 쓰면 ACME 챌린지를 자동으로 처리한다. 설치 자체는 간단하다.

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.5/cert-manager.yaml

실제로, ClusterIssuer를 만들고, Ingress 리소스에 cert-manager.io/cluster-issuer 어노테이션만 붙이면 된다. 이론은 그렇다.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: nginx
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["api.example.com"]
      secretName: api-example-com-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api
                port:
                  number: 80

그래서, 여기서 첫 발급이 막혔다. kubectl describe certificate api-example-com-tls를 보면 Waiting for HTTP-01 challenge propagation만 반복됐다. 한 시간을 들여다본 끝에 원인을 찾았는데, 별 것 아니었다. 챌린지용 Ingress가 만들어질 때 NGINX의 default backend로 빠지면서 80 포트가 막혀 있었다. 클라우드 LB가 80을 안 받게 잡혀 있던 거다.

한편, ACME HTTP-01 챌린지는 무조건 80 포트로 들어온다. LB의 80 포트를 열고 ingress-nginx 서비스로 흘려보내야 한다. 보안상 80을 닫고 싶으면 DNS-01 챌린지로 바꿔야 한다. DNS-01은 도메인 DNS에 TXT 레코드를 다는 방식이라 클라우드 DNS API 권한이 필요하다.

자동 갱신 확인은 두 가지로

cert-manager는 만료 30일 전부터 자동 갱신을 시도한다. 운영에서는 이게 정말 도는지 두 군데에서 확인해야 안심이 된다.

# 인증서 만료일과 상태
kubectl get certificate -A
kubectl describe certificate api-example-com-tls -n default

# Order/Challenge 리소스 추적
kubectl get challenges,orders -A

Challenges가 계속 pending이면 ACME 서버가 외부에서 우리 도메인을 못 찾는 거다. 보통 DNS 캐시 또는 LB 라우팅 문제다.

경로 기반 라우팅 — annotation 한 줄로 흐름이 깨진다

즉, 같은 도메인 아래에서 /api는 백엔드 API로, /는 SPA 정적 호스팅으로 보내는 구성은 흔하다. 경로 기반 라우팅은 Ingress 스펙의 핵심 기능이지만 NGINX의 path rewrite 동작을 모르면 한참 헤맨다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-ingress
  annotations:
    nginx.ingress.kubernetes.io/use-regex: "true"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: api
                port:
                  number: 8080
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

rewrite-target을 잘못 쓰면 백엔드가 /api/users 대신 /users를 받게 된다. 이게 백엔드 라우터 입장에선 다른 경로다. 처음 이 설정을 그대로 복사해 썼다가 백엔드에서 404가 폭발했다(체감상 30분 내내 같은 에러). 정규식 캡처 그룹을 의식하지 않고 rewrite-target: /로 두면 모든 요청이 루트로 가버린다. 자세한 동작은 공식 user guide의 rewrite 섹션이 정확하다.

pathType 세 가지의 의미

pathType 매칭 방식 정규식 사용
Exact 정확히 일치 불가
Prefix 경로 세그먼트 단위 prefix 불가
ImplementationSpecific 컨트롤러 구현에 따름 NGINX는 정규식 가능

따라서, 정규식을 쓰려면 ImplementationSpecific을 쓰고 use-regex: "true"를 켜야 한다. Prefix/api//api2를 매칭하지 않는다. 세그먼트 단위라서 그렇다(이걸 모르고 Prefix가 prefix-string 매칭인 줄 알면 또 헤맨다).

백엔드 종류가 다르면 ingress를 쪼개라

API와 웹을 같은 Ingress에 넣어도 동작은 한다. 다만 백엔드별로 필요한 어노테이션이 갈리면(예: API는 큰 body, 웹은 캐시 헤더) Ingress를 두 개로 쪼개는 게 운영에 편하다. 어노테이션은 Ingress 단위로 적용되고, path 단위로 다른 어노테이션을 주려면 별도 Ingress가 필요하다.

Rate Limiting — 한 사용자가 다 잡아먹는 경우

또한, 운영하다가 한 IP가 초당 수백 건씩 요청을 보내는 케이스가 한 번은 온다. ingress-nginx는 ConfigMap이나 어노테이션으로 간단한 IP 기반 Rate Limit을 걸 수 있다.

metadata:
  annotations:
    nginx.ingress.kubernetes.io/limit-rps: "20"
    nginx.ingress.kubernetes.io/limit-rpm: "600"
    nginx.ingress.kubernetes.io/limit-connections: "10"
    nginx.ingress.kubernetes.io/limit-burst-multiplier: "5"

limit-rps는 초당 평균 20 요청, limit-burst-multiplier: 5는 순간적으로 100 요청까지 허용한다는 뜻이다. 초과분은 503이 아니라 503의 변형 503 Service Temporarily Unavailable로 떨어진다. 정확히는 NGINX의 limit_req 모듈이 만들어내는 응답이다.

이처럼, 여기서 한 번 데였다. externalTrafficPolicy: Local을 안 쓰는 환경에서 limit-rps를 걸어두면 클라이언트 IP가 LB 노드 IP로 들어와서 모든 트래픽이 같은 IP로 보인다. 그러면 첫 사용자가 RPS를 다 채우고 나머지는 전부 차단된다. 클라이언트 IP 보존 설정이 안 되어 있으면 Rate Limit은 거의 의미가 없다.

설정 키 의미 권장 시작값
limit-rps 초당 평균 요청 수 백엔드 capacity의 30%
limit-rpm 분당 평균 요청 수 rps × 60의 70%
limit-connections 동시 연결 수 10~50
limit-burst-multiplier 버스트 배수 3~5

API 키나 사용자 ID 단위로 제한을 걸려면 ingress-nginx의 IP 기반 제한으로는 한계가 있다. 그땐 API Gateway 계층(Kong, APISIX 등)을 앞에 두거나 Lua 스니펫을 직접 작성해야 한다(Lua 스니펫은 v1.9부터 기본적으로 비활성화되어 별도 옵션으로 켜야 한다, GitHub #10393 관련).

Canary 배포 — 트래픽 5%만 신버전으로

ingress-nginx는 nginx.ingress.kubernetes.io/canary 계열 어노테이션으로 Canary 배포를 직접 지원한다. 별도 서비스 메시 없이도 트래픽 비율을 나눌 수 있다는 게 장점이다.

# 안정 버전 Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-stable
spec:
  ingressClassName: nginx
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-v1
                port:
                  number: 80
---
# Canary Ingress — 같은 host, 같은 path
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-canary
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "5"
spec:
  ingressClassName: nginx
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-v2
                port:
                  number: 80

canary-weight: "5"는 5% 트래픽을 v2로 보낸다. 모니터링 대시보드에서 v2의 5xx 비율이 안정적이면 weight를 25, 50, 100으로 올리면 된다. 한 번에 100으로 올리면 그 시점부터 100%가 신버전을 받는다.

헤더 기반 Canary — QA부터 흘려보낼 때

metadata:
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-by-header: "X-Canary"
    nginx.ingress.kubernetes.io/canary-by-header-value: "always"

X-Canary: always 헤더가 붙은 요청만 v2로 간다. 내부 QA가 자기 브라우저 익스텐션으로 헤더를 박고 신버전을 검증한 다음, 비율 기반 Canary로 넘기는 흐름이 안전하다. 헤더 기반과 weight 기반은 동시에 켜져 있어도 헤더가 우선이다(헤더 매칭이 안 된 트래픽만 weight 룰을 탄다).

Canary에서 흔한 실수

그래서, 같은 host, 같은 path에 두 Ingress가 떠야 한다. 처음 Canary를 깔았을 때 host가 살짝 다르게 들어가서 트래픽이 전혀 안 갔다. canary-weight가 0이 아닌데 메트릭에서 v2 호출이 안 보이면 99% Ingress 매칭이 깨진 거다. kubectl describe ingress api-canary로 룰을 다시 확인하는 게 빠르다.

메트릭과 로그가 없으면 다 무용지물

ingress-nginx의 진짜 무서운 점은 잘 동작하는 것처럼 보이면서 한 구간이 죽는다는 점이다. 그래서 메트릭과 로그는 운영 들어가기 전 반드시 켜둬야 한다.

controller.metrics.enabled를 켜면 /metrics 엔드포인트가 열린다. Prometheus operator가 깔려 있으면 ServiceMonitor만 추가하면 된다. 핵심 지표는 다음 네 가지다.

  • nginx_ingress_controller_requests: 호스트·경로별 요청 수
  • nginx_ingress_controller_response_duration_seconds: 응답 시간 히스토그램
  • nginx_ingress_controller_response_size: 응답 크기
  • nginx_ingress_controller_ssl_expire_time_seconds: TLS 만료까지 남은 초

실제로, 마지막 지표는 cert-manager 자동 갱신을 믿더라도 알람으로 한 번 더 거는 게 좋다. 갱신이 실패해도 만료 1시간 전까지 알람이 안 온 적이 있다. 백업 알람이 그래서 필요하다.

즉, 로그는 위 values.yaml의 log-format-upstream처럼 JSON 형태로 떨어뜨리는 게 분석에 좋다. Loki나 ELK로 보내고 upstream_status, upstream_time 두 필드만 봐도 백엔드 파드의 진짜 상태를 알 수 있다. NGINX가 보는 status와 백엔드가 응답한 status가 다르면 retry가 일어났다는 뜻이다.

다 깔고 나서 다시 본 한 줄짜리 교훈

운영에 들어가서 터진 의외의 것은 RBAC도 아니고 인증서도 아니었다. 그라파나 대시보드가 너무 늦게 들어왔다는 것이다. 메트릭이 안 보이는 동안 503이 나도 누구 잘못인지 알 수 없었다. ingress-nginx는 처음부터 metrics.enabled: true와 ServiceMonitor를 같이 깔고, 알람 규칙은 인증서 만료, 5xx 비율, 백엔드 파드 수 0 세 개를 기본으로 잡아두는 게 운영 사고를 줄인다.

특히, 당장 손에 잡히는 액션은 세 가지다. 첫째, 지금 깔린 ingress-nginx의 ConfigMap에서 proxy-body-sizekeep-alive 값을 확인하고 기본값이면 위 values.yaml대로 바꿔라. 둘째, cert-manager의 Certificate 리소스 상태를 kubectl get certificate -A로 점검하고 만료 임박한 게 있는지 본다. 셋째, Canary용 Ingress 템플릿을 미리 만들어두고 다음 배포부터 weight 5%로 시작해라.

다만 NGINX가 모든 게이트웨이 요구를 다 커버하지는 못한다. 사용자 단위 Rate Limit, mTLS 인증, 복잡한 라우팅 트리가 필요하다면 Envoy 기반 Gateway API 구현체로 넘어가는 게 장기적으로 합리적인지 더 지켜봐야 한다.

관련 글