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/ArgoCD는 초록불인데 서비스는 죽어 있었…
devops

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

운영 중인 API의 HTTPS가 통째로 죽어 있다는 제보를 받았다. 그런데 확인해보니 매니페스트도, ArgoCD도, 인증서도 전부 정상이었다. 선언한 대로 되어 있는데 실제로는 되어 있지 않은 상태였다. 원인은 클라우드 로드밸런서의 리스너 프로토콜이 생성 후에는 바꿀 수 없는 필드였기 때문이다. 이 글은 그 하루 동안 배운 것 — 쿠버네티스의 재조정이 어디...

2026.08.11·11 min read·87 views

선언형 API 와 reconcile loop를 먼저 읽으면 좋다. 이 글은 그 루프가 닿지 못하는 바깥을 다룬다.

운영 중인 API의 HTTPS가 통째로 죽어 있다는 제보를 받았다. 그런데 확인해보니 매니페스트도, ArgoCD도, 인증서도 전부 정상이었다. 선언한 대로 되어 있는데 실제로는 되어 있지 않은 상태였다.

원인은 클라우드 로드밸런서의 리스너 프로토콜이 생성 후에는 바꿀 수 없는 필드였기 때문이다. 이 글은 그 하루 동안 배운 것 — 쿠버네티스의 재조정이 어디서 멈추는지, 왜 아무도 나에게 실패를 알려주지 않았는지 — 를 정리한 것이다.

모든 계기판이 정상을 가리키고 있었다

먼저 확인한 것들이다. 하나도 이상하지 않았다.

확인 대상결과
Service 매니페스트의 TLS 어노테이션4개 모두 선언되어 있음
ArgoCD Application 상태전부 Synced / Healthy
인증서 유효기간·SAN유효, 도메인 커버됨
LoadBalancer 상태ACTIVE / ONLINE
80 포트정상 응답
443 포트TLS handshake 실패

openssl s_client로 붙어보니 이렇게 나왔다.

plaintext
no peer certificate available
SSL handshake has read 5 bytes and written 1485 bytes
New, (NONE), Cipher is (NONE)

서버가 5바이트만 보내고 끊는다. 인증서를 아예 제시하지 않는다. TLS 1.0부터 1.3까지 전부 시도해도 같았으니 버전 협상 문제도 아니었다.

여기서 한참 헤맸다. 매니페스트에 인증서가 분명히 들어 있는데 서버가 인증서를 안 준다는 게 말이 안 됐다. 선언과 현실이 어긋나 있다는 걸 인정하는 데 시간이 걸렸다.

쿠버네티스의 재조정은 클러스터 경계에서 끝난다

Service를 type: LoadBalancer로 선언하면 실제 로드밸런서는 쿠버네티스가 만들지 않는다. cloud-controller-manager(줄여서 occm)가 클라우드 API를 호출해서 만든다. 여기가 경계다.

이 그림에서 중요한 건 점선이 아니라 경계 자체다. ArgoCD는 왼쪽 상자 안만 본다. git의 Service 객체와 클러스터의 Service 객체를 비교해서 같으면 Synced다. 오른쪽 상자에 실제로 무엇이 서 있는지는 비교 대상이 아니다.

내 경우 왼쪽은 완벽했다. 어노테이션 4개가 그대로 들어 있었다. 오른쪽 리스너는 전혀 다른 프로토콜로 서 있었다.

plaintext
선언: TCP-443 리스너 프로토콜 = TERMINATED_HTTPS (LB가 TLS 종료)
실제: TCP-443 리스너 프로토콜 = TCP (그냥 통과)

TCP로 통과시킨 TLS 트래픽이 뒤쪽 nginx의 평문 HTTP 포트로 꽂히고 있었다. nginx는 TLS ClientHello를 HTTP 요청으로 파싱하려다 실패하고 평문으로 에러를 뱉는다. 클라이언트는 그걸 깨진 TLS 레코드로 읽는다. 그래서 "TLS 오류"로 보였던 것이다.

왜 occm은 이걸 안 고쳤나 — 불변 필드

occm이 게을렀던 게 아니다. 어노테이션 변경을 감지하고 재조정도 돌았다. 문제는 리스너의 protocol이 클라우드 API 수준에서 수정 불가능한 필드라는 것이다.

