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/선언한 LoadBalancer가 안 만들어질…
devops

선언한 LoadBalancer가 안 만들어질 때 — cloud-controller-manager 장애 격리 진단기

공인 진입점 전환 작업에서 외부 전용 ingress-nginx controller를 배포했다. controller Pod는 정상으로 떴는데, LoadBalancer 타입 Service의 EXTERNAL-IP가 에서 움직이지 않았다. 10분을 기다려도 그대로였다. 나는 이날 pending 하나를 따라가면서, LB가 실제로 누구 손에서 만들어지는지, 그리고 관...

2026.07.03·7 min read·116 views·SERIES · API Gateway를 제거하고 쿠버네티스로 직접 노출하기 · 2/7

외부 트래픽은 어떻게 Pod까지 닿는가를 먼저 읽으면 좋다. 그 글이 "LoadBalancer 타입 Service를 선언하면 클라우드가 LB를 만들어준다"까지 다뤘다면, 이 글은 선언했는데 안 만들어질 때 어디서부터 파고드는지를 다룬다.

공인 진입점 전환 작업에서 외부 전용 ingress-nginx controller를 배포했다. controller Pod는 정상으로 떴는데, LoadBalancer 타입 Service의 EXTERNAL-IP가 <pending>에서 움직이지 않았다.

plaintext
NAME                        TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)
ingress-nginx-controller    LoadBalancer   10.254.16.125   <pending>     80:31804/TCP,443:32031/TCP

10분을 기다려도 그대로였다. 나는 이날 pending 하나를 따라가면서, LB가 실제로 누구 손에서 만들어지는지, 그리고 관리형 쿠버네티스에서 장애를 "내 영역 / 플랫폼 영역"으로 가르는 방법을 배웠다.

LB는 쿠버네티스가 만드는 게 아니다

type: LoadBalancer를 선언하면 쿠버네티스 자체가 LB를 만들 것 같지만, 쿠버네티스 코어에는 그런 능력이 없다. 실제 작업은 cloud-controller-manager 안의 service controller가 한다. Service 변경을 감시하다가 LoadBalancer 타입을 발견하면 클라우드 API를 호출해서 실제 LB 장비를 만들고, 발급된 IP를 Service의 status에 되써준다.

내가 쓰는 관리형 쿠버네티스(NHN Cloud NKS)는 OpenStack 기반이라, 이 역할을 openstack-cloud-controller-manager(occm)가 맡는다.

이 구조를 알면 진단 방향이 잡힌다. <pending>이라는 건 이 체인 어딘가에서 멈췄다는 뜻이고, 멈춘 지점을 찾는 게 진단이다.

진단의 시작은 이벤트다

첫 번째로 볼 곳은 Service의 Events다.

bash
kubectl -n ingress-nginx describe svc ingress-nginx-controller
plaintext
Warning  SyncLoadBalancerFailed  service-controller
  Error syncing load balancer: failed to ensure load balancer:
  no subnet-id for service ingress-nginx/ingress-nginx-controller :
  subnet-id not set in cloud provider config,
  and failed to find subnet-id from OpenStack:
  The service is currently unable to handle the request due to a temporary
  overloading or maintenance. This is a temporary condition. Try again later.

에러 메시지가 길지만, 뜯어보면 두 개의 실패가 겹쳐 있다.

  • cloud provider 설정 파일에 subnet-id가 없다.
  • 그래서 OpenStack API로 자동 조회를 시도했는데, 그 조회가 503으로 실패했다.

여기서 occm이 LB를 만들기 전에 VIP를 둘 서브넷부터 결정해야 한다는 걸 알게 됐다. 그 결정 순서는 이렇다.

  1. Service의 loadbalancer.openstack.org/subnet-id annotation
  2. cloud provider 설정 파일(cloud-config)의 subnet-id
  3. 둘 다 없으면 OpenStack API로 자동 조회

내 클러스터는 1, 2가 모두 비어 있어서 3에 의존하고 있었고, 그 3이 죽어 있었다. 그렇다면 1번 경로로 서브넷을 직접 알려주면 자동 조회 의존을 끊을 수 있다.

참고로 cloud-config는 kube-system의 secret으로 들어 있는 경우가 많아서, 이렇게 직접 확인했다.

bash
kubectl -n kube-system get secret cinder-csi-cloud-config -o json \
  | jq -r '.data["cloud-config"]' | base64 -d

서브넷 UUID는 어떻게 알아내나

annotation에 넣을 값은 서브넷의 UUID다. 나는 두 가지 사실을 대조해서 찾았다. 아래 IP와 UUID는 구조만 같은 예시 값이다.

