fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/devops/ingress-nginx 운영에서 부딪힌 디…
devops

ingress-nginx 운영에서 부딪힌 디테일들 — webhook, whitelist, affinity, 리소스 사양

ingress controller를 하나 추가하는 작업은 "차트 만들고 배포하면 끝"일 줄 알았다. 그런데 실제로는 그 과정에서 처음 보는 개념들에 계속 걸렸다. annotation으로 설정을 관리하는 방식, admission webhook이 만드는 self-lock 위험, whitelist, Pod 분산 배치, 그리고 리소스 사양까지. 하나하나는 작지만,...

2026.06.09·6 min read·92 views·SERIES · API Gateway를 제거하고 쿠버네티스로 직접 노출하기 · 5/7

ingress controller를 하나 추가하는 작업은 "차트 만들고 배포하면 끝"일 줄 알았다. 그런데 실제로는 그 과정에서 처음 보는 개념들에 계속 걸렸다. annotation으로 설정을 관리하는 방식, admission webhook이 만드는 self-lock 위험, whitelist, Pod 분산 배치, 그리고 리소스 사양까지. 하나하나는 작지만, 모르고 지나가면 운영에서 사고가 나는 것들이라 정리해둔다.

내부용과 외부용 controller를 분리한 배경은 외부 트래픽이 Pod까지 닿는 경로에 있고, 이 글은 그 후속으로 controller를 다루며 부딪힌 운영 디테일을 모았다.

annotation으로 설정을 관리하는 이유

Ingress YAML을 보면 metadata.annotations에 별별 설정이 다 들어간다.

yaml
metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 10m
    nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8

처음엔 "왜 설정을 spec이 아니라 annotation(메모)으로 넣지?"가 이상했다. 이유를 알고 나니 쿠버네티스의 설계 의도가 보였다.

쿠버네티스의 Ingress 표준 spec은 최소한만 정의한다. "어느 호스트의 어느 경로를 어느 Service로" 정도뿐이다. body size 제한, IP whitelist, 경로 rewrite, rate limit 같은 세부 동작은 표준 spec에 칸 자체가 없다.

그런데 실제 Ingress Controller는 제품마다(nginx, traefik, haproxy) 고유 기능이 천차만별이다. 이걸 전부 표준 spec에 넣을 수가 없다. 한 제품 기능을 표준에 박으면 다른 제품엔 안 맞으니까. 그래서 controller별 확장 설정을 annotation이라는 통로로 전달한다. nginx ingress controller는 nginx.ingress.kubernetes.io/로 시작하는 annotation을 읽어서 자기 내부 nginx.conf에 반영한다.

spec은 정해진 칸만 채우는 정식 신청서 양식이고, annotation은 거기 붙이는 포스트잇 메모다. 양식엔 칸이 없지만 담당자(controller)가 메모를 읽고 처리해준다.

이렇게 한 이유는 쿠버네티스가 벤더 중립을 지키려고 그런 거다. "Ingress"라는 표준 개념은 공통으로 두되, 각 controller의 고유 기능은 표준을 건드리지 않고 annotation으로 확장하게 했다. 다만 이 방식이 깔끔하지 않다는 불만이 쌓여서, 요즘은 Gateway API라는 차세대 표준이 이런 설정을 정식 필드로 흡수하려는 흐름도 있다. 아직은 ingress-nginx와 annotation 조합이 현업 주류지만.

admission webhook — 격리가 안 되는 검문소

이게 이번 작업에서 가장 직관에 어긋났던 부분이다.

admission webhook은 쿠버네티스에 리소스를 만들거나 바꾸려고 요청하면, API 서버가 저장하기 직전에 거치는 검문소다. ingress-nginx를 설치하면 이 검문소(ValidatingWebhookConfiguration)가 같이 깔린다. 역할은 검증이다. 잘못된 Ingress(nginx 설정으로 바꿨을 때 문법 오류가 나는 것)를 미리 거부해서, 깨진 설정 하나가 nginx 전체를 망가뜨리는 걸 막는다.

문제는 controller를 둘로 나눌 때 드러난다. controller는 IngressClass로 자기 것만 처리하지만, webhook은 그렇지 않다.

  • controller는 --ingress-class로 자기 class(nginx 또는 nginx-external)만 본다. 깔끔하게 격리된다.
  • 그런데 webhook은 cluster-scoped 리소스라서, class로도 namespace로도 격리되지 않고 클러스터 전체의 Ingress 요청을 가로챈다.

(이 "cluster-scoped"라는 성질은 쿠버네티스 핵심 객체 4종에서 다룬, namespace에 속하지 않는 전역 리소스 얘기와 같은 맥락이다.)