이건 추측이 아니라 소스를 보면 바로 확인된다. OpenStack Octavia의 API 타입 정의(octavia/api/v2/types/listener.py)에서 생성용 클래스와 수정용 클래스를 비교하면 이렇다.

필드ListenerPOST (생성)ListenerPUT (수정)
protocol있음, 필수없음
protocol_port있음, 필수없음
default_tls_container_ref있음있음
tls_versions있음있음
allowed_cidrs있음있음

수정 요청 타입에 protocol 자체가 존재하지 않는다. 그러니 어떤 값을 보내도 반영될 수가 없다. 인증서(default_tls_container_ref)는 양쪽에 다 있어서 바꿀 수 있는데, 프로토콜만 못 바꾼다.

왜 이런 필드가 있냐면, 리스너의 프로토콜은 그 리스너가 무엇인지를 정의하는 속성이기 때문이다. L4로 통과시키는 리스너와 TLS를 종료하는 리스너는 내부 구현이 다르다. 바꾸려면 지웠다 다시 만드는 수밖에 없다. 데이터베이스로 치면 컬럼 타입을 바꾸는 게 아니라 테이블을 새로 만드는 쪽에 가깝다.

그래서 리스너를 지웠다

방법은 하나뿐이었다. 기존 443 리스너를 클라우드 API로 직접 삭제하고, occm이 그 자리에 새로 만들도록 두는 것이다.

지우자마자 60초 만에 TERMINATED_HTTPS 리스너가 재생성됐다. 로드밸런서 자체와 VIP, 공인 IP는 그대로 유지되고 리스너 하나만 교체됐다. 80 포트 리스너는 별개라 영향이 없었다.

프로덕션 리소스를 지우는 작업이라 부담스러웠지만, 판단 근거는 명확했다. 443은 이미 100% 죽어 있어서 잃을 트래픽이 없었다. 복구 직후 클라이언트 요청이 200으로 들어오기 시작했고, 24시간 로그를 다시 뒤져보니 복구 이전 시간대에는 요청이 단 한 건도 도달하지 못한 상태였다.

불변 필드인지 어떻게 미리 아는가

이번에 제일 궁금했던 부분이다. 방법이 몇 가지 있는데, 신뢰도가 서로 다르다는 게 핵심이다.

API 타입 정의에서 생성과 수정을 비교한다

가장 확실하다. 위에서 한 방식이다. 오픈소스 클라우드 API는 대부분 생성용 타입과 수정용 타입이 따로 있고, 수정용에 없는 필드가 곧 불변 필드다. 문서보다 소스가 빠를 때가 많다.

kubectl explain을 본다 — 단, 불완전하다

쿠버네티스 자체 리소스라면 필드 설명에 적혀 있는 경우가 있다.

plaintext
$ kubectl explain service.spec.clusterIP
    ... This field may not be changed through updates unless the type field
    is also being changed to ExternalName

"may not be changed through updates"가 불변 표시다. 다만 모든 불변 필드에 이 문구가 있는 건 아니다. 실제로 statefulset.spec.volumeClaimTemplates로 확인해봤더니 설명에 그런 언급이 없었다. 있으면 확실한 근거지만, 없다고 가변인 건 아니다.

컨트롤러의 이슈 트래커를 본다

클라우드 API가 허용하더라도 중간 컨트롤러가 안 보내는 경우가 따로 있다. occm에도 어노테이션 변경이 최초 생성 때만 반영되던 이슈가 있었고, 타임아웃 관련 어노테이션 여러 개가 여기 해당했다. 2020년에 고쳐졌지만, 이런 층이 따로 존재한다는 사실 자체가 중요하다. 클라우드 API가 허용하는 필드인데도 컨트롤러가 안 보내면 결과는 똑같이 "조용히 무시됨"이다.

그 어노테이션이 upstream 것인지 벤더 확장인지 구분한다

여기서 제일 오래 헤맸다. 내가 쓰던 어노테이션들의 네임스페이스를 다시 보니 두 종류가 섞여 있었다.

plaintext
loadbalancer.openstack.org/subnet-id        ← upstream occm
loadbalancer.<vendor>/listener-protocol     ← 클라우드 사업자 자체 확장

upstream occm 문서에는 리스너 프로토콜을 제어하는 어노테이션이 아예 없다. 내가 쓰던 건 클라우드 사업자가 따로 만든 확장이었다. upstream 소스와 이슈 트래커를 아무리 뒤져도 이 동작이 안 나오는 게 당연했다.