먼저 클러스터가 실제로 쓰는 IP를 확인한다. 노드 IP와, 이미 잘 동작하고 있는 기존 사설 LB의 VIP가 단서다.

  • 노드 IP: 10.0.10.25 (kubectl get nodes -o wide)
  • 기존 사설 LB VIP: 10.0.10.80 (기존 LoadBalancer Service의 EXTERNAL-IP)

다음으로 테넌트의 서브넷 목록을 클라우드 API로 조회한다.

CIDR이름포함 범위
10.0.10.0/25DMZ.0 부터 .127 — 노드도 기존 VIP도 여기
10.0.10.128/26INT.128 부터 .191
10.0.10.192/26DB.192 부터 .255

노드와 기존 LB가 모두 들어 있는 10.0.10.0/25가 클러스터 서브넷이고, 그 행의 UUID를 annotation에 넣으면 된다.

yaml
service:
  annotations:
    loadbalancer.openstack.org/subnet-id: "a9c40f34-0000-0000-0000-000000000000"

이 클러스터는 GitOps로 관리하고 있어서 values 파일에 넣고 PR과 수동 sync로 반영했다. 이 반영 흐름은 Helm과 ArgoCD로 GitOps 하기에 정리해 뒀다.

에러 메시지가 바뀌면 그것도 진전이다

annotation 반영 후에도 LB는 안 만들어졌다. 하지만 에러가 바뀌었다.

plaintext
Error syncing load balancer: failed to ensure load balancer:
error getting loadbalancer for Service ingress-nginx/ingress-nginx-controller:
The service is currently unable to handle the request due to a temporary
overloading or maintenance.

"no subnet-id"가 사라졌다. 서브넷 결정 단계는 통과했고, 이제 그다음 단계인 LB 조회 호출에서 같은 503을 받는 것이다. 결과만 보면 여전히 실패지만, 실패 지점이 앞으로 이동했다는 것 자체가 조치가 유효했다는 증거다. 트러블슈팅에서 에러 메시지의 변화를 추적하는 습관이 왜 중요한지 체감한 순간이었다.

그리고 이 시점에서 의심이 하나로 좁혀졌다. 서브넷 조회도 503, LB 조회도 503 — 특정 API가 아니라 occm이 클라우드 API로 나가는 길 자체가 문제 아닌가?

안과 밖에서 같은 API를 두드려 격리한다

이 가설을 확인하려고 같은 클라우드 API를 클러스터 밖에서 직접 호출해 봤다. 테넌트 자격증명으로 identity 토큰을 받아서 LB 목록을 조회하는 것이다.

bash
# 개념 설명용으로 축약 (실제는 identity API로 토큰 발급 후 호출)
curl -H "X-Auth-Token: $TOKEN" \
  https://<region>-api-network.example.com/v2.0/lbaas/loadbalancers

결과는 HTTP 200. 기존 LB 목록이 멀쩡히 조회됐고, 생성 시도가 실패하며 남긴 LB도 없었다. 클라우드의 LB 서비스 자체는 정상이라는 뜻이다.

같은 목적지를 두 경로로 두드렸는데 밖은 되고 안은 안 된다. 그러면 문제는 클라우드 서비스가 아니라 클러스터 안에서 클라우드 API로 나가는 내부 경로에 있다.

내부 경로의 정체 — 메타데이터 프록시와 trust 인증

cloud-config를 다시 보면 내부 경로의 구조가 보인다.

ini
[Global]
auth-url=http://169.254.169.241/v3
user-id=...
trust-id=...

두 가지가 눈에 띈다.

  • auth-url이 공인 API 주소가 아니라 link-local 대역의 내부 프록시다. 클러스터 안 컴포넌트는 이 프록시를 거쳐 클라우드 API에 나간다.
  • password가 없고 trust-id가 있다. trust는 OpenStack keystone의 위임 인증으로, "이 클러스터가 사용자를 대신해 API를 호출해도 된다"는 위임장이다. 관리형 쿠버네티스가 사용자 비밀번호를 저장하지 않고 클라우드 리소스를 조작할 수 있는 이유가 이것이다.

그리고 결정적인 방증을 찾았다. 같은 프록시와 trust 인증을 쓰는 다른 컴포넌트인 csi-cinder-controllerplugin(볼륨 연동 담당)을 보니, 토큰 발급에서 403을 받으며 57일째 CrashLoopBackOff 상태였다.