그래서 외부용 controller를 추가하면 webhook도 하나 더 생기는데, 이 새 webhook은 "외부 class만"이 아니라 모든 Ingress(내부 것 포함)를 검문한다. 여기서 사고 시나리오가 나온다.

  1. 외부 controller의 webhook Pod가 죽는다.
  2. 그런데 이 webhook은 모든 Ingress 요청을 검문하도록 등록돼 있다.
  3. 검문에 응답할 수 없으면, 정책에 따라 요청을 거부한다(failurePolicy: Fail).
  4. 결과적으로 내부 Ingress를 수정하려 해도 "webhook 응답 없음"으로 거부된다.
  5. 배포 도구가 Ingress를 반영하려다 줄줄이 실패한다 — self-lock의 변종이다.

그래서 외부 controller에는 admissionWebhooks.enabled: false로 webhook 자체를 안 만들었다. 검증을 한 겹 포기하는 셈이지만, 사전 검증은 기존 controller의 webhook이 어차피 클러스터 전체로 해주고 있으니(전역이라), 새 장애점만 안 생기고 내부 경로가 안전해진다. 검증 한 겹과 self-lock 위험을 맞바꾼 거고, 후자가 훨씬 무거웠다.

controller는 자기 구역만 배달하는 우편배달부고, webhook은 우체국 전체의 검열대다. 검열대를 하나 더 만들면, 그게 고장 났을 때 다른 구역 편지까지 전부 막힌다.

whitelist-source-range — 공인인데 평문일 때의 보호막

외부용 Ingress에는 출발지 IP를 제한하는 annotation을 걸었다.

yaml
nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8,203.0.113.5

이걸 건 이유는 단계 때문이다. 공인 LB로 열면 이론상 인터넷의 누구나 그 IP로 접근할 수 있다. 그런데 이 시점엔 아직 TLS를 안 붙여서 통신이 평문 HTTP다. 평문인 채로 인터넷 전체에 열면 요청·응답 내용이 그대로 노출되니 위험하다. (TLS가 왜 필요한지는 HTTPS는 어떻게 안전한가에 정리해뒀다.)

그래서 공인 진입은 켜되, 실제로 닿을 수 있는 범위를 사내 IP로 제한했다. nginx가 요청의 출발지 IP를 보고, 목록에 없으면 403으로 거부한다. 지금 검증할 건 "공인 IP로 들어온 요청이 경로를 타고 앱까지 닿는가"뿐이라, 사내에서 호출해 확인하면 충분하다. 정식으로 외부에 공개할 때(TLS와 도메인이 준비되면) 이 제한을 조정하면 된다.

나중에 안 것 — 이 설명에는 큰 전제가 빠져 있다. nginx가 보는 출발지 IP가 진짜 클라이언트 IP일 때만 이 방어가 성립한다. 나중에 TLS를 붙이면서 443을 TCP 패스스루로 바꿨더니 X-Forwarded-For가 사라졌고, 거기에 externalTrafficPolicy: Cluster의 SNAT가 겹쳐 출발지가 노드 IP로 둔갑했다. 그 노드 대역이 마침 위 허용 목록에 들어 있어서 whitelist가 통째로 무력화됐다. 어떻게 발견했고 왜 그런지는 IP whitelist가 조용히 뚫려 있었다에 정리했다.

podAntiAffinity — replica를 다른 노드에 흩뿌리기

controller values에 이런 블록이 있었다.

yaml
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - podAffinityTerm:
          labelSelector:
            matchExpressions:
              - key: app.kubernetes.io/instance
                operator: In
                values: [ingress-nginx-external]
          topologyKey: kubernetes.io/hostname
        weight: 1

길어 보이지만 뜻은 한 줄이다. 이 controller의 Pod들끼리는 가능하면 서로 다른 노드에 배치해라.

  • podAntiAffinity — anti(반대), 즉 "이런 Pod들끼리는 떨어뜨려 놔라".
  • preferredDuringScheduling... — preferred는 선호(강제 아님). 가능하면 지키되 안 되면 그냥 배치한다. (반대인 required로 하면 못 지킬 때 Pod가 아예 안 뜬다.)
  • topologyKey: kubernetes.io/hostname — 노드 단위로 떨어뜨린다.

controller를 2개 띄우는데 둘이 같은 노드에 몰려 있으면, 그 노드 하나가 죽을 때 둘 다 같이 죽어서 진입점 전체가 멈춘다. 다른 노드에 흩어두면 노드 하나가 죽어도 나머지가 살아남는다. preferred로 둔 건, 노드가 부족할 때 같은 노드에라도 뜨게 허용하려는 거다. required였으면 노드가 모자랄 때 Pod가 안 떠버린다.