관리형 쿠버네티스라 컨트롤러 파드도 클러스터에 안 보인다. 컨트롤 플레인 쪽에서 돌기 때문에 버전을 확인할 수도, 소스를 읽을 수도 없다. 동작을 알아낼 방법이 실측밖에 없는 영역이 존재한다는 뜻이다.

정리하면 불변성은 세 층에서 온다.

층원인확인 방법
클라우드 API수정 요청 타입에 필드가 없음소스·API 문서
upstream 컨트롤러감지해도 안 보냄이슈 트래커·소스
벤더 확장문서화가 얕고 소스가 비공개실측 외에 없음

아래로 내려갈수록 미리 알기 어렵다. 세 번째 층은 "일단 해보고 클라우드 쪽에서 확인한다" 말고 방법이 없었다.

이 구분이 실무에서 갈리는 지점이 하나 있다. 반영이 안 될 때 쿠버네티스 버전을 올리면 해결되는가를 판단하는 자리다. 두 번째 층이면 컨트롤러 버전이 답이 되지만, 첫 번째나 세 번째 층이면 버전과 무관하다. 이번 건 첫 번째와 세 번째가 겹친 경우라 업그레이드로는 아무것도 달라지지 않았다.

직접 바꿔보고 에러를 확인한다

가장 직관적이지만 프로덕션에서는 쓸 수 없다. 개발 환경이 있다면 여기서 먼저 확인하는 게 맞다.

진짜 함정은 어노테이션이 검증되지 않는다는 것

여기까지 정리하고 나서야 이번 사건의 진짜 원인이 보였다. 불변 필드 자체가 아니라 불변 필드를 어노테이션으로 다뤘다는 구조가 문제였다.

쿠버네티스 자체 필드와 어노테이션은 실패하는 방식이 완전히 다르다.

쿠버네티스 자체 필드어노테이션
예시service.spec.clusterIPloadbalancer.<vendor>/listener-protocol
타입 검증apiserver가 스키마로 검증없음, 임의 문자열 맵
불변 필드를 바꾸면apply가 거부됨apply 성공
오타를 내면필드 없음 에러apply 성공
반영 실패를 알 수 있나즉시 에러로 안다아무 신호 없음

spec.clusterIP를 바꾸려 하면 apiserver가 거부한다. 실수해도 즉시 알 수 있어서 안전하다. 반면 어노테이션은 apiserver 입장에서 그냥 문자열이다. 값이 무엇이든, 오타가 있든, 반영이 불가능하든 kubectl apply는 항상 성공한다.

클라우드 리소스 설정을 어노테이션으로 넘기는 순간, 그 설정에 대한 타입 안정성을 전부 포기하는 셈이 된다. 컴파일 타임 검사 없이 리플렉션으로 문자열 필드명을 넘기는 코드와 구조가 같다.

그리고 ArgoCD는 이 상황에서 초록불을 켠다. git의 어노테이션 문자열과 클러스터의 어노테이션 문자열이 같으니까 정말로 Synced가 맞다. ArgoCD가 틀린 게 아니라, 애초에 그걸 볼 수 있는 도구가 아니었다.

다른 클라우드는 이걸 어떻게 풀었나

내가 겪은 게 특정 클라우드만의 문제인지 궁금해서 AWS와 GCP 문서를 찾아봤다. 셋 다 접근이 달랐고, 그 차이가 그대로 안전성 차이가 됐다.

AWS는 API 층에서 애초에 막지 않는다. ELBv2의 ModifyListener에는 Protocol 파라미터가 있다. 전환 시 부작용까지 문서에 적혀 있다 — "TLS에서 TCP로 바꾸면 보안 정책과 기본 인증서 속성이 제거된다". 리스너를 지웠다 만들 필요 자체가 없다.

다만 어노테이션 층에는 같은 함정이 남아 있다. aws-load-balancer-type에는 이런 경고가 붙어 있다.

Do not add or modify this annotation on an existing Service. Adding or modifying this annotation on an existing Service can result in misconfigured resources, such as leaked AWS resources or exposing your NLB to the internet.