plaintext
Failed to GetOpenStackProvider: Request forbidden:
[POST http://169.254.169.241/v3/auth/tokens], error message:
{"error":{"code":403,"message":"You are not authorized to perform the requested action."}}

occm의 503과 csi의 403 — 에러 코드는 다르지만 둘 다 같은 내부 인증 경로 위에 있다. 이 클러스터는 내부에서 클라우드 API로 나가는 길이 꽤 오래전부터 죽어 있었고, LB를 새로 만들려는 순간에야 그게 드러난 것이다.

같은 인증 경로를 공유하는 다른 컴포넌트의 상태는 강력한 방증이다. 내 워크로드에서 재현하기 어려운 장애도, 옆 컴포넌트의 로그가 대신 증언해 준다.

여기서부터는 플랫폼의 영역이다

trust 인증과 내부 프록시는 관리형 쿠버네티스의 control plane 영역이라 사용자가 직접 고칠 수 없다. 내 쪽에서 할 수 있는 조치(서브넷 명시)는 끝났고, 나머지는 증거를 정리해 클라우드 지원 채널에 넘기는 게 맞다고 판단했다.

(후속) 문의 결과 원인은 클러스터 생성자 계정의 권한 상실로 확인됐고, 신원 모델 전환으로 해결했다. 전말과 배운 것은 관리형 클러스터는 누구의 권한으로 클라우드를 만지는가에 이어서 정리했다.

미해결 상태로 글을 남기는 이유는, 이번에 얻은 진단 절차 자체가 결과와 무관하게 재사용 가능하기 때문이다.

LB pending 진단 체크리스트

다음에 또 <pending>을 만나면 이 순서로 내려갈 것이다.

  1. kubectl describe svc로 Events를 본다 — 에러 메시지에서 어느 단계(서브넷 결정, LB 생성, LB 조회, 인증)에서 멈췄는지 읽는다.
  2. 조치 후에는 에러 메시지가 바뀌었는지를 본다 — 같은 실패라도 지점이 이동했으면 진전이다.
  3. 같은 클라우드 API를 클러스터 밖에서 직접 호출한다 — 플랫폼 장애인지 클러스터 내부 경로 문제인지 갈린다.
  4. 같은 인증 경로를 쓰는 다른 컴포넌트(csi 등)의 상태를 본다 — 방증이 쌓이면 원인 범위가 좁아진다.
  5. 관리형 경계를 넘는 원인이면 증거(이벤트 로그, 격리 결과, 방증)를 묶어 플랫폼에 문의한다.

지금 보면

이 클러스터는 2년 넘게 LB를 새로 만들 일이 없었다. 안 쓰는 경로는 죽어 있어도 아무 증상이 없다가, 쓰는 순간에야 드러난다. csi가 57일 동안 재시작 1만 6천 번을 찍는 동안 아무도 몰랐던 것도 같은 이유다 — 볼륨을 쓰는 워크로드가 없으니 아픈 데가 없었다.

돌아보면 "장애가 없다"와 "경로가 살아 있다"는 다른 명제였다. 평소 안 쓰는 의존 경로는 주기적으로 두드려 보거나, 최소한 코어 컴포넌트의 CrashLoopBackOff 정도는 알림으로 잡았어야 했다. 이번 작업이 끝나면 그 감시부터 챙길 생각이다.

관련 글

  • 외부 트래픽은 어떻게 Pod까지 닿는가 — LB와 Ingress Controller의 역할 분리
  • ingress-nginx 운영에서 부딪힌 디테일들 — 외부 전용 controller 구성 시 webhook, whitelist
  • Helm과 ArgoCD로 GitOps 하기 — annotation을 values로 반영한 GitOps 흐름

참고 링크

  • Exposing applications using services of LoadBalancer type — cloud-provider-openstack 공식 문서
  • Cloud Controller Manager — Kubernetes 공식 문서
  • Trusts — OpenStack Keystone 공식 문서
on this page
  • 01LB는 쿠버네티스가 만드는 게 아니다
  • 02진단의 시작은 이벤트다
  • 03서브넷 UUID는 어떻게 알아내나
  • 04에러 메시지가 바뀌면 그것도 진전이다
  • 05안과 밖에서 같은 API를 두드려 격리한다
  • 06내부 경로의 정체 — 메타데이터 프록시와 trust 인증
  • 07여기서부터는 플랫폼의 영역이다
  • 08LB pending 진단 체크리스트
  • 09지금 보면
  • 10관련 글
  • 11참고 링크
tags
#study📚 API Gateway를 제거하고 쿠버네티스로 직접 노출하기
← PREVIOUS외부 트래픽은 어떻게 Pod까지 닿는가 — LoadBalancer, Ingress Controller, 내부와 외부 분리NEXT →관리형 클러스터는 누구의 권한으로 클라우드를 만지는가 — trust 단절과 service user 전환

이런 글도

  • devops

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

    2026.09.02
  • devops

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

    2026.08.20
  • devops

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

    2026.08.11
  • devops

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

    2026.08.03

댓글 (0)