당직 두 명을 가능하면 다른 건물에 배치하는 것과 같다. 한 건물에 불나도 한 명은 살아있게. 단 건물이 하나뿐이면 같은 건물이라도 배치한다(필수는 아니니까).

리소스 사양 — 추측 말고 측정

controller에 잡아둔 리소스가 충분한지 궁금했는데, 답은 추측이 아니라 측정에서 나왔다. kubectl top으로 실제 사용량을 보면 된다.

직접 보고 알게 된 것:

  • ingress-nginx controller는 의외로 메모리를 적게 쓴다. 여러 Ingress를 처리하는데도 30~50Mi 수준이었다. nginx가 가벼운 reverse proxy라 그렇다.
  • memory request == limit이면 Guaranteed QoS다. "딱 그만큼 보장, 넘으면 즉시 OOMKill"이라는 뜻이다. 안정적이지만 여유 폭이 좁다. 순간적으로 사용량이 치솟을 때 죽지 않으려면 limit을 request보다 넉넉히 두는 게 안전하다.
  • 환경마다 사양이 다르다. 테스트 환경은 작게 잡고 replica를 고정해도 되지만, 운영 환경은 더 크게 잡고 autoscaling을 걸어 부하에 따라 Pod 수가 늘게 한다. 그래서 새 controller를 운영으로 확장할 땐 운영 쪽 사양·autoscaling 정책에 맞춰줘야 한다.

특히 주의할 케이스가 있다. 큰 파일을 업로드받는 서비스는 nginx가 요청 본문을 버퍼링하므로, 동시 업로드가 몰리면 메모리 압박이 생길 수 있다. (큰 본문은 디스크 임시파일로도 버퍼링돼서 폭증하진 않지만, 모니터링 대상이다.) 그래서 운영 적용 전에는 kubectl top으로 실제 부하를 한 번 측정하고 사양을 정하는 게 맞다. "충분하겠지"라는 추측보다 30초짜리 측정이 훨씬 믿을 만하다.

정리

ingress controller 하나 추가하는 일이, 알고 보니 이런 디테일들의 묶음이었다.

  • 설정은 표준 spec에 칸이 없어서 annotation으로 들어간다(벤더 중립 설계).
  • webhook은 controller와 달리 격리가 안 되니, controller를 늘릴 때 self-lock을 조심해야 한다.
  • 공인인데 평문인 단계에선 whitelist로 사내만 열어 테스트한다.
  • replica는 podAntiAffinity로 다른 노드에 흩뿌려 가용성을 지킨다.
  • 리소스는 추측하지 말고 kubectl top으로 측정해서 정한다.

각각은 사소해 보여도, 모르고 지나가면 "왜 내부 배포가 다 막히지?" 같은 사고로 돌아오는 것들이었다. 한 번 부딪혀보고 나니 다음엔 미리 챙길 수 있겠다 싶어 기록으로 남긴다.

관련 글

  • 외부 트래픽이 Pod까지 닿는 경로 — 내부/외부 controller를 나눈 배경
  • 쿠버네티스 핵심 객체 4종 — cluster-scoped 리소스가 뭔지
  • HTTPS는 어떻게 안전한가 — whitelist가 임시 보호막인 이유(TLS 전 단계)

참고 링크

  • ingress-nginx — Annotations
  • Kubernetes — Dynamic Admission Control
  • Kubernetes — Assigning Pods to Nodes (Affinity)
on this page
  • 01annotation으로 설정을 관리하는 이유
  • 02admission webhook — 격리가 안 되는 검문소
  • 03whitelist-source-range — 공인인데 평문일 때의 보호막
  • 04podAntiAffinity — replica를 다른 노드에 흩뿌리기
  • 05리소스 사양 — 추측 말고 측정
  • 06정리
  • 07관련 글
  • 08참고 링크
tags
#study📚 API Gateway를 제거하고 쿠버네티스로 직접 노출하기
← PREVIOUSAPI Gateway를 제거한 자리 채우기 — path rewrite, 요청 크기 병목 4개, 그리고 HTTPSNEXT →IP whitelist가 조용히 뚫려 있었다 — 클라이언트 IP는 어디서 사라지는가

이런 글도

  • devops

    종료 신호는 어디서 멈추는가

    2026.09.02
  • devops

    파드가 1개면 minAvailable 이 노드 교체를 막는다

    2026.08.20
  • devops

    ArgoCD는 초록불인데 서비스는 죽어 있었다 — 불변 필드와 재조정의 경계

    2026.08.11
  • devops

    파드가 서비스 이름을 못 푸는 이유 — dnsPolicy 4종과 ndots

    2026.08.03

댓글 (0)