aws-load-balancer-name은 더 노골적이다. "서비스 생성 후 이 어노테이션을 바꾸면 아무 효과가 없다"고 적혀 있다. 내가 겪은 것과 똑같은 조용한 무시다. 차이는 문서에 그렇게 적어뒀다는 것뿐이다.

GKE는 설계를 아예 다르게 했다. 불변 속성을 어노테이션이 아니라 쿠버네티스 자체 필드로 올렸다.

plaintext
spec.loadBalancerClass
  "This attribute is immutable and can't be changed after the
   Service manifest has been applied to a cluster."

spec 아래 필드라서 apiserver가 스키마로 검증한다. 바꾸려 하면 apply 자체가 거부된다. 조용한 실패가 구조적으로 불가능하다.

세 방식을 나란히 놓으면 이렇게 갈린다.

불변 속성을 어디에 두나잘못 바꾸면알아차리는 시점
쿠버네티스 spec 필드 (GKE)apply 거부즉시
어노테이션, 단 문서에 경고 있음 (AWS)조용히 무시문서를 읽었다면 사전에
벤더 확장 어노테이션조용히 무시장애가 나야

불변 속성을 어느 층에 두느냐가 실패의 시끄러움을 결정한다. 어노테이션은 스키마 변경 없이 기능을 붙일 수 있어 편하지만, 그 편의의 대가로 타입 안정성을 통째로 내준다. GKE가 loadBalancerClass를 spec으로 승격시킨 건 그 대가를 치르지 않겠다는 선택이다.

설계할 일이 생긴다면 여기서 배울 게 있다. 한 번 정하면 못 바꾸는 값일수록 검증되는 자리에 둬야 한다. 자유 문자열 맵에 넣어두고 "문서에 적어놨으니 됐다"고 하면, 문서를 안 읽은 사람이 아니라 문서가 지워진 다음 사람이 당한다.

같은 패턴이 나올 수 있는 다른 곳

LoadBalancer만의 문제가 아니다. 조건은 딱 두 가지다.

  1. 쿠버네티스 객체가 클러스터 밖의 리소스를 대리한다
  2. 그 리소스의 설정을 어노테이션이나 CRD로 넘긴다

이 조건을 만족하는 구성요소는 흔하다. 내가 직접 겪은 건 LoadBalancer 하나지만, 구조상 같은 함정이 가능한 자리들이다.

구성요소클러스터 밖 리소스어긋날 수 있는 지점
cloud-controller-manager로드밸런서, 리스너, 풀이 글의 사례
external-dnsDNS 레코드레코드 타입 변경
cert-manager인증서 발급 기관의 상태발급 실패가 Ingress에 안 드러남
CSI 드라이버볼륨, 스냅샷스토리지 클래스·크기 축소
Cluster Autoscaler노드 그룹인스턴스 타입 변경

여기 적힌 것 중 내가 실제로 확인한 건 첫 줄뿐이다. 나머지는 "구조가 같으니 의심해볼 자리"라는 뜻이지, 실제로 그렇게 깨진다고 검증한 게 아니다.

그래서 무엇을 점검하나

이번 일 이후로 체크리스트를 이렇게 잡았다.

설정을 바꿀 때

  • 어노테이션으로 클라우드 리소스 설정을 바꿨다면, apply 성공은 근거가 아니다. 클라우드 쪽에서 직접 조회해서 확인한다.
  • 특히 리소스의 "정체성"에 해당하는 필드(프로토콜, 타입, 이름)는 불변일 가능성이 높다고 먼저 의심한다.
  • 어노테이션의 네임스페이스가 upstream인지 벤더 확장인지 먼저 본다. 벤더 확장이면 공식 문서와 소스로 검증할 길이 없으니 실측 계획부터 세운다.
  • 개발 환경이 있으면 거기서 먼저 적용하고 클라우드 API로 결과를 확인한다.

환경을 새로 켤 때

  • 기존 환경에서 통했던 어노테이션이 신규 환경에서도 통한다는 보장이 없다. 기존 환경은 삭제·재생성이라는 수동 절차를 이미 거쳤을 수 있다.
  • 환경별로 클라우드 리소스의 실제 상태를 나란히 비교한다. 이번에도 개발·검증 환경과 운영 환경의 리스너 프로토콜을 나란히 놓고 나서야 확신이 섰다.

증상이 이상할 때

  • Synced / Healthy는 "쿠버네티스 객체가 git과 같다"는 뜻이지 "서비스가 동작한다"는 뜻이 아니다.
  • 클러스터 안에서 원인을 못 찾으면 경계 밖을 본다. LoadBalancer가 안 만들어질 때의 진단 흐름과 같은 접근이다.

지금 보면

가장 뼈아픈 지점은 기술이 아니라 문서였다.

이 함정은 이미 알려져 있었다. 개발 환경에서 같은 문제를 겪고 매니페스트에 주석으로 남겨뒀었다. "컨트롤러가 리스너 프로토콜을 그 자리에서 바꾸지 못하므로 기존 리스너를 지워야 한다"는 내용이었다. 그런데 이후 구조를 바꾸는 커밋에서 그 주석이 함께 지워졌다. 개발·검증 환경은 이미 수동 절차를 밟은 뒤였으니 아무 문제가 없었고, 나중에 운영 환경을 켤 때는 그 절차가 필요하다는 사실이 어디에도 남아 있지 않았다.

실측으로 알아낸 함정을 지우는 커밋은 코드 변경보다 위험하다. 코드가 사라지면 컴파일이 깨지지만, 함정 기록이 사라지면 아무 일도 일어나지 않다가 몇 달 뒤에 터진다. 리팩터링할 때 주석을 정리하는 습관이 있었는데, "왜 이렇게 해야 하는가"를 담은 주석은 정리 대상이 아니라는 걸 이번에 확실히 배웠다.

또 하나는 관측의 공백이다. TLS handshake 실패는 어디에도 로그가 남지 않는다. 로드밸런서가 TLS를 종료하니 실패는 거기서 끝나고, 뒤쪽 nginx까지 도달하지 못하니 액세스 로그에도 없다. 클라우드가 로드밸런서 액세스 로그를 제공하지 않으면 관측 수단이 아예 없다. 결국 내가 근거로 쓴 건 로그의 부재였다. 24시간 동안 요청이 0건이라는 사실이 유일한 증거였다.

없는 로그를 근거로 삼아야 하는 상황이 온다는 걸 미리 알았다면, 외부에서 주기적으로 handshake를 찔러보는 점검 하나쯤은 미리 걸어뒀을 것이다. 지금 보면 그게 가장 실효성 있는 대비였다.

참고 링크

  • Octavia API v2 — 공식 문서
  • octavia/api/v2/types/listener.py — 리스너 타입 정의 소스
  • occm: loadbalancer not updating when Octavia specific service annotations change — 2020년 해결됨. 컨트롤러 층에서 어노테이션이 누락되던 사례로 참고
  • occm 로드밸런서 어노테이션 목록 — upstream 공식 문서
  • Annotations — 쿠버네티스 공식 문서
  • AWS ELBv2 ModifyListener API — Protocol 변경 가능 여부
  • AWS Load Balancer Controller 어노테이션 — 변경 금지 경고 포함
  • GKE LoadBalancer Service 파라미터 — loadBalancerClass 불변성
  • Kubernetes Controllers — 공식 문서
on this page
  • 01모든 계기판이 정상을 가리키고 있었다
  • 02쿠버네티스의 재조정은 클러스터 경계에서 끝난다
  • 03왜 occm은 이걸 안 고쳤나 — 불변 필드
  • 04그래서 리스너를 지웠다
  • 05불변 필드인지 어떻게 미리 아는가
  • API 타입 정의에서 생성과 수정을 비교한다
  • `kubectl explain`을 본다 — 단, 불완전하다
  • 컨트롤러의 이슈 트래커를 본다
  • 그 어노테이션이 upstream 것인지 벤더 확장인지 구분한다
  • 직접 바꿔보고 에러를 확인한다
  • 06진짜 함정은 어노테이션이 검증되지 않는다는 것
  • 07다른 클라우드는 이걸 어떻게 풀었나
  • 08같은 패턴이 나올 수 있는 다른 곳
  • 09그래서 무엇을 점검하나
  • 10지금 보면
  • 11참고 링크
tags
#심화#study

이런 글도

  • devops

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

    2026.08.20
  • devops

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

    2026.08.03
  • devops

    IP whitelist가 조용히 뚫려 있었다 — 클라이언트 IP는 어디서 사라지는가

    2026.07.27
  • devops

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

    2026.09.02

댓글 (0)