전체 글

총 656개의 글

반응형

OpenTelemetry로 쿠버네티스 트레이스 모으기: Collector, Tempo, 샘플링과 장애 훈련까지 kind 실습

로컬 쿠버네티스 실습 8편OpenTelemetry 트레이스메트릭·로그와 연결하고 장애 훈련까지Keycloak 로그인 한 번이 어디서 시간을 쓰는지 Span 단위로 보고, Collector와 Tempo를 일부러 멈춰 본 기록OpenTelemetryOTel CollectorGrafana Tempo트레이스OpenTelemetry(OTel)는 앱에서 트레이스·메트릭·로그를 만들고 모아서 원하는 저장소로 보내는 표준 규격과 도구 모음입니다. 저장소나 화면은 아니고, 데이터를 만들고 나르는 쪽을 맡습니다. kind 클러스터에 OpenTelemetry Operator 0.129.0(Collector 0.162.0)과 Grafana Tempo 3.1.1을 설치하고, 7편의 Keycloak과 Grafana에 트레이싱을 ..

Keycloak 운영 모드로 Grafana SSO 만들기: Keycloak Operator, CloudNativePG, 이중화와 장애·백업 훈련

로컬 쿠버네티스 실습 7편Keycloak 운영 모드Grafana SSO와 장애 훈련까지Keycloak 2개 + PostgreSQL 2개로 SSO를 만들고, 파드와 DB를 일부러 죽여 본 기록KeycloakKeycloak OperatorCloudNativePGGrafana SSOKeycloak은 로그인과 사용자·권한 관리를 대신해 주는 오픈소스 인증 서버이고, 운영에서는 개발 모드(start-dev)가 아니라 외부 데이터베이스와 고정 주소를 쓰는 운영 모드(start)로 띄웁니다. kind 클러스터에 Keycloak Operator 26.8.2로 Keycloak 2개를, CloudNativePG 1.30.1로 PostgreSQL 2개(주 서버 + 복제본)를 띄우고, 6편의 HTTPS 위에서 Grafana ..

cert-manager로 Gateway API HTTPS 붙이기: 사설 CA, 자동 갱신, 만료 알림까지 kind 실습

로컬 쿠버네티스 실습 6편cert-manager + Gateway APIHTTPS를 자동으로 유지하기사설 CA를 만들고, 인증서 자동 갱신을 지켜보고, 만료 알림까지 실제로 울려 본 기록cert-manager사설 CAGateway APIPrometheusRulecert-manager는 Certificate 리소스에 적은 대로 인증서를 발급해 Secret에 넣고, 만료 전에 알아서 다시 발급하는 컨트롤러입니다. kind 클러스터에 cert-manager v1.21.3을 설치하고 자체 서명 → 루트 CA → 서버 인증서로 이어지는 사설 CA를 만든 뒤, Gateway API의 HTTPS 리스너에 연결했습니다. HTTP로 들어온 요청은 HTTPS로 옮기고, 인증서를 2분마다 갱신하게 해서 Envoy가 재시작 ..

Fluent Bit과 Loki로 쿠버네티스 로그 모으기: kind 실습과 Grafana 조회, AWS 확장 예시

로컬 쿠버네티스 실습 5편Fluent Bit → Loki → Grafana파드 로그를 한곳에서 보기수집기와 저장소를 나눠 설치하고, 실제로 겪은 오류 4개를 고친 과정Fluent BitLokiLogQLGrafanaFluent Bit과 Loki는 경쟁 제품이 아니라 역할이 다릅니다. Fluent Bit은 노드마다 떠서 컨테이너 로그를 읽고 쿠버네티스 정보를 붙여 보내는 수집기이고, Loki는 그 로그를 받아 저장하고 LogQL로 찾게 해 주는 저장소입니다. kind 클러스터에 Loki를 단일 구성으로, Fluent Bit을 DaemonSet으로 설치해 연결했고, web 파드에 보낸 요청 5개가 Grafana에서 5줄 모두 검색되는 것까지 확인했습니다. 설치 중에는 차트 스키마 오류, 약 11GiB를 요청하..

kube-prometheus-stack 설치와 Grafana 접속: kind 클러스터 모니터링과 target down 해결

로컬 쿠버네티스 실습 4편 kube-prometheus-stack으로Prometheus·Grafana 띄우기 helm 한 번으로 설치하고, Gateway로 Grafana를 열고, down 6개를 0개로 만든 기록 kube-prometheus-stackGrafanaPrometheusGateway APIkube-prometheus-stack helm 차트를 쓰면 Prometheus, Grafana, Alertmanager, node-exporter, kube-state-metrics가 한 번에 설치됩니다. 실습 PC의 kind 클러스터에서는 81초 만에 모든 파드가 Running이 됐고 메모리는 약 1.4GB를 더 썼습니다. Grafana는 3편의 Gateway에 grafana.localhost 호스트..

NGINX Ingress 대신 Gateway API: kind 클러스터 앱을 Windows localhost로 접속하기

로컬 쿠버네티스 실습 3편NGINX Ingress 대신 Gateway APIkind 앱을 localhost:8080으로Envoy Gateway를 설치하고, 로드밸런서 없는 kind에서 Windows 브라우저까지 연결한 과정Gateway APIEnvoy GatewayHTTPRoutekindkind 클러스터에서 Gateway API를 쓰려면 Gateway API 리소스 정의와 구현체가 필요한데, Envoy Gateway helm 차트 하나로 둘 다 설치됩니다. 남는 문제는 kind에 로드밸런서가 없다는 점입니다. 이 글에서는 Envoy 프록시를 8080 포트가 연결된 제어 플레인 노드에 띄우고 노드의 80번 포트를 직접 받게(hostPort) 설정해서, Windows 브라우저의 localhost:8080이 ..

Headlamp 설치와 사용법: 로컬 쿠버네티스를 화면으로 보고 파드 배포하기

로컬 쿠버네티스 실습 2편 Headlamp 설치와 사용법클릭으로 보고, YAML로 배포하기 WSL2 안의 kind 클러스터를 Windows 앱에서 보고 파드까지 배포해 본 기록 Headlamp쿠버네티스 GUIkindWSL2Headlamp는 쿠버네티스 클러스터를 화면으로 보고 관리하는 데스크톱 앱입니다. Windows에서는 winget으로 설치하고, 사용자 폴더의 .kube\config에 클러스터 접속 정보를 넣으면 별도 설정 없이 클러스터가 목록에 나옵니다. 보기 전용이 아니라 왼쪽 아래 '+ 생성' 버튼에 YAML을 붙여 넣고 '적용'을 누르면 Deployment를 바로 만들 수 있고, 실제로 만든 파드 2개가 Running이 되는 것까지 확인했습니다.1편에서 WSL2 Ubuntu 24.04 안에..

Windows에서 무료 쿠버네티스 실습 환경 만들기: WSL2 + Docker Engine + kind

로컬 쿠버네티스 실습 1편Windows에서 무료 쿠버네티스 실습 환경WSL2 + Docker Engine + kind클라우드 비용 없이 내 PC에 노드 3개짜리 클러스터를 만든 과정과 실제로 겪은 문제WSL2Docker EnginekindkubectlWindows 11에서는 WSL2에 Ubuntu를 설치하고, 그 안에 Docker Engine과 kind를 올리면 클라우드 비용 없이 제어 플레인 1개와 워커 2개로 된 쿠버네티스 클러스터를 만들 수 있습니다. 실제로 해 보니 클러스터 생성은 52초면 끝났고, 시간이 걸린 부분은 WSL 설치 뒤 재부팅과 배포판 설치였습니다. Docker Desktop 없이 Docker 공식 apt 저장소로 설치하고, kubectl·kind·helm은 공식 배포처 파일을 SH..

Bedrock Knowledge Bases 검색 설정과 재정렬: 필요한 문서를 먼저 찾는 방법 | Bedrock 28편

Amazon Bedrock 완전 가이드 · 28편 Knowledge Bases 검색 설정과 재정렬 필요한 문서를 먼저 찾는 방법 Amazon Bedrock Knowledge Bases는 질문할 때마다 검색 설정을 바꿀 수 있습니다. 가져올 청크 수는 numberOfResults(기본 5개, 1~100)로, 검색 범위는 메타데이터 필터로, 결과 순서는 재정렬 모델로, 여러 주제가 섞인 질문은 질의 분해로 조정합니다. 설정을 바꾸기 전에 Retrieve로 검색 결과의 원문·위치·메타데이터·점수를 먼저 보고, 문제가 수집·검색·생성 중 어디서 생겼는지 가른 뒤 한 번에 하나씩 바꿉니다.고객 관리형 Knowledge Base의 비정형 데이터 소스를 기준으로 Retrieve·RetrieveAndGenerat..

Bedrock Knowledge Bases 임베딩과 벡터 저장소: 차원·인덱스·유사도 이해하기 | Bedrock 27편

Amazon Bedrock 완전 가이드 · 27편 Knowledge Bases 임베딩과 벡터 저장소 차원·인덱스·유사도 이해하기 Amazon Bedrock Knowledge Bases에서 임베딩 모델은 청크를 정해진 길이(차원)의 숫자 벡터로 바꾸고, 벡터 저장소는 이 벡터를 인덱스에 저장해 질문 벡터와 가까운 것을 찾습니다. 벡터 인덱스의 차원은 임베딩 모델의 출력 차원과 같아야 하며, 하이브리드 검색은 일부 저장소에서만 쓸 수 있습니다. 임베딩 모델과 저장소는 만든 뒤 바꾸기 어려우므로, 지원 리전·검색 방식·비용 구조를 함께 보고 고릅니다.고객 관리형 Knowledge Base를 기준으로 임베딩 모델과 차원, 벡터 인덱스와 필드 매핑, 지원 벡터 저장소와 S3 Vectors 선택 기준, 의미 ..
반응형

OpenTelemetry로 쿠버네티스 트레이스 모으기: Collector, Tempo, 샘플링과 장애 훈련까지 kind 실습

반응형
로컬 쿠버네티스 실습 8편
OpenTelemetry 트레이스
메트릭·로그와 연결하고 장애 훈련까지

Keycloak 로그인 한 번이 어디서 시간을 쓰는지 Span 단위로 보고, Collector와 Tempo를 일부러 멈춰 본 기록

OpenTelemetryOTel CollectorGrafana Tempo트레이스

OpenTelemetry(OTel)는 앱에서 트레이스·메트릭·로그를 만들고 모아서 원하는 저장소로 보내는 표준 규격과 도구 모음입니다. 저장소나 화면은 아니고, 데이터를 만들고 나르는 쪽을 맡습니다. kind 클러스터에 OpenTelemetry Operator 0.129.0(Collector 0.162.0)과 Grafana Tempo 3.1.1을 설치하고, 7편의 Keycloak과 Grafana에 트레이싱을 켰습니다. 그 결과 평소 약 40ms 걸리는 Keycloak 로그인 처리의 절반가량이 비밀번호 해시 계산(Argon2, 중앙값 22.4ms)이라는 것과, 재시작 직후 첫 로그인은 671ms까지 느려진다는 것을 Span 단위로 확인했습니다. 메트릭의 점(exemplar)에서 트레이스로, 트레이스에서 같은 요청의 로그(Loki)로 넘어가게 연결했고, 로그인 30번 기준 샘플링 100%와 10%의 저장량 차이(30개와 3개)를 쟀습니다. Collector와 Tempo를 각각 멈춰도 로그인은 모두 성공했고, 멈춘 동안의 트레이스도 큐와 재시도로 사라지지 않았습니다. 운영 환경에서 AWS(CloudWatch·X-Ray)의 OTLP 엔드포인트로 보내는 방법과 SigV4 인증도 함께 정리했습니다.

1~7편의 kind 클러스터(노드 3개, Envoy Gateway, kube-prometheus-stack, cert-manager 사설 CA HTTPS, Keycloak 2개 + PostgreSQL 2개)를 이어서 씁니다. 5편 뒤 지웠던 Loki와 Fluent Bit은 5편 설정 그대로 다시 설치했습니다. OpenTelemetry Operator 0.129.0, Collector 배포판 opentelemetry-collector-k8s 0.162.0, Tempo 차트 3.2.1(Tempo 3.1.1), Keycloak 26.8.2, Grafana 13.3.0 기준입니다. AWS로는 실제로 보내지 않고 설정 예시만 다루며, 매니페스트와 화면에 AWS 키를 넣지 않았습니다.

01

이번 편에서 만드는 구성

4편에서 메트릭(Prometheus), 5편에서 로그(Loki)를 모았습니다. 이번 편은 마지막 관측 신호인 트레이스를 붙입니다. 추적 대상은 따로 데모 앱을 만들지 않고 7편의 로그인 흐름을 그대로 씁니다. 브라우저 → Grafana → Keycloak → PostgreSQL로 이어지는 실제 요청이라, 트레이스가 무엇을 보여 주는지 확인하기 좋습니다.

8편 구성 요소
구성 요소 네임스페이스 역할
OpenTelemetry Operator opentelemetry-operator-system Collector 리소스를 보고 Deployment·Service를 만들어 줌
OTel Collector observability Keycloak·Grafana가 보낸 트레이스를 받아 걸러 내고 Tempo로 전달
Grafana Tempo tracing 트레이스 저장소. 트레이스로 RED 메트릭과 서비스 그래프도 만듦
Keycloak(7편) keycloak 내장된 OpenTelemetry 트레이싱을 켬
Grafana(4편) monitoring 자체 트레이스를 보내고, 세 신호를 한 화면에서 조회
Loki·Fluent Bit(5편) loki, logging 트레이스 ID가 들어간 로그를 저장

글의 앞부분에서는 개념을 정리합니다. 트레이스가 메트릭·로그와 무엇이 다른지, OpenTelemetry가 정확히 어디까지 맡는지, 데이터는 어디서 보는지부터 설명합니다. 그다음 설치 → 트레이싱 켜기 → 샘플링 → 세 신호 연결 → 장애 훈련 순서로 진행하고, 마지막에 운영 환경(AWS)으로 보내는 방법과 SigV4를 다룹니다.

02

트레이스와 메트릭, 로그는 뭐가 다른가

한 줄로 줄이면 메트릭은 숫자로 된 전체 통계이고, 트레이스는 요청 하나의 상세 기록입니다. 로그는 그 사이에서 특정 순간에 앱이 남긴 문장입니다.

세 관측 신호 비교
구분 메트릭 트레이스 로그
단위 시간에 따른 숫자(예: 30초마다 집계) 요청 1건 이벤트 1줄
예시 로그인 p95 0.205초, 오류율 2% 19:33:06 로그인: Keycloak 41.75ms 중 해시 21.69ms Client 'grafana' ... full scope allowed
답하는 질문 무엇이, 얼마나 나빠졌나 어디서, 왜 느렸나 그때 무슨 일이 있었나
데이터 크기 작음(요청이 늘어도 시리즈 수는 비슷) 큼(요청 수에 비례) 큼(로그 줄 수에 비례)
보관 전부, 오래 샘플링해서 일부만, 짧게 필요한 만큼
알림 알림의 기준으로 적합 원인 분석용 특정 오류 감지에 보조로

비유하면 메트릭은 고속도로 전광판("서울→부산 평균 5시간, 정체 중")이고, 트레이스는 차 한 대의 블랙박스("대전 IC에서 40분 정체")입니다. 전광판으로 정체를 알아채고, 블랙박스로 정체 구간을 찾습니다.

운영에서는 세 신호를 순서대로 씁니다.

  • 메트릭 알림: "Keycloak 로그인 p95가 2초를 넘었다"
  • 트레이스로 원인 찾기: 느린 요청 몇 개를 열어 보니 DB 쿼리 Span만 유독 길다
  • 로그로 확인: 같은 트레이스 ID의 로그에 "connection pool exhausted"가 찍혀 있다

이 흐름을 클릭 몇 번으로 이어 주는 것이 이번 편의 목표입니다. 메트릭 그래프의 한 점에 그 순간의 대표 트레이스 ID를 붙여 두는 것을 exemplar라고 하고, 트레이스에서 같은 파드·같은 시간대의 로그로 넘어가는 것을 trace-to-logs라고 합니다.

03

OpenTelemetry가 하는 일

트레이스의 기본 단위부터 정리합니다. 택배 배송 조회와 비슷합니다.

  • Trace: 요청 하나의 전체 여정입니다. 송장번호 같은 트레이스 ID(32자리 16진수)가 붙습니다.
  • Span: 여정 중 한 구간입니다. "Keycloak 로그인 처리 41.75ms", "SELECT USER_ENTITY 1.7ms"처럼 이름, 시작 시각, 걸린 시간, 부모 Span을 가집니다.
  • Context propagation: 서비스가 다음 서비스를 부를 때 HTTP 헤더(traceparent)에 트레이스 ID를 실어 보냅니다. 받는 쪽이 이 값을 이어받아야 서로 다른 서비스의 Span이 하나의 트레이스로 묶입니다.

OpenTelemetry는 이 데이터를 만들고 나르는 부분을 표준으로 정했습니다. 구성 요소는 네 가지입니다.

OpenTelemetry 구성 요소
구성 요소 하는 일 이 실습에서
API·SDK(계측) 앱 안에서 Span을 만드는 언어별 라이브러리. 에이전트를 붙이는 자동 계측도 있음 Keycloak(Java)과 Grafana(Go)에 이미 내장되어 있어 설정만 켬
OTLP 관측 데이터를 주고받는 표준 프로토콜(gRPC 4317, HTTP 4318) Keycloak·Grafana → Collector → Tempo 모두 OTLP
Collector 받고(receivers) → 가공하고(processors) → 내보내는(exporters) 중계 서버 소음 제거, 쿠버네티스 정보 붙이기, 샘플링, 큐·재시도
Operator 쿠버네티스에서 Collector 배포와 자동 계측 주입을 관리 Collector 리소스 하나로 Deployment·Service 생성
핵심

저장소는 OpenTelemetry가 아닙니다. 트레이스는 Tempo나 Jaeger, 메트릭은 Prometheus, 로그는 Loki 같은 백엔드가 저장합니다. 앱을 OTel로 계측해 두면 저장소를 바꿀 때 앱 코드는 그대로 두고 Collector의 exporter 설정만 바꾸면 됩니다. 예전에는 관측 제품마다 전용 에이전트를 따로 깔아야 했는데, 지금은 주요 상용 제품과 AWS도 OTLP를 직접 받습니다. 이것이 "벤더 종속을 피한다"는 말의 실제 의미입니다.

04

OTel 데이터는 어디서 보나

OpenTelemetry 자체에는 화면이 없습니다. "OTel 로그·트레이스를 어디서 보나"의 답은 Collector가 어느 저장소로 보내느냐에 따라 달라집니다.

이 실습에서 신호별 저장소와 조회 위치
신호 저장소 보는 곳
트레이스 Tempo(이번 편) Grafana → Explore → Tempo
로그 Loki(5편) Grafana → Explore → Loki
메트릭 Prometheus(4편) Grafana 대시보드, Explore → Prometheus

세 저장소를 모두 Grafana에 데이터 소스로 연결하므로 보는 곳은 Grafana 한 곳입니다. Grafana가 아닌 화면도 있습니다. 트레이스 전용 오픈소스인 Jaeger UI, Datadog·New Relic·Honeycomb 같은 상용 제품, AWS의 X-Ray와 CloudWatch가 모두 OTLP로 데이터를 받습니다.

OpenTelemetry는 로그도 다룹니다. Collector의 filelog 수신기가 5편의 Fluent Bit처럼 노드의 컨테이너 로그 파일을 읽을 수 있습니다. 운영에서는 두 방식 중 하나를 고릅니다.

  • Fluent Bit은 로그, OTel은 트레이스: 이미 로그 수집이 안정적으로 도는 곳에서 흔한 구성입니다. 이 글도 이 방식입니다.
  • OTel Collector 하나로 세 신호 모두: 에이전트 종류가 줄어 관리가 편합니다. 대신 Collector가 멈추면 모든 신호가 함께 끊기므로 Collector를 더 신경 써서 운영해야 합니다.
05

준비: Loki 재설치와 차트 받기

트레이스에서 로그로 넘어가는 것까지 보여 주려고, 5편 뒤 정리했던 Loki와 Fluent Bit을 5편 설정 그대로 다시 설치했습니다. Loki는 48초, Fluent Bit은 3초 만에 준비됐습니다(5편에서 늘려 둔 WSL inotify 한도가 남아 있었습니다).

Tempo와 OTel Operator 차트를 받을 때 문제가 하나 있었습니다. 차트 목록(index.yaml)은 받아지는데, 차트 파일 자체를 받는 단계에서 둘 다 같은 오류가 났습니다.

Error: failed to fetch https://github.com/grafana-community/helm-charts/releases/download/tempo-3.2.1/tempo-3.2.1.tgz : 504 Gateway Time-out

두 차트 저장소 모두 차트 파일을 GitHub 릴리스에 올려 두는 방식이라, GitHub 릴리스 다운로드가 504를 내는 동안에는 helm install이 실패합니다(github.com 접속 자체는 정상이었습니다). 두 프로젝트는 같은 차트를 OCI 레지스트리(ghcr.io)에도 올리고 있어서 그쪽에서 받았습니다.

helm pull oci://ghcr.io/grafana-community/helm-charts/tempo --version 3.2.1
helm pull oci://ghcr.io/open-telemetry/opentelemetry-helm-charts/opentelemetry-operator --version 0.129.0
운영 포인트

운영 배포 파이프라인이 외부 차트 저장소에 직접 의존하면, 저장소 장애가 곧 배포 장애가 됩니다. 사내 레지스트리(Harbor, ECR 등)에 차트와 이미지를 복제해 두고 버전을 고정해서 설치하면 이런 장애와 "같은 버전인데 내용이 바뀌는" 문제를 함께 막을 수 있습니다.

06

Tempo 설치: 기본값에서 바꾼 것

Tempo 차트는 두 가지입니다. 단일 바이너리로 띄우는 tempo와 구성 요소를 나눠 띄우는 tempo-distributed입니다. 실습은 단일 바이너리를 썼습니다. 차트 3.2.1은 Tempo 3.1.1을 설치하며, 3.x부터는 ingester가 live-store로, compactor가 backend scheduler·worker로 바뀌었습니다.

기본값을 그대로 쓰면 안 되는 부분이 몇 가지 있었습니다.

Tempo 차트 기본값과 바꾼 값
항목 기본값 바꾼 값 이유
persistence 꺼짐 켜짐, 5Gi 꺼져 있으면 파드가 재시작될 때 트레이스가 모두 사라짐
retention 24h 72h 주말을 넘긴 장애도 볼 수 있게. 로그·메트릭보다는 짧게
reportingEnabled true false 사용 통계를 Grafana Labs로 보내는 기본값
receivers Jaeger 4종 + OTLP OTLP만 Collector가 OTLP로만 보내므로 열린 포트를 줄임
metricsGenerator 꺼짐 켜짐 트레이스에서 RED 메트릭과 서비스 그래프를 만들어 Prometheus로 보냄
resources 없음 요청 100m/256Mi, 한도 1Gi 다른 파드와 자원을 다투지 않게
tempo:
reportingEnabled: false
retention: 72h
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
metricsGenerator:
enabled: true
remoteWriteUrl: "http://kps-kube-prometheus-stack-prometheus.monitoring:9090/api/v1/write"
overrides:
defaults:
metrics_generator:
processors:
- service-graphs
- span-metrics
persistence:
enabled: true
size: 5Gi
serviceMonitor:
enabled: true
additionalLabels:
release: kps

metrics generator가 만든 메트릭을 받고 exemplar를 저장하려면 Prometheus 쪽 기능도 켜야 합니다. kube-prometheus-stack에서는 두 값을 추가합니다.

prometheus:
prometheusSpec:
enableFeatures:
- exemplar-storage
enableRemoteWriteReceiver: true

업그레이드 후 Prometheus 실행 인자에 --enable-feature=exemplar-storage와 --web.enable-remote-write-receiver가 붙은 것을 확인했습니다. Tempo 설치는 32초 걸렸습니다. Service에는 Jaeger 포트가 여전히 나열되는데, 차트의 Service 템플릿이 포트를 고정으로 적어 두었기 때문이고 실제로 듣는 포트는 OTLP뿐입니다.

07

Collector 설치: Operator와 배치 방식

OpenTelemetry Operator 차트는 기본값이 "cert-manager가 이미 설치되어 있다"는 가정입니다. Operator의 웹훅(Collector 리소스를 검사하는 API)에 인증서가 필요한데, 이를 cert-manager로 발급합니다. 6편에서 설치한 cert-manager가 그대로 쓰였습니다.

helm install otel-operator ./opentelemetry-operator-0.129.0.tgz \
-n opentelemetry-operator-system --create-namespace \
-f 08-otel-operator-values.yaml --wait

kubectl -n opentelemetry-operator-system get certificate \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,NOT_AFTER:.status.notAfter

설치는 20초 걸렸고, cert-manager가 자체 서명 Issuer와 웹훅 인증서(만료 2027-02-06, 갱신 예정 2027-01-07)를 만들었습니다. 6편에서 만든 인증서 만료 알림 규칙은 클러스터의 모든 Certificate를 보기 때문에, 이 인증서도 자동으로 감시 대상이 됩니다.

Collector를 어떻게 배치할지가 운영에서 첫 번째 결정입니다.

Collector 배치 방식
방식 구성 장점 단점
에이전트(DaemonSet) 노드마다 1개, 앱은 같은 노드의 Collector로 보냄 네트워크 홉이 짧고, 노드 로그·메트릭도 같이 수집 노드 수만큼 자원 사용, tail 샘플링 불가(트레이스가 노드마다 흩어짐)
게이트웨이(Deployment) 한곳에 모아 몇 개만 둠 샘플링·필터 정책을 한곳에서 관리, 외부로 나가는 출구 하나 Collector가 단일 장애점이 되기 쉬움
둘 다 에이전트 → 게이트웨이 대규모 운영의 일반적 형태 구성 요소가 늘어남

이 실습은 게이트웨이 방식(Deployment) 1개로 시작했습니다. tail 샘플링은 한 트레이스의 모든 Span이 같은 Collector에 모여야 판단할 수 있어서, 게이트웨이를 2개 이상으로 늘리려면 앞단에 트레이스 ID를 기준으로 나눠 주는 load_balancing exporter 계층을 둬야 합니다.

apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: otel
namespace: observability
spec:
mode: deployment
replicas: 1
serviceAccount: otel-collector
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
config:
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
k8s_attributes:
extract:
metadata: [k8s.namespace.name, k8s.pod.name, k8s.node.name, k8s.deployment.name, k8s.statefulset.name]
tail_sampling: { ... } # 샘플링 절에서 설명
batch:
send_batch_size: 1024
timeout: 5s
exporters:
otlp_grpc/tempo:
endpoint: tempo.tracing.svc:4317
tls: { insecure: true }
sending_queue: { enabled: true, queue_size: 5000 }
retry_on_failure: { enabled: true, max_elapsed_time: 300s }
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noise, k8s_attributes, tail_sampling, batch]
exporters: [otlp_grpc/tempo]
  • memory_limiter: 메모리 한도(512Mi)의 80%(로그에 limit_mib 409로 표시)에 닿으면 새 데이터를 거절해 OOMKilled를 막습니다. 처리기 목록의 맨 앞에 둡니다.
  • k8s_attributes: 데이터를 보낸 파드의 IP로 네임스페이스·파드·노드 이름을 찾아 붙입니다. 파드 목록을 읽어야 하므로 pods·namespaces·nodes·replicasets 읽기 권한(ClusterRole)을 따로 만들어 줬습니다. 이 값이 있어야 트레이스에서 같은 파드의 로그로 넘어갈 수 있습니다.
  • batch: 모아서 보내 네트워크 호출 수를 줄입니다. 처리기 목록의 맨 뒤에 둡니다.
  • sending_queue·retry_on_failure: Tempo가 잠시 멈춰도 메모리 큐에 쌓아 두고 다시 보냅니다. 장애 훈련에서 실제로 확인합니다.
버전 주의

Collector 0.162.0 배포판에서는 구성 요소 이름이 밑줄 형식입니다(k8s_attributes, tail_sampling, load_balancing, exporter otlp_grpc·otlp_http). 인터넷의 예전 예시(k8sattributes, otlp)를 그대로 붙여 넣기 전에 components 명령으로 실제 이름을 확인하는 것이 안전합니다. 또 Operator 기본 이미지인 opentelemetry-collector-k8s 배포판에는 AWS SigV4 인증 확장이 들어 있지 않습니다. 이 내용은 AWS 절에서 다시 다룹니다.

docker run --rm ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-k8s:0.162.0 components

설치를 마친 뒤 Headlamp에서 본 파드 목록입니다. Collector(observability), Tempo(tracing), Operator(opentelemetry-operator-system)와 다시 설치한 Loki·Fluent Bit이 모두 Running이고, Collector와 Tempo는 서로 다른 워커 노드에 있습니다.

08

운영 함정: 수집되지 않는 Collector 메트릭

Collector 리소스에 observability.metrics.enableMetrics: true를 넣으면 Operator가 Collector 자체 메트릭(받은·보낸·버린 Span 수, 큐 크기)을 위한 ServiceMonitor를 만들어 줍니다. 그런데 Prometheus에서 조회되지 않았습니다.

kubectl -n observability get servicemonitor otel-monitoring-collector -o jsonpath='{.metadata.labels}'
# app.kubernetes.io/component, app.kubernetes.io/name 등 — release 라벨 없음

kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorSelector}'
# {"matchLabels":{"release":"kps"}}

4편에서 설명한 대로 kube-prometheus-stack의 Prometheus는 release: kps 라벨이 붙은 ServiceMonitor만 읽습니다. Operator가 만든 ServiceMonitor에는 이 라벨이 없었습니다. 자동 생성을 끄고, Operator가 만든 모니터링용 Service(8888 포트)를 가리키는 ServiceMonitor를 직접 만들었습니다.

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: otel-collector
namespace: observability
labels:
release: kps
spec:
selector:
matchLabels:
app.kubernetes.io/name: otel-collector-monitoring
endpoints:
- port: monitoring
interval: 30s

적용 후 Prometheus에서 otelcol_receiver_accepted_spans가 조회되기 시작했습니다(이 시점 약 22 Span/초). Collector는 관측 데이터의 통로라서, Collector 자신이 관측되지 않으면 "트레이스가 없는 것"과 "Collector가 버린 것"을 구분할 수 없습니다.

09

Keycloak과 Grafana에 트레이싱 켜기

Keycloak은 OpenTelemetry 트레이싱을 내장하고 있어서, 7편의 Keycloak 리소스에 두 항목만 추가하면 됩니다. 클러스터의 CRD를 kubectl explain keycloak.spec.tracing으로 확인한 필드 이름을 썼습니다.

# 08-keycloak-tracing-patch.yaml
spec:
telemetry:
endpoint: http://otel-collector.observability.svc:4317
protocol: grpc
serviceName: keycloak
tracing:
enabled: true
samplerType: traceidratio
samplerRatio: 1.0

kubectl -n keycloak patch keycloak keycloak --type merge --patch-file 08-keycloak-tracing-patch.yaml

tracing.serviceName과 tracing.resourceAttributes는 CRD 설명에 DEPRECATED로 표시되어 있어 공통 설정인 telemetry 쪽에 넣었습니다. 운영자는 이 값을 KC_TELEMETRY_*, KC_TRACING_* 환경 변수로 바꾸고, k8s.namespace.name=keycloak 속성도 스스로 붙였습니다. 파드 2개가 하나씩 교체되는 데 82초 걸렸습니다(7편에서 잰 롤링 업데이트와 같은 과정입니다).

앱 쪽(head) 샘플링 비율은 1.0으로 두어 전부 보내고, 무엇을 남길지는 Collector가 정하게 했습니다. 이유는 샘플링 절에서 설명합니다.

Grafana도 자체 트레이스를 보낼 수 있습니다. kube-prometheus-stack 값에 다음을 추가했습니다.

grafana:
grafana.ini:
tracing.opentelemetry:
sampler_type: const
sampler_param: 1
tracing.opentelemetry.otlp:
address: otel-collector.observability.svc:4317
propagation: w3c

로그인을 몇 번 하고 Grafana의 Explore → Tempo에서 TraceQL로 Keycloak 로그인 처리 트레이스를 찾았습니다.

{resource.service.name="keycloak" && name="POST /realms/{realm}/login-actions/authenticate"}

검색 결과 목록입니다. 같은 로그인 처리가 거의 매번 40ms 안팎으로 끝나는 것을 한눈에 볼 수 있습니다.

트레이스 하나를 열면 Span이 폭포 모양으로 펼쳐집니다. 아래는 19:33:06의 로그인 처리(Span 62개, JGroups 메시지 Span 포함)에서 주요 구간만 옮긴 것입니다.

keycloak POST /realms/{realm}/login-actions/authenticate 41.75ms
keycloak LoginActionsService.authenticateForm 32.33ms
keycloak SELECT keycloak.LOGIN_FAILURE 0.46ms
keycloak PasswordCredentialProvider.isValid 22.16ms
keycloak Argon2PasswordHashProvider.encode 21.69ms
keycloak SELECT keycloak.OFFLINE_USER_SESSION 0.39ms
keycloak SELECT keycloak.OFFLINE_CLIENT_SESSION 0.39ms
keycloak DefaultKeycloakTransactionManager.commit 7.31ms

왼쪽 목록에서 트레이스 ID를 누르면 오른쪽에 폭포 그림이 열립니다. PasswordCredentialProvider.isValid 아래 Argon2PasswordHashProvider.encode 막대가 전체의 절반을 차지하고, 오른쪽 위의 "Logs for this trace"를 누르면 같은 트레이스의 로그로 넘어갑니다.

DB 쿼리는 하나하나가 1ms도 걸리지 않고, 전체 41.75ms 중 절반 이상(21.69ms)이 비밀번호 해시 계산(Argon2)입니다. 2시간 동안 모인 Argon2 Span 57개를 보면 최소 19.3ms, 중앙값 22.4ms로 거의 일정했습니다. Argon2는 비밀번호 대입 공격을 어렵게 하려고 일부러 CPU와 메모리를 많이 쓰게 만든 알고리즘입니다. 그래서 로그인이 몰리면 Keycloak의 CPU가 먼저 부족해지고, CPU 한도를 너무 낮게 잡으면 로그인 지연이 바로 늘어납니다. 메트릭만 봤다면 "로그인이 0.04초 걸린다"까지만 알았을 내용을 트레이스가 구간별로 보여 준 사례입니다. 커밋 단계의 INSERT는 7편에서 켠 영구 사용자 세션이 DB에 저장되는 부분입니다.

운영 포인트

같은 로그인 처리 트레이스를 시간순으로 늘어놓으면, 트레이싱을 켜려고 Keycloak을 교체한 직후의 첫 로그인은 671ms, 그다음은 77ms·67ms였고 그 뒤로는 35~58ms에서 안정됐습니다(중간에 한 번 226ms, 이때 Argon2가 65.1ms). Java 기반 서버는 재시작 직후 코드가 최적화(JIT)되기 전이라 첫 요청들이 느립니다. 롤링 업데이트 중에는 새 파드로 넘어간 사용자가 이 느린 구간을 겪으므로, 지연 알림의 기준을 정할 때 재시작 직후 값을 구분해서 보고, 필요하면 준비(readiness) 전에 예열 요청을 보내는 방법을 검토합니다.

10

운영 함정: 끊어진 트레이스와 배경 소음

기대와 다른 점이 두 가지 있었습니다.

첫째, Grafana와 Keycloak이 한 트레이스로 이어지지 않았습니다. Grafana의 로그인 콜백 처리(HTTP GET /login/:name) 537ms 중 authn.oauth.Authenticate가 458ms였는데, 그 아래에 Keycloak을 호출하는 Span이 없었습니다. 같은 시각 Keycloak의 토큰 발급(POST .../token, 23.5ms)과 userinfo(6~9ms)는 Keycloak을 루트로 하는 별도 트레이스로 잡혔습니다. Tempo가 트레이스로 그린 서비스 그래프(Explore → Tempo → Service Graph)를 보면 차이가 더 분명합니다. user→grafana, user→keycloak, keycloak→keycloak-db-rw 간선이 있고, Grafana가 데이터 소스를 조회하는 grafana→loki, grafana→prometheus, grafana→tempo 간선도 있지만, grafana→keycloak 간선만 없습니다.

Tempo의 Service Graph 화면입니다. grafana에서 loki·prometheus·tempo로 가는 화살표는 있지만 grafana에서 keycloak으로 가는 화살표는 없고, user에서 keycloak으로 따로 들어갑니다.

Grafana 트레이스에는 이 호출의 클라이언트 Span이 없고, Keycloak 쪽은 부모 없이 새 트레이스로 시작했습니다. Grafana가 데이터 소스를 부를 때는 트레이스가 이어지므로 Grafana 전체가 전파를 못 하는 것은 아니고, OAuth 토큰 교환에 쓰는 HTTP 클라이언트만 traceparent 헤더를 싣지 않는 것으로 보입니다. 받는 쪽(Keycloak)이 계측되어 있어도, 호출하는 쪽 클라이언트가 계측되어 있지 않으면 트레이스는 거기서 끊깁니다.

운영 포인트

트레이스가 서비스 사이에서 이어지려면 호출 경로의 모든 구간이 같은 전파 방식(보통 W3C traceparent)을 지원해야 합니다. 직접 만든 앱은 HTTP 클라이언트·DB 드라이버·메시지 큐 라이브러리까지 계측되어 있는지 확인하고, 이번 Grafana처럼 손댈 수 없는 구간은 "여기서 끊긴다"는 사실을 문서로 남겨 둡니다. 서비스 그래프에서 기대한 간선이 없는 것으로 끊긴 곳을 찾을 수 있습니다.

둘째, 아무도 로그인하지 않아도 트레이스가 계속 쌓였습니다. 5분 동안 생긴 트레이스 1,182개를 루트 이름으로 묶어 보니 대부분 사용자 요청이 아니었습니다.

유휴 상태 5분간 트레이스(루트 기준 상위)
서비스 루트 Span 개수 정체
grafana open session 280 Grafana 내부 DB 세션
grafana grpcutils.Authenticate 166 Grafana 내부 인증 호출
keycloak SELECT keycloak.JGROUPS_PING 94 7편 클러스터링(jdbc-ping)이 몇 초마다 DB로 동료 파드 확인
grafana initialization·validation·bootstrap 각 54 Grafana 내부 작업

Keycloak 클러스터 탐색 쿼리는 사용자 요청과 무관하므로 Collector의 filter 처리기로 받자마자 버렸습니다. 처음에는 SELECT만 막았는데 exemplar와 서비스 그래프에 INSERT·DELETE keycloak.JGROUPS_PING이 계속 보여서 정규식으로 넓혔습니다.

processors:
filter/noise:
error_mode: ignore
traces:
span:
- 'IsMatch(name, "^(SELECT|INSERT|UPDATE|DELETE) keycloak\\.JGROUPS_PING$")'

유휴 상태 120초 기준으로 Tempo에 보낸 Span이 2,143개에서 1,390개로 35% 줄었고, 넓힌 뒤 120초 동안 JGROUPS 트레이스는 0개였습니다. 남은 대부분은 Grafana 내부 작업입니다. 운영에서는 이런 소음이 저장 비용의 대부분을 차지할 수 있으므로, 처음 도입할 때 "아무도 안 쓸 때 몇 Span이 들어오는가"를 먼저 재 보는 것이 좋습니다.

11

샘플링: 전부 저장하면 생기는 일

트레이스 양은 요청 수에 비례합니다. 로그인 처리 한 번이 Keycloak에서만 Span 62개였으니, 초당 수백 건의 요청이 있는 서비스에서 전부 저장하면 저장 비용이 메트릭과 비교할 수 없게 커집니다. 그래서 일부만 남기는 샘플링이 필수입니다.

샘플링 방식 비교
방식 판단 시점·위치 장점 단점
head 샘플링 요청이 시작될 때, 앱에서(예: Keycloak samplerRatio) 앱과 네트워크 부담까지 줄어듦, 단순함 오류가 날지 미리 알 수 없어 오류 트레이스도 같은 비율로 버려짐
tail 샘플링 트레이스가 끝난 뒤, Collector에서 오류·느린 요청은 모두 남기고 정상 요청만 줄일 수 있음 트레이스가 끝날 때까지 Collector 메모리에 들고 있어야 함, 트레이스 단위로 한곳에 모아야 함

이 실습은 앱에서는 전부 보내고(head 1.0), Collector에서 tail 샘플링으로 정했습니다. 정책은 위에서부터 하나라도 맞으면 남깁니다.

tail_sampling:
decision_wait: 10s # 트레이스가 끝나길 10초 기다린 뒤 판단
num_traces: 20000 # 판단 대기 중인 트레이스를 메모리에 들고 있을 최대 개수
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow
type: latency
latency: { threshold_ms: 500 }
- name: sample-rest
type: probabilistic
probabilistic: { sampling_percentage: 100 }

나머지 정책의 비율만 바꿔 로그인을 30번씩 해 봤습니다.

sample-rest 비율에 따른 결과(로그인 30회)
비율 Tempo에 남은 로그인 트레이스 그 구간 sample-rest 판정
100% 30개 모두 sampled
10% 3개 sampled 31, not_sampled 299(약 9.4%)

비율대로 줄어드는 것을 확인했습니다. 첫 측정 구간에서는 오류 정책이 트레이스 8개, 느림 정책이 12개를 따로 남겼는데, 비율을 10%로 낮춰도 이런 트레이스는 그대로 모두 남습니다. 이것이 tail 샘플링을 쓰는 이유입니다. 실습에서는 화면 확인을 위해 100%로 되돌려 두었습니다.

운영 포인트

운영에서는 나머지 비율을 10% 안팎에서 시작하고, 오류·느린 요청 정책을 따로 둡니다. decision_wait보다 오래 걸리는 트레이스는 일부 Span만으로 판단되므로, 서비스의 가장 긴 요청 시간보다 넉넉하게 잡습니다. num_traces가 부족하면 판단 전에 트레이스가 밀려나므로 Collector 메모리 한도와 함께 정합니다. 또 샘플링으로 버린 요청은 트레이스로는 볼 수 없으므로, 정확한 요청 수와 오류율은 샘플링 전의 데이터로 만든 메트릭으로 봐야 합니다. 이 실습의 Tempo span metrics는 샘플링을 거친 뒤의 트레이스로 만들어지므로, 비율을 10%로 낮추면 요청 수도 함께 줄어 보입니다. 앱 자체 메트릭을 쓰거나, Collector의 span_metrics connector(이 배포판에 포함)를 샘플링 앞에 두어 만듭니다.

12

세 신호 연결: 메트릭 → 트레이스 → 로그

Grafana에 Tempo 데이터 소스를 추가하면서 로그·메트릭과의 연결을 함께 설정했습니다. kube-prometheus-stack의 Grafana는 grafana_datasource: "1" 라벨이 붙은 ConfigMap을 사이드카가 읽어 재시작 없이 등록합니다(5편과 같은 방식).

datasources:
- name: Tempo
type: tempo
uid: tempo
url: http://tempo.tracing.svc:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: "-1m"
spanEndTimeShift: "1m"
filterByTraceID: true
tags:
- key: k8s.namespace.name
value: namespace
- key: k8s.pod.name
value: pod
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true

tags는 Span의 리소스 속성(Collector의 k8s_attributes가 붙인 값)을 5편 Loki의 라벨 이름으로 바꿔 줍니다. Keycloak 트레이스에는 k8s.pod.name: keycloak-0, Grafana 트레이스에는 k8s.deployment.name: kps-grafana와 파드 이름이 붙어 있었습니다.

로그 쪽 재료는 이미 있었습니다. Keycloak은 트레이싱을 켜면 로그 형식에 트레이스 ID를 자동으로 넣고, Grafana 로그에는 원래 traceID=가 들어 있습니다.

# Keycloak 로그(요청 처리 중인 줄)
2026-11-08 10:14:39,192 traceId=e5bc06a6…, parentId=2a4e4e72…, spanId=a7a1576d…, sampled=true WARN [...TokenEndpoint.full-scope-allowed] ...
# Keycloak 로그(요청 밖의 줄)
2026-11-08 10:14:04,841 traceId=, parentId=, spanId=, sampled= INFO [...KeycloakApplication] Bootstrap completed ...
# Grafana 로그
logger=backgroundsvcs.managerAdapter traceID=602f1ae2… level=info msg=starting module=router

Loki 데이터 소스에 derivedFields를 추가해 로그 줄의 트레이스 ID를 Tempo 링크로 만들었습니다. ConfigMap 안에서 $는 Grafana 환경 변수 치환 기호라서 $$로 적어야 합니다.

jsonData:
derivedFields:
- name: TraceID
matcherRegex: '(?:traceId|traceID)=(\w+)'
datasourceUid: tempo
url: '$${__value.raw}'
urlDisplayLabel: 트레이스 보기

적용 후 Explore → Loki에서 {namespace="keycloak"} |~ "traceId=[0-9a-f]+"로 검색하면 로그 줄의 traceId=… 부분이 강조되고 링크가 됩니다. 토큰 요청 로그(19:34:22)의 링크를 누르자 오른쪽에 해당 트레이스가 열렸습니다. Keycloak의 POST .../token 처리 8.95ms, Span 16개였고, 그중 토큰 발급 처리(TokenEndpoint.processGrantRequest)가 6.58ms였습니다. 반대로 트레이스 화면의 "Logs for this trace" 버튼을 누르면 같은 트레이스 ID의 로그로 돌아갑니다.

위는 Loki에서 traceId=…가 강조된 Keycloak 로그이고, 아래는 그 링크를 눌러 연 토큰 발급 트레이스입니다. 로그 원문에는 뒤에서 설명할 full-scope-allowed 경고도 그대로 보입니다.

마지막으로 메트릭 → 트레이스 연결(exemplar)입니다. Tempo metrics generator가 만든 span metrics에는 exemplar가 붙고, Prometheus는 이를 exemplar-storage 기능으로 저장합니다. Grafana의 기본 Prometheus 데이터 소스에 exemplar 링크를 켜는 값은 kube-prometheus-stack에 있습니다.

grafana:
sidecar:
datasources:
exemplarTraceIdDestinations:
datasourceUid: tempo
traceIdLabelName: traceID
겪은 문제

처음에는 traceIdLabelName: trace_id로 적었습니다. Prometheus의 /api/v1/query_exemplars로 실제 exemplar를 조회해 보니 라벨 이름이 traceID였습니다(시리즈 163개, exemplar 489개). 라벨 이름이 다르면 오류 없이 링크만 생기지 않으므로, 설정 전에 실제 데이터의 라벨 이름을 확인하는 것이 빠릅니다.

연결 후 Grafana의 데이터 소스 상태 확인에서 Tempo는 "Data source is working", Loki는 "Data source successfully connected."를 반환했습니다. span metrics로 본 10분 p95는 로그인 처리 0.205초, 토큰 발급 0.031초, userinfo 0.015초였습니다. 이 값들은 앱 코드에 메트릭을 하나도 추가하지 않고 트레이스에서 만든 것입니다.

덤으로 발견한 것

트레이스 ID가 붙은 Keycloak 로그를 보다가 토큰 요청마다 TokenEndpoint.full-scope-allowed 경고가 찍히는 것을 발견했습니다. 메시지는 7편에서 만든 grafana 클라이언트에 "Full scope allowed" 스위치가 켜져 있고, 이 스위치는 더 이상 권장되지 않아(deprecated) 앞으로 제거될 예정이니 끄고 클라이언트에 필요한 역할만 client role scope로 설정하라는 내용입니다. 이 스위치가 켜져 있으면 토큰에 사용자의 모든 역할이 담깁니다. Grafana는 groups 값만 쓰므로, 끄고 필요한 범위만 매핑하는 것이 최소 권한 원칙에도 맞습니다. 관측 신호를 연결해 두면 이런 설정 문제도 함께 드러납니다.

13

장애 훈련: Collector나 Tempo가 멈추면 앱도 멈추나

관측 도구가 서비스를 멈추게 하면 안 됩니다. 로그인 시간을 재면서 Collector와 Tempo를 차례로 0개로 줄여 봤습니다. 로그인은 7편의 브라우저 흐름을 curl로 재현한 것(Grafana → Keycloak 로그인 → 콜백 → 사용자 정보)입니다.

# 훈련 A: Collector를 0개로
kubectl -n observability patch otelcol otel --type merge -p '{"spec":{"replicas":0}}'
# 훈련 B: Tempo를 0개로
kubectl -n tracing scale sts tempo --replicas=0
장애 훈련 결과
상황 로그인 5회 소요 시간(ms) 관측 데이터
정상(기준) 5/5 성공 503, 476, 441, 479, 424 -
A. Collector 0개(39초) 5/5 성공 433, 470, 407, 399, 463 중단 중 로그인 트레이스 5개 모두 복구 후 도착
B. Tempo 0개(46초) 5/5 성공 414, 407, 406, 421, 398 중단 중 로그인 트레이스 5개 모두 복구 후 조회됨

두 경우 모두 로그인 시간은 기준과 차이가 없었습니다. 앱의 OpenTelemetry SDK는 Span을 백그라운드에서 모아 보내기 때문에, 보낼 곳이 없어도 요청 처리를 기다리게 하지 않습니다.

훈련 A에서 Grafana는 10초마다 오류 로그를 남겼고, Keycloak 로그에는 내보내기 오류 줄이 없었습니다. Collector가 돌아온 뒤 중단 시간대의 Keycloak 로그인 트레이스 5개가 모두 Tempo에 들어왔습니다. SDK가 메모리 버퍼에 들고 있다가 다시 보낸 것으로 보입니다. 다만 이 버퍼는 크기가 정해져 있어서, 중단이 길어지거나 요청이 많으면 넘친 만큼은 사라집니다.

logger=tracing level=error msg="OpenTelemetry handler returned an error" err="traces export: exporter export timeout: rpc error: code = Unavailable desc = connection error: ... dial tcp ..."

훈련 B에서는 Collector의 큐가 일을 했습니다. Tempo가 멈추자 보낸 Span 수(otelcol_exporter_sent_spans)가 2,345에서 멈추고 큐 크기(otelcol_exporter_queue_size)가 0에서 12로 늘었으며, 로그에는 재시도 메시지가 찍혔습니다. Tempo가 돌아오고 60초 뒤 보낸 Span은 6,006으로 늘었고 큐는 3으로 줄었습니다. 중단 시간대의 로그인 트레이스 5개도 모두 조회됐습니다.

info internal/retry_sender.go:136 Exporting failed. Will retry the request after interval.
운영 포인트

Collector의 sending_queue는 기본이 메모리 큐라서, 저장소가 멈춘 사이 Collector까지 재시작되면 쌓아 둔 데이터가 사라집니다. 유실을 줄여야 한다면 file_storage 확장으로 디스크 큐를 쓰고, retry_on_failure.max_elapsed_time(이 실습은 300초)보다 긴 저장소 장애는 유실로 이어진다는 것을 전제로 둡니다. 큐가 가득 차면 memory_limiter와 함께 새 데이터를 거절하므로, 큐 크기와 거절 수에 알림을 겁니다.

14

모니터링: Collector 자신을 지켜보기

Collector 메트릭이 Prometheus로 들어오므로, 운영에서 지켜볼 값과 쿼리를 정리했습니다. 메트릭 이름은 이 실습의 Collector 0.162.0에서 확인한 이름입니다.

Collector 핵심 메트릭
보는 것 메트릭 의미
받은 양 otelcol_receiver_accepted_spans 앱이 보낸 Span. 갑자기 0이면 앱이나 네트워크 문제
거절 otelcol_receiver_refused_spans memory_limiter 등이 거절. 0이 아니면 Collector가 버거움
보낸 양 otelcol_exporter_sent_spans 저장소로 보낸 Span. 받은 양과 함께 보면 샘플링·필터 효과
보내기 실패 otelcol_exporter_send_failed_spans 재시도까지 실패한 Span = 유실. 실패가 한 번도 없으면 메트릭 자체가 아직 없음
큐 otelcol_exporter_queue_size 저장소가 느리거나 멈추면 늘어남
필터 otelcol_processor_filter_spans.filtered 소음 필터로 버린 Span(Prometheus 3에서는 이름에 점이 그대로 남음)
샘플링 판정 otelcol_processor_tail_sampling_count_traces_sampled 정책별로 남긴·버린 트레이스 수
# 받은 Span 대비 Tempo로 보낸 비율(샘플링·필터 후 남는 비율)
sum(rate(otelcol_exporter_sent_spans[5m])) / sum(rate(otelcol_receiver_accepted_spans[5m]))

# 큐가 5분 넘게 쌓이는 중 → 저장소 문제 의심
max_over_time(otelcol_exporter_queue_size[5m]) > 100

# 유실 발생
sum(increase(otelcol_exporter_send_failed_spans[10m])) > 0

자원 사용량도 적어 둡니다. 유휴에 가까운 상태에서 메모리(working set)는 Tempo 142.5MiB, Collector 25.1MiB, Operator 36.5MiB였고, 다시 설치한 Loki는 171.6MiB, Fluent Bit 3개는 합계 18.6MiB였습니다. 이번 편의 모든 구성 요소를 띄운 뒤 WSL 메모리 사용은 23GiB 중 6.6GiB였습니다.

15

운영 환경으로: AWS OTLP 엔드포인트

OTel로 계측해 두면 저장소를 바꿀 때 Collector의 exporter만 바꿉니다. AWS는 CloudWatch와 X-Ray가 OTLP를 직접 받는 엔드포인트를 제공합니다. 앱이나 Collector가 OTLP로 보내면 AWS 전용 형식으로 바꾸지 않아도 그대로 들어갑니다.

AWS OTLP 엔드포인트
신호 엔드포인트 저장·조회
트레이스 https://xray.<리전>.amazonaws.com/v1/traces X-Ray, CloudWatch Transaction Search, Application Signals
로그 https://logs.<리전>.amazonaws.com/v1/logs CloudWatch Logs
메트릭 https://monitoring.<리전>.amazonaws.com/v1/metrics CloudWatch(PromQL 조회 가능)

AWS 문서는 새로 만드는 관측 구성에 "OTel SDK → OTel Collector → CloudWatch OTLP 엔드포인트" 경로를 권장하며, 이 경로는 EKS, ECS, EC2, 온프레미스에서 똑같이 동작한다고 설명합니다. https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/GettingSetup.html

이 실습 구성에서 Tempo 대신 AWS로 보낸다면 exporter와 인증 확장만 바뀝니다. 아래는 구성 예시이며 이 실습에서 적용하지 않았습니다. 키는 어디에도 적지 않습니다.

# 예시(적용하지 않음): contrib 또는 ADOT 배포판 Collector 기준
extensions:
sigv4auth/xray:
region: ap-northeast-2
service: xray
exporters:
otlphttp/xray:
traces_endpoint: https://xray.ap-northeast-2.amazonaws.com/v1/traces
auth:
authenticator: sigv4auth/xray
service:
extensions: [sigv4auth/xray]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8s_attributes, tail_sampling, batch]
exporters: [otlphttp/xray]
주의

앞에서 확인했듯 이 실습의 Collector 이미지(opentelemetry-collector-k8s 0.162.0)에는 sigv4auth 확장이 없습니다. AWS로 SigV4 인증을 해서 보내려면 확장이 들어 있는 contrib 배포판이나 AWS가 배포하는 ADOT(AWS Distro for OpenTelemetry) Collector로 이미지를 바꿔야 합니다. 예시의 구성 요소 이름도 쓰는 배포판·버전의 components 출력으로 다시 확인합니다.

  • ADOT: AWS가 배포하는 OTel Collector와 SDK입니다. 기본 Collector에 X-Ray·CloudWatch 전송 기능과 SigV4 인증을 넣어 둔 버전입니다.
  • CloudWatch 에이전트: 이 에이전트도 로컬 4317(gRPC)·4318(HTTP)로 OTLP를 받아 CloudWatch로 넘길 수 있습니다.
  • 비용: 받은 메트릭·로그·트레이스 양만큼 요금이 나오므로, 이 글의 소음 필터와 tail 샘플링을 Collector에서 먼저 적용한 뒤 보내는 것이 비용에 직접 영향을 줍니다.
16

SigV4와 자격 증명

AWS로 보내는 데이터는 인증이 필요합니다. AWS 문서는 트레이스 OTLP 엔드포인트의 인증으로 SigV4(권장)와 Bearer 토큰(API 키) 두 가지를 안내합니다. https://docs.aws.amazon.com/xray/latest/devguide/xray-opentelemetry.html

SigV4(Signature Version 4)는 AWS API 요청에 서명해서 "누가 보냈는지"와 "중간에 바뀌지 않았는지"를 증명하는 방식입니다. AWS CLI, SDK, Collector가 AWS로 보내는 요청 대부분이 이 방식을 씁니다. 핵심은 비밀 키를 보내지 않는다는 점입니다.

  • 자격 증명은 Access Key ID(공개 이름표)와 Secret Access Key(비밀)로 이루어집니다.
  • 요청을 보낼 때 Secret Key를 그대로 보내지 않고, Secret Key로 요청 내용을 서명(HMAC-SHA256)한 값만 붙입니다.
  • 서명에는 요청 메서드·경로·주요 헤더·본문 해시와 함께 시각, 날짜, 리전, 서비스 이름이 들어갑니다. 그래서 오래된 요청을 가로채 다시 보내거나, 서울 리전 X-Ray용 서명을 다른 리전·다른 서비스에 쓰는 것이 막힙니다.
  • AWS는 같은 방식으로 서명을 다시 계산해 비교하고, 일치할 때만 요청을 받아들입니다. 요청을 누가 엿봐도 Secret Key는 알 수 없고, 내용을 바꾸면 서명이 맞지 않습니다.
SigV4와 Bearer 토큰 비교
구분 SigV4 Bearer 토큰(API 키)
보내는 것 요청마다 새로 계산한 서명 토큰 문자열 그대로
유출 시 서명 하나로는 다른 요청에 재사용이 어려움 토큰만 있으면 누구나 사용 가능
자격 증명 보통 IAM 역할의 단기 자격 증명(자동 만료·갱신) 장기 키인 경우가 많음
구현 복잡해서 SDK나 Collector 확장이 대신 처리 헤더 한 줄이라 간단
쓰는 곳 AWS 안의 워크로드(EKS, ECS, EC2) AWS SDK나 단기 자격 증명을 쓸 수 없는 곳(다른 클라우드 등)

EKS에서는 Collector 파드의 서비스 계정에 IAM 역할을 연결합니다(IRSA 또는 EKS Pod Identity). 그러면 Collector가 단기 자격 증명을 자동으로 받아 SigV4로 서명하므로, 키를 매니페스트나 Secret에 넣을 필요가 없습니다. 역할에는 트레이스를 보내는 데 필요한 권한만 줍니다.

로컬 kind 클러스터에서 AWS로 보내려면 결국 장기 키를 어딘가에 둬야 합니다. 이 실습에서 AWS로 실제로 보내지 않고 설정 예시로만 다룬 이유입니다.

17

운영 체크리스트

이번 실습에서 확인한 것과 운영으로 옮길 때 추가로 할 일을 정리합니다.

  • 차트와 이미지는 사내 레지스트리에 복제하고 버전을 고정한다. 외부 저장소 장애(이번의 504)가 배포 장애로 이어지지 않게 한다.
  • Tempo는 persistence를 켜고 보관 기간을 정한다. 기본값(꺼짐)은 재시작 때 트레이스를 모두 잃는다. 운영 규모에서는 객체 저장소(S3 등)를 쓴다.
  • Collector는 memory_limiter를 맨 앞, batch를 맨 뒤에 두고, 메모리 한도와 tail 샘플링의 num_traces를 함께 정한다.
  • tail 샘플링을 쓰면서 Collector를 늘릴 때는 load_balancing 계층으로 트레이스 ID 단위로 모은다.
  • 도입 직후 유휴 상태의 Span 양을 재고, 클러스터 탐색·헬스 체크 같은 소음을 필터로 버린다.
  • 오류·느린 요청은 모두 남기고 나머지만 비율로 줄인다. 정확한 요청 수·오류율은 샘플링 전 데이터로 만든 메트릭으로 본다.
  • Operator가 자동으로 만드는 ServiceMonitor 같은 부속 리소스가 Prometheus 선택 라벨과 맞는지 확인한다.
  • Collector의 거절·보내기 실패·큐 크기에 알림을 건다. Collector가 관측되지 않으면 유실을 알 수 없다.
  • 서비스 그래프에서 기대한 간선이 있는지 보고, 트레이스가 끊기는 구간(이번 Grafana → Keycloak)은 문서로 남긴다.
  • 로그 형식에 트레이스 ID가 들어가는지 확인하고, 로그 저장소 데이터 소스에 트레이스 링크를 건다.
  • Collector와 저장소를 일부러 멈춰 앱 영향이 없는지, 데이터가 얼마나 살아남는지 측정해 둔다(이번: 39초·46초 중단에서 유실 없음).
  • AWS로 보낼 때는 sigv4auth가 있는 배포판(contrib·ADOT)을 쓰고, 키 대신 IRSA나 Pod Identity로 단기 자격 증명을 쓴다.
19

자주 묻는 질문

OpenTelemetry와 Prometheus는 경쟁 관계인가요?

아닙니다. OpenTelemetry는 데이터를 만들고 나르는 쪽이고 Prometheus는 메트릭을 저장하고 조회하는 쪽입니다. 이 실습에서도 Tempo가 트레이스에서 만든 메트릭을 Prometheus로 보냈고, Collector 자신의 메트릭도 Prometheus가 수집했습니다. OTel Collector가 메트릭을 받아 Prometheus로 보내는 구성도 흔합니다.

Fluent Bit을 OTel Collector로 바꿔야 하나요?

꼭 그렇지는 않습니다. 로그 수집이 안정적으로 돌고 있다면 이 글처럼 Fluent Bit은 로그, OTel은 트레이스를 맡겨도 됩니다. 트레이스와 로그를 잇는 데 필요한 것은 수집 도구가 아니라 로그 줄에 트레이스 ID가 들어 있는지와, 로그 라벨(namespace, pod)이 트레이스의 리소스 속성과 맞는지입니다.

트레이스는 얼마나 보관해야 하나요?

트레이스는 주로 최근 장애의 원인을 찾는 데 쓰므로 메트릭·로그보다 짧게 둡니다. 이 실습은 주말을 넘긴 장애도 볼 수 있게 72시간으로 했습니다. 오래 봐야 하는 추세(요청 수, 지연, 오류율)는 트레이스에서 만든 메트릭으로 Prometheus에 남기면 트레이스 원본을 오래 들고 있지 않아도 됩니다.

AWS로 보낼 때 키는 어디에 두나요?

EKS라면 키를 두지 않는 것이 원칙입니다. Collector의 서비스 계정에 IRSA나 EKS Pod Identity로 IAM 역할을 연결하면, Collector가 단기 자격 증명을 자동으로 받아 SigV4로 서명합니다. 매니페스트나 Secret에 Access Key를 넣는 방식은 유출과 교체 부담이 커서 피합니다.

작성 방식과 다음 편

이 글의 명령, 오류 메시지, 측정값은 1편의 실습 PC에서 직접 실행한 것이며, 설정은 OpenTelemetry, Grafana Tempo, Keycloak, Grafana, AWS 공식 문서를 기준으로 했습니다. AWS 부분은 실제로 전송하지 않은 구성 예시이고, 키와 비밀번호는 출력하지 않았습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 지금까지 손으로 적용한 매니페스트와 Helm 값을 Argo CD로 Git에서 관리하는 GitOps 구성으로 옮깁니다.

반응형

Keycloak 운영 모드로 Grafana SSO 만들기: Keycloak Operator, CloudNativePG, 이중화와 장애·백업 훈련

반응형
로컬 쿠버네티스 실습 7편
Keycloak 운영 모드
Grafana SSO와 장애 훈련까지

Keycloak 2개 + PostgreSQL 2개로 SSO를 만들고, 파드와 DB를 일부러 죽여 본 기록

KeycloakKeycloak OperatorCloudNativePGGrafana SSO

Keycloak은 로그인과 사용자·권한 관리를 대신해 주는 오픈소스 인증 서버이고, 운영에서는 개발 모드(start-dev)가 아니라 외부 데이터베이스와 고정 주소를 쓰는 운영 모드(start)로 띄웁니다. kind 클러스터에 Keycloak Operator 26.8.2로 Keycloak 2개를, CloudNativePG 1.30.1로 PostgreSQL 2개(주 서버 + 복제본)를 띄우고, 6편의 HTTPS 위에서 Grafana 로그인을 Keycloak SSO로 바꿨습니다. 그룹에 따라 Admin과 Viewer가 나뉘고 그룹이 없는 사용자는 거부되는 것까지 확인했습니다. 그다음 Keycloak 파드와 DB 주 서버를 일부러 죽여, Keycloak 쪽은 끊김 없이 넘어가고 DB 쪽은 약 8초 동안 로그인이 실패하는 것을 측정했습니다. 설치 중에는 CRD 누락으로 인한 운영자 CrashLoopBackOff, 한 노드에 몰린 파드, 포트가 빠진 토큰 주소, 스스로 막힌 가져오기 Job을 만났고, 원인과 해결 방법을 함께 정리했습니다.

1~6편의 kind 클러스터(노드 3개, Envoy Gateway, kube-prometheus-stack의 Grafana·Prometheus, cert-manager 사설 CA HTTPS)를 이어서 씁니다. Keycloak 26.8.2, Keycloak Operator 26.8.2, CloudNativePG 1.30.1(차트 0.29.1), PostgreSQL 18.6 기준입니다. 백업은 pg_dump로 받고 복구까지 확인했으며, 객체 저장소를 쓰는 연속 백업(특정 시점 복구)은 로컬에 저장소가 없어 구성 방법만 설명합니다. 비밀번호와 클라이언트 비밀값은 모두 클러스터 안에서 무작위로 만들어 Secret에만 넣었습니다.

01

이번 편에서 만드는 구성

6편까지 Grafana는 관리자 계정 하나로 로그인했습니다. 사람이 늘면 계정을 Grafana마다, 도구마다 따로 만들게 되고, 퇴사자 계정 정리도 도구마다 해야 합니다. SSO(Single Sign-On)는 로그인을 한 곳(인증 서버)에 모으고, 각 도구는 그 인증 서버가 발급한 토큰을 믿게 하는 방식입니다. 이번 편에서는 Keycloak을 인증 서버로 두고 Grafana를 첫 번째 연결 대상으로 붙입니다.

7편 구성 요소
구성 요소 개수·위치 역할
Keycloak 2개(워커 노드마다 1개) 로그인 화면, 사용자·그룹 관리, 토큰 발급
Keycloak Operator 1개 Keycloak 리소스를 보고 StatefulSet, Service, 설정을 만들어 줌
PostgreSQL(CloudNativePG) 2개(주 서버 + 복제본) 사용자, 클라이언트, 세션 저장
Gateway(6편) 1개 https://keycloak.localhost:8443에서 TLS를 풀고 Keycloak으로 전달
Grafana(4편) 1개 Keycloak으로 로그인하고 그룹을 역할로 바꿈
Prometheus(4편) 1개 Keycloak과 PostgreSQL 지표 수집

글이 길어서 순서를 먼저 적어 둡니다. Keycloak 종류와 실행 모드를 정리하고, DB → Keycloak → realm → Grafana 순서로 만든 뒤, 지표·장애·백업을 확인합니다.

02

Keycloak의 종류: 배포판, 설치 방식, 실행 모드

Keycloak을 검색하면 설정 방법이 서로 다른 글이 섞여 나옵니다. 대부분 아래 세 가지 중 하나가 달라서 생기는 차이입니다. 무엇을 기준으로 한 글인지 먼저 구분하면 헷갈림이 크게 줄어듭니다.

배포판: 무엇을 설치하나
배포판 특징 구분하는 방법
Keycloak(현재, Quarkus 기반) CNCF 프로젝트인 오픈소스 Keycloak. 17 버전(2022년)부터 Quarkus 기반이 기본. 이 글의 26.8.2가 여기에 해당 설정을 kc.sh 옵션·환경 변수로 줌. 주소에 /auth가 없음
예전 WildFly 기반 Keycloak 16 버전까지의 기본 배포판. 지금은 지원이 끝남 standalone.xml, JBoss CLI, 주소의 /auth가 보이면 이 방식의 글
Red Hat build of Keycloak Red Hat이 유상 지원하는 Keycloak 빌드. 예전 Red Hat SSO(RH-SSO, WildFly 기반)의 후속 버전 번호와 지원 정책이 Red Hat 기준. 사용법은 오픈소스 Keycloak과 거의 같음
쿠버네티스 설치 방식
방식 장점 주의할 점
Keycloak Operator(공식) Keycloak 리소스 하나로 StatefulSet, 서비스, 클러스터링, ServiceMonitor, realm 가져오기를 관리. 버전을 Keycloak과 같이 냄 CRD와 운영자 버전을 함께 관리해야 함. 운영자가 모르는 설정은 unsupported.podTemplate으로 우회
커뮤니티 Helm 차트(codecentric keycloakx 등) Helm values로 익숙하게 관리 공식 지원이 아니므로 Keycloak 새 버전 반영 속도와 기본값을 직접 확인
Bitnami Helm 차트 많이 쓰여 예제가 많음 2025년 Bitnami 이미지 배포 정책이 바뀌어, 새로 시작한다면 이미지 출처와 갱신 정책을 먼저 확인
직접 매니페스트 모든 것을 직접 통제 클러스터링, 헬스 체크, 업그레이드 절차를 모두 직접 만들어야 함
실행 모드: start-dev와 start
항목 개발 모드(start-dev) 운영 모드(start)
DB 내장 H2 파일 DB 외부 DB 필수(이 글은 PostgreSQL)
주소(hostname) 설정하지 않아도 됨 고정 주소 설정 필요. 토큰의 발급자(iss)가 이 주소가 됨
HTTP HTTP 허용 기본은 HTTPS. 프록시에서 TLS를 끝내면 프록시 헤더 설정과 함께 HTTP를 켬
캐시·클러스터 혼자 동작 여러 개가 하나의 클러스터로 묶여 세션을 나눠 가짐
용도 로컬에서 기능 확인 실제 사용자 로그인

Keycloak Operator는 항상 운영 모드로 띄웁니다. 이 실습의 시작 로그에도 Profile prod activated가 찍혔습니다. 그래서 DB, 주소, 프록시 설정을 처음부터 운영과 같은 방식으로 정해야 합니다. Keycloak 운영 환경 설정 가이드

03

DB 먼저: CloudNativePG로 PostgreSQL 2개 띄우기

Keycloak은 사용자, 클라이언트, 세션을 DB에 저장합니다. Keycloak 파드가 여러 개여도 DB가 하나뿐이면 DB가 죽는 순간 로그인이 멈추므로, DB도 이중화합니다. CloudNativePG는 PostgreSQL을 쿠버네티스 리소스(Cluster)로 관리하는 운영자로, 주 서버와 복제본을 만들고 주 서버가 죽으면 복제본을 주 서버로 올립니다. CloudNativePG 문서

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm upgrade --install cnpg cnpg/cloudnative-pg --version 0.29.1 \
-n cnpg-system --create-namespace --wait
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: keycloak-db
namespace: keycloak
spec:
instances: 2 # 주 서버 1 + 복제본 1
affinity:
enablePodAntiAffinity: true
podAntiAffinityType: required # 두 인스턴스를 반드시 다른 노드에
bootstrap:
initdb:
database: keycloak
owner: keycloak # 비밀번호는 운영자가 만들어 Secret keycloak-db-app에 넣음
storage:
size: 2Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi

적용하고 92초 뒤 두 인스턴스가 준비되었습니다. 결과에서 운영에 바로 쓰이는 것은 서비스와 Secret입니다.

NAME INSTANCES READY STATUS PRIMARY
keycloak-db 2 2 Cluster in healthy state keycloak-db-1

keycloak-db-1 primary lab-worker2
keycloak-db-2 replica lab-worker

서비스: keycloak-db-rw(항상 주 서버), keycloak-db-ro(복제본만), keycloak-db-r(아무 인스턴스)
Secret: keycloak-db-app(사용자·비밀번호), keycloak-db-ca, keycloak-db-server, keycloak-db-replication
복제 상태(pg_stat_replication): streaming | async

Keycloak은 keycloak-db-rw에 연결합니다. 주 서버가 바뀌면 운영자가 이 서비스가 가리키는 파드를 바꿔 주므로, Keycloak 설정은 바꿀 필요가 없습니다. 비밀번호도 운영자가 만든 Secret을 그대로 참조하므로 사람이 볼 일이 없습니다.

운영 포인트

복제는 기본이 비동기(async)라서, 주 서버가 죽는 순간 아직 복제본에 넘어가지 않은 마지막 쓰기가 사라질 수 있습니다. 이 손실이 허용되지 않는다면 동기 복제(minSyncReplicas·maxSyncReplicas)와 인스턴스 3개를 검토합니다. 또 kind 기본 저장소(local-path)는 볼륨이 노드에 묶여 있어서, 노드가 사라지면 그 인스턴스는 다른 노드에서 새로 복제해 와야 합니다. 클라우드에서는 영역(AZ)을 넘나드는 블록 스토리지와 함께 인스턴스를 영역마다 나눠 둡니다.

04

Keycloak Operator 설치: CRD가 4개로 늘었다

Keycloak Operator는 공식 저장소(keycloak-k8s-resources)의 매니페스트로 설치합니다. 예전 안내에 따라 CRD 2개(keycloaks, keycloakrealmimports)와 운영자만 적용했더니, 운영자가 CrashLoopBackOff에 빠졌습니다.

Couldn't start informer for keycloaksamlclients.k8s.keycloak.org/v2alpha1 resources
... GET .../apis/k8s.keycloak.org/v2alpha1/namespaces/keycloak/keycloaksamlclients ... Message: Not Found.
Failed to start application: java.lang.RuntimeException: Failed to start quarkus

26.8 버전의 운영자는 클라이언트를 리소스로 관리하는 기능(KeycloakOIDCClient, KeycloakSAMLClient)도 감시하는데, 그 CRD가 없으니 시작하지 못한 것입니다. 저장소의 kustomization.yml에 필요한 파일 목록이 있어서, 그 목록대로 CRD 4개를 모두 적용하자 11초 만에 Running이 되었습니다.

V=26.8.2
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
kubectl apply -f $B/keycloaks.k8s.keycloak.org-v1.yml
kubectl apply -f $B/keycloakrealmimports.k8s.keycloak.org-v1.yml
kubectl apply -f $B/keycloakoidcclients.k8s.keycloak.org-v1.yml # 26.8에서 필요
kubectl apply -f $B/keycloaksamlclients.k8s.keycloak.org-v1.yml # 26.8에서 필요
kubectl apply -n keycloak -f $B/kubernetes.yml

$ kubectl api-resources --api-group=k8s.keycloak.org
keycloakoidcclients k8s.keycloak.org/v2alpha1 KeycloakOIDCClient
keycloakrealmimports k8s.keycloak.org/v2beta1 KeycloakRealmImport
keycloaks kc k8s.keycloak.org/v2beta1 Keycloak
keycloaksamlclients k8s.keycloak.org/v2alpha1 KeycloakSAMLClient
운영 포인트

운영자를 올릴 때는 버전마다 CRD 목록과 API 버전이 바뀌었는지 먼저 봅니다. 파일을 하나씩 나열해 적용하기보다, 저장소의 kustomization처럼 버전별로 묶인 목록을 그대로 쓰는 편이 빠뜨림이 없습니다. 운영자는 기본적으로 자기가 설치된 네임스페이스만 감시하므로, Keycloak 리소스도 같은 네임스페이스(keycloak)에 둡니다. Keycloak Operator 설치 문서

05

Keycloak 리소스: 주소, 프록시, 관리자 비밀번호

관리자 초기 계정은 매니페스트에 쓰지 않고, 클러스터 안에서 무작위 비밀번호로 Secret을 만들었습니다. 이 계정은 이름 그대로 처음 들어가기 위한 임시 계정입니다.

umask 077
openssl rand -base64 24 | tr -d '\n' > password # 파일 이름이 Secret의 키 이름이 된다
kubectl create secret generic keycloak-bootstrap-admin -n keycloak \
--from-literal=username=temp-admin --from-file=password
rm password
apiVersion: k8s.keycloak.org/v2beta1
kind: Keycloak
metadata:
name: keycloak
namespace: keycloak
spec:
instances: 2
db:
vendor: postgres
host: keycloak-db-rw
database: keycloak
usernameSecret: {name: keycloak-db-app, key: username}
passwordSecret: {name: keycloak-db-app, key: password}
http:
httpEnabled: true # TLS는 Gateway에서 끝냄(6편)
hostname:
hostname: https://keycloak.localhost:8443
proxy:
headers: xforwarded # Envoy가 붙이는 X-Forwarded-* 헤더를 믿음
ingress:
enabled: false # Ingress 대신 HTTPRoute
bootstrapAdmin:
user:
secret: keycloak-bootstrap-admin
additionalOptions:
- name: metrics-enabled
value: "true"
- name: event-metrics-user-enabled
value: "true"
serviceMonitor:
enabled: true
labels:
release: kps
scheduling:
topologySpreadConstraints: # 이유는 아래 '운영 함정' 참고
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
nodeTaintsPolicy: Honor
labelSelector:
matchLabels:
app: keycloak
resources:
requests: {cpu: 200m, memory: 768Mi}
limits: {memory: 1536Mi}

hostname은 브라우저가 보는 전체 주소입니다. 포트까지 적은 이유는 Windows에서 들어오는 포트가 8443이기 때문입니다. 이 값이 토큰의 발급자(iss)가 되므로, 한 번 정하면 바꾸기 어렵습니다. 바꾸면 이 주소를 기준으로 토큰을 검증하던 모든 앱의 설정을 함께 바꿔야 합니다. Keycloak hostname 설정 문서

Gateway에는 6편의 HTTPS 리스너에 붙는 라우트를 추가했습니다. 6편에서 인증서에 keycloak.localhost를 미리 넣고 HTTP → HTTPS 이동에도 넣어 두었기 때문에, 인증서와 리다이렉트는 손댈 것이 없습니다.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: keycloak
namespace: keycloak
spec:
parentRefs:
- name: lab-gateway
namespace: default
sectionName: https
hostnames:
- keycloak.localhost
rules:
- backendRefs:
- name: keycloak-service
port: 8080

82초 뒤 Ready가 되었고, 로그에서 두 파드가 하나의 클러스터로 묶이는 것을 볼 수 있습니다. 파드끼리 서로를 찾는 방법(JGroups jdbc-ping)은 DB에 자기 주소를 적어 두고 서로 읽는 방식이라, 별도 설정이 필요 없었습니다.

Keycloak 26.8.2 on JVM (powered by Quarkus …) started in 5.878s.
Profile prod activated.
ISPN000078: Starting JGroups channel `ISPN` with stack `jdbc-ping`
ISPN100000: Node keycloak-1 joined the cluster
06

운영 함정 3가지: 몰린 파드, 빠진 포트, 스스로 막힌 Job

Keycloak이 Ready가 된 뒤 확인해 보니 문제가 세 가지 있었습니다. 모두 에러 없이 "동작은 하는" 상태라 확인하지 않으면 지나치기 쉬운 것들입니다.

첫째, 두 파드가 모두 lab-worker 한 노드에 떠 있었습니다. 운영자는 기본 분산 규칙(topologySpreadConstraints)을 넣지만, 값이 whenUnsatisfiable: ScheduleAnyway라 "가능하면 나눠라" 수준입니다. 이 상태로는 노드 하나가 죽으면 Keycloak 두 개가 함께 죽어서 이중화한 의미가 없습니다.

둘째, 설정 정보(discovery 문서)의 토큰 주소에 포트가 빠져 있었습니다. 처음에는 hostname.backchannelDynamic: true를 켜 두었는데, 이 옵션은 일부 주소를 요청이 들어온 Host로 만듭니다. Envoy가 Host에서 포트를 떼고 넘기다 보니 :8443이 사라졌고, 이 주소를 그대로 쓰는 앱은 토큰을 받지 못합니다. 옵션을 끄자 모든 주소가 hostname 기준으로 고정되었습니다.

# backchannelDynamic: true
issuer: https://keycloak.localhost:8443/realms/master
token: https://keycloak.localhost/realms/master/protocol/openid-connect/token ← 포트 없음

# 끈 뒤
token: https://keycloak.localhost:8443/realms/master/protocol/openid-connect/token

셋째, 첫 번째 문제를 고치려고 "같은 라벨(app.kubernetes.io/instance: keycloak)이 있는 노드에는 띄우지 마라"는 required podAntiAffinity를 넣었습니다. 그러자 realm 가져오기 Job 파드가 5분 넘게 Pending에 머물렀습니다.

0/3 nodes are available: 1 node(s) had untolerated taint(s),
2 node(s) didn't match pod anti-affinity rules.

# 가져오기 Job 파드의 라벨과 배치 규칙
labels: app=keycloak-realm-import, app.kubernetes.io/instance=keycloak
affinity: podAntiAffinity required, app.kubernetes.io/instance=keycloak

운영자는 가져오기 Job을 Keycloak과 같은 설정으로 만듭니다. 그래서 Job 파드도 같은 라벨과 규칙을 물려받았고, "Keycloak이 있는 노드는 피하라"는 규칙 때문에 워커 2대를 모두 피했습니다. 남은 control-plane 노드는 테인트로 막혀 있어 갈 곳이 없었습니다. 서버 파드에만 있는 라벨(app: keycloak)로 분산 규칙(DoNotSchedule)을 걸자 Job 파드는 영향을 받지 않았고, 서버 파드는 노드마다 하나씩 배치되었습니다. Topology Spread Constraints 문서

고친 뒤 Headlamp에서 본 파드 목록입니다. keycloak-0과 keycloak-1, keycloak-db-1과 keycloak-db-2가 각각 lab-worker와 lab-worker2에 나뉘어 있고, realm 가져오기 Job(lab-4z9vt)은 Completed로 끝났습니다.

이 설정들을 바꾸는 동안 Keycloak은 StatefulSet 롤링 교체로 한 개씩 바뀌었습니다. 교체 77초 동안 1초마다 접속한 74번이 모두 200이었습니다. 6편에서 hostPort로 띄운 Envoy가 교체 중 72초 동안 끊겼던 것과 비교하면, 여러 개를 띄우는 것이 왜 중요한지 보입니다.

운영 포인트

이중화는 파드 수가 아니라 "서로 다른 장애 영역에 있느냐"로 확인합니다. 배포 뒤에는 kubectl get pods -o wide로 노드를, 클라우드라면 영역(zone)까지 확인합니다. 운영자가 만드는 부속 파드(Job 등)가 서버의 배치 규칙을 물려받는지도 함께 봅니다.

07

realm과 클라이언트: 비밀값 없이 선언하기

realm은 Keycloak 안의 독립된 사용자 공간입니다. 사용자, 그룹, 클라이언트(로그인을 맡기는 앱), 보안 정책이 realm마다 따로 있습니다. 관리용 master realm은 Keycloak 관리자 전용으로 두고, 실제 사용자는 별도 realm(lab)에 둡니다.

KeycloakRealmImport는 realm 설정을 JSON(여기서는 YAML) 그대로 넣는 리소스입니다. 클라이언트 비밀값과 사용자 비밀번호는 ${...} 자리표시자로 두고, 실제 값은 Secret에서 채우게 했습니다. 그래서 이 파일은 Git에 올려도 비밀값이 없습니다. Keycloak realm 가져오기 문서

apiVersion: k8s.keycloak.org/v2beta1
kind: KeycloakRealmImport
metadata:
name: lab
namespace: keycloak
spec:
keycloakCRName: keycloak
placeholders:
GRAFANA_CLIENT_SECRET:
secret: {name: lab-realm-secrets, key: grafana-client-secret}
ALICE_PASSWORD:
secret: {name: lab-realm-secrets, key: alice-password}
# BOB_PASSWORD, CAROL_PASSWORD 동일
realm:
realm: lab
enabled: true
sslRequired: external
bruteForceProtected: true # 로그인 실패가 반복되면 잠시 잠금
failureFactor: 5
passwordPolicy: "length(12) and notUsername"
accessTokenLifespan: 300 # 액세스 토큰 5분
ssoSessionIdleTimeout: 1800 # 30분 동안 활동이 없으면 SSO 세션 종료
groups:
- name: grafana-admins
- name: grafana-editors
- name: grafana-viewers
clients:
- clientId: grafana
publicClient: false # 기밀 클라이언트: Grafana 서버가 비밀값으로 토큰을 받음
secret: ${GRAFANA_CLIENT_SECRET}
standardFlowEnabled: true # 브라우저 로그인(Authorization Code)
directAccessGrantsEnabled: false
redirectUris:
- https://grafana.localhost:8443/login/generic_oauth
webOrigins:
- https://grafana.localhost:8443
attributes:
pkce.code.challenge.method: S256
post.logout.redirect.uris: https://grafana.localhost:8443/login
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
claim.name: groups
full.path: "false"
id.token.claim: "true"
access.token.claim: "true"
userinfo.token.claim: "true"
users: # alice: grafana-admins, bob: grafana-viewers, carol: 그룹 없음
  • redirectUris를 정확한 주소 하나로 둡니다. *를 쓰면 공격자가 만든 주소로 로그인 결과(코드)를 빼돌릴 수 있습니다.
  • directAccessGrantsEnabled: false는 앱이 사용자 아이디·비밀번호를 직접 받아 Keycloak에 넘기는 방식을 막습니다. 비밀번호는 Keycloak 화면에서만 입력되어야 SSO의 의미가 있습니다.
  • PKCE(S256)는 로그인 중간에 코드가 새도 그 코드만으로는 토큰을 받을 수 없게 합니다.
  • groups 매퍼는 사용자의 그룹 이름을 토큰과 userinfo에 groups로 넣습니다. Grafana는 이 값을 보고 역할을 정합니다.

가져오기는 26초 걸렸고, Job 로그에 Realm 'lab' imported가 찍혔습니다.

주의

KeycloakRealmImport는 realm이 없을 때 한 번 만드는 용도입니다. 이미 realm이 있으면 덮어쓰지 않으므로, 파일을 고쳐 다시 적용해도 기존 realm은 바뀌지 않습니다. 운영 중 변경은 관리 콘솔, Admin REST API, 또는 이를 이용하는 설정 관리 도구로 하고, 그 변경 절차를 따로 정해 둡니다.

08

Grafana SSO 연결: 프런트 채널과 백 채널

OIDC 로그인에는 두 종류의 통신이 있습니다. 브라우저가 Keycloak 로그인 화면으로 이동하는 프런트 채널과, Grafana 서버가 Keycloak에 직접 코드를 보내 토큰으로 바꾸는 백 채널입니다. 둘은 출발지가 달라서 주소도 다르게 줄 수 있습니다.

Grafana가 쓰는 Keycloak 주소
설정 누가 호출 주소
auth_url 브라우저 https://keycloak.localhost:8443/realms/lab/.../auth(Gateway, HTTPS)
token_url Grafana 서버 http://keycloak-service.keycloak.svc:8080/realms/lab/.../token(클러스터 내부)
api_url(userinfo) Grafana 서버 http://keycloak-service.keycloak.svc:8080/realms/lab/.../userinfo

백 채널을 내부 주소로 둔 이유가 있습니다. 클러스터 안에서 keycloak.localhost:8443은 Windows 쪽 포트라 바로 닿지 않습니다. 맞추려면 CoreDNS 재작성과 Grafana의 사설 CA 신뢰를 추가로 설정해야 합니다. 내부 주소를 쓰면 그런 설정이 필요 없고, 토큰의 발급자는 Keycloak의 hostname 설정 덕분에 어느 주소로 받아도 같습니다. 대신 클러스터 내부 구간은 암호화되지 않으므로, 운영에서는 서비스 메시(mTLS)나 Keycloak 자체 TLS로 이 구간도 보호할지 정합니다.

# 07-kps-grafana-sso-values.yaml (kube-prometheus-stack의 grafana 하위 차트)
grafana:
envValueFrom:
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: # 비밀값은 values가 아니라 Secret에서
secretKeyRef: {name: grafana-oauth, key: client-secret}
grafana.ini:
auth:
disable_login_form: false # 관리자 폼 로그인은 비상용으로 남김
auth.generic_oauth:
enabled: true
name: Keycloak
allow_sign_up: true
client_id: grafana
scopes: openid email profile
use_pkce: true
auth_url: https://keycloak.localhost:8443/realms/lab/protocol/openid-connect/auth
token_url: http://keycloak-service.keycloak.svc:8080/realms/lab/protocol/openid-connect/token
api_url: http://keycloak-service.keycloak.svc:8080/realms/lab/protocol/openid-connect/userinfo
login_attribute_path: preferred_username
email_attribute_path: email
groups_attribute_path: groups
role_attribute_path: contains(groups[*], 'grafana-admins') && 'Admin' || contains(groups[*], 'grafana-editors') && 'Editor' || contains(groups[*], 'grafana-viewers') && 'Viewer' || ''
role_attribute_strict: true

role_attribute_path는 그룹 목록을 보고 Grafana 역할을 고르는 식입니다. 어느 그룹에도 없으면 빈 값이 나오는데, role_attribute_strict: true라서 이 경우 기본 역할(Viewer)을 주지 않고 로그인을 거부합니다. Keycloak에 계정만 있으면 누구나 Grafana를 볼 수 있게 되는 것을 막는 설정입니다. Grafana Keycloak 연동 문서

적용 후 Grafana 로그인 화면에는 관리자 폼 아래에 "Sign in with Keycloak" 버튼이 생깁니다. 버튼을 누르면 Keycloak의 lab realm 로그인 화면으로 이동하고, 비밀번호는 이 화면에서만 입력합니다.

브라우저 로그인 과정을 curl로 그대로 재현해 세 사용자를 확인했습니다. Grafana의 SSO 버튼 → Keycloak 로그인 화면 → 아이디·비밀번호 제출 → Grafana 콜백 순서입니다.

[alice] Grafana 콜백 → 302 https://grafana.localhost:8443/
[alice] /api/user/orgs → [{"orgId":1,"name":"Main Org.","role":"Admin"}]

[bob] Grafana 콜백 → 302 https://grafana.localhost:8443/
[bob] /api/user/orgs → [{"orgId":1,"name":"Main Org.","role":"Viewer"}]

[carol] Grafana 콜백 → 302 https://grafana.localhost:8443/login
[carol] loginError: "IdP did not return a role attribute, please contact your administrator"
# Grafana 로그
Failed to extract role ... [oauth.role_attribute_strict_violation]

alice로 로그인한 뒤 Grafana 프로필입니다. 이름, 이메일, 사용자 이름이 Keycloak에서 받아 온 값이라 "Synced via Generic OAuth"로 표시되고, Grafana에서 직접 고칠 수 없습니다. 사용자 정보는 Keycloak 한 곳에서 관리한다는 뜻입니다.

carol은 Keycloak 로그인 자체는 성공했지만 Grafana에서 거부되었습니다. 인증(누구인가)은 Keycloak이, 권한(무엇을 할 수 있나)은 그룹을 보고 Grafana가 정한다는 구분이 이 결과에 그대로 나타납니다.

09

모니터링: 무엇을 보고 있어야 하나

Keycloak 리소스의 serviceMonitor와 직접 만든 PodMonitor(PostgreSQL)에 release: kps 라벨을 붙여, 4편의 Prometheus가 네 대상을 모두 수집합니다.

up{namespace="keycloak"}
job=keycloak-service pod=keycloak-0 (9000) 1
job=keycloak-service pod=keycloak-1 (9000) 1
job=keycloak/keycloak-db pod=keycloak-db-1 (9187) 1
job=keycloak/keycloak-db pod=keycloak-db-2 (9187) 1

기본 지표에는 JVM, HTTP, 캐시 정보는 있지만 "로그인이 몇 번 성공·실패했나"는 없습니다. event-metrics-user-enabled를 켜면 사용자 이벤트가 keycloak_user_events_total로 나옵니다. 파드마다 따로 세므로 조회할 때는 합쳐서 봅니다. Keycloak 이벤트 지표 문서

keycloak_user_events_total{error="",event="login",realm="lab"} 3.0
keycloak_user_events_total{error="",event="code_to_token",realm="lab"} 2.0
keycloak_user_events_total{error="",event="user_info_request",realm="lab"} 2.0
운영에서 볼 만한 조회식
보고 싶은 것 PromQL
로그인 실패율(5분) sum(rate(keycloak_user_events_total{event="login_error"}[5m])) / sum(rate(keycloak_user_events_total{event=~"login|login_error"}[5m]))
실패 원인별 수 sum by (error) (rate(keycloak_user_events_total{event="login_error"}[5m]))
Keycloak 대상 수 count(up{job="keycloak-service"} == 1)
DB 복제 지연(초) max(cnpg_pg_replication_lag)
마지막 백업 시각 cnpg_collector_last_available_backup_timestamp

로그인 실패율이 갑자기 오르면 비밀번호 대입 공격이나 외부 연동(LDAP 등) 장애를, 실패 원인별 수에서 invalid_user_credentials가 대부분인지 다른 오류인지를 구분해 봅니다. 6편처럼 알림 규칙을 만들 때는 지표가 아예 없을 때를 잡는 규칙도 함께 둡니다.

10

장애 훈련 1: Keycloak 파드를 죽였을 때

alice가 로그인한 상태에서 keycloak-0을 강제 종료(--grace-period=0 --force)하고, 1초마다 접속하면서 기존 SSO 세션과 새 로그인을 시험했습니다.

15:38:02 keycloak-0 강제 종료
15:38:04 종료 직후 alice SSO 재사용: 비밀번호 없이 로그인 성공
15:38:04 종료 직후 bob 새 로그인: 성공
keycloak-1 로그: ISPN100001: Node keycloak-0 left the cluster
keycloak-0 복귀 25초
접속 응답: 200 × 33 (실패 0)
복귀 후 alice SSO 재사용: 성공

남은 한 개가 모든 요청을 받았고, alice의 SSO 세션도 살아 있었습니다. Keycloak 26은 사용자 세션을 메모리 캐시뿐 아니라 DB에도 저장하기 때문에(persistent user sessions), 파드가 죽어도 세션이 사라지지 않습니다. 예전 버전에서 "Keycloak을 재시작하면 모두 다시 로그인해야 한다"던 경험과 다른 부분입니다.

운영 포인트

한 개가 죽어도 버티려면 남은 한 개가 전체 부하를 감당할 수 있어야 합니다. 리소스는 "평소 부하 ÷ 파드 수"가 아니라 "평소 부하 ÷ (파드 수 − 1)"을 기준으로 잡고, PodDisruptionBudget으로 노드 점검 때 두 개가 동시에 내려가지 않게 합니다.

11

장애 훈련 2: DB 주 서버를 죽였을 때

이번에는 bob이 1초 간격으로 계속 새로 로그인하게 해 두고, PostgreSQL 주 서버(keycloak-db-1)를 강제 종료했습니다.

15:38:38 keycloak-db-1(주 서버) 강제 종료
15:38:40 ~ 15:38:47 로그인 실패 6회 (Keycloak 단계)
15:38:59 Cluster 상태의 주 서버 표시가 keycloak-db-2로 바뀜 (21초)
로그인 결과: 성공 54 / 실패 6
클러스터 정상 상태 복구: 91초 (옛 주 서버는 복제본으로 돌아옴)

NAME INSTANCES READY STATUS PRIMARY
keycloak-db 2 2 Cluster in healthy state keycloak-db-2

DB가 바뀌는 약 8초 동안은 로그인이 실패했습니다. 주 서버가 없는 동안에는 세션 저장 같은 쓰기를 할 수 없기 때문입니다. 그 뒤로는 Keycloak 설정을 전혀 바꾸지 않았는데도 로그인이 다시 성공했습니다. keycloak-db-rw 서비스가 새 주 서버를 가리키게 되었기 때문입니다. 장애 후 lab realm의 사용자 수(3명)도 그대로였습니다.

운영 포인트

DB 장애 전환 시간 동안의 로그인 실패는 피할 수 없으므로, 이 시간을 측정해 두고 서비스 목표(SLO)에 반영합니다. 이 실습은 강제 종료였고, 노드 자체가 응답하지 않는 장애에서는 장애 판단에 시간이 더 걸릴 수 있습니다. 비동기 복제라 이 몇 초 사이의 마지막 쓰기가 사라질 수 있다는 점도 함께 기록해 둡니다.

12

백업과 복구: 받아 둔 백업으로 실제로 살려 보기

이중화는 장비 장애를 막아 주지만, 실수로 지운 realm이나 잘못된 일괄 변경은 복제본에도 그대로 복제됩니다. 그래서 백업은 따로 필요합니다. 이 실습에서는 pg_dump로 논리 백업을 받고, 새 DB에 풀어서 내용이 같은지 확인했습니다.

# 복제본에서 덤프(주 서버 부하를 줄임)
kubectl exec -n keycloak keycloak-db-1 -c postgres -- pg_dump -Fc -d keycloak > keycloak-YYYYMMDD-HHMM.dump
# → 1초 이내, 264K, 테이블 데이터 101개

# 빈 Cluster(keycloak-db-restore)를 만들고 복원
kubectl exec -i -n keycloak keycloak-db-restore-1 -c postgres -- \
pg_restore -d keycloak --no-owner --role=keycloak < keycloak-YYYYMMDD-HHMM.dump
# → 1초

# 원본과 비교(realm별 사용자 수)
원본: lab|3 master|1
복원본: lab|3 master|1
lab 클라이언트 수: 7 / 7

백업 파일에는 사용자 비밀번호 해시와 클라이언트 비밀값이 들어 있습니다. 그래서 이 파일은 블로그 폴더가 아니라 권한을 600으로 둔 WSL 안 폴더에만 두었습니다. 운영에서도 백업 저장소의 접근 권한과 암호화를 DB와 같은 수준으로 관리합니다.

pg_dump는 받은 시점으로만 돌아갈 수 있습니다. 운영에서는 CloudNativePG의 Barman Cloud 플러그인으로 S3 같은 객체 저장소에 기본 백업과 WAL(변경 기록)을 계속 보내, 원하는 시각으로 되돌리는 특정 시점 복구(PITR)를 씁니다. 이 실습 클러스터는 아직 이 설정이 없어서 firstRecoverabilityPoint와 lastSuccessfulBackup이 비어 있습니다. 위 표의 마지막 백업 시각 지표로 백업이 멈추지 않았는지 감시하는 것까지가 백업 구성입니다. CloudNativePG Barman Cloud 플러그인

운영 포인트

백업은 복원해 본 적이 있어야 백업입니다. 이 실습처럼 별도 DB에 정기적으로 복원해서 핵심 데이터(realm, 사용자 수, 클라이언트 수)를 비교하는 절차를 일정으로 잡아 둡니다. 복원에 걸린 시간도 함께 기록해 두면, 장애 때 "언제 복구되느냐"는 질문에 답할 수 있습니다.

13

Keycloak이 죽었을 때 들어갈 문: 비상용 관리자 로그인

모든 도구의 로그인을 Keycloak에 모으면 Keycloak이 죽었을 때 아무도 못 들어가는 상황이 생깁니다. 그래서 Grafana의 관리자 폼 로그인은 끄지 않고 비상용(break-glass)으로 남겨 두었습니다. Keycloak을 0개로 줄여 확인했습니다.

Keycloak 파드 수: 0
SSO 버튼: 302 → https://keycloak.localhost:8443/.../auth → Keycloak 503
관리자 폼 로그인: 200
login: admin isGrafanaAdmin: True authLabels: []

SSO는 503으로 막혔지만 관리자 계정으로는 들어갈 수 있었습니다. 확인 뒤 다시 2개로 되돌렸습니다. 비상용 계정의 비밀번호는 금고(비밀 관리 도구)에 두고, 사용하면 기록을 남기고 바로 바꾸는 절차를 함께 정합니다.

14

운영 체크리스트

이번 실습에서 확인한 것과 운영으로 옮길 때 추가로 할 일을 정리합니다.

  • Keycloak은 운영 모드(start)로, 외부 DB와 고정 hostname으로 띄운다. hostname은 토큰 발급자가 되므로 처음에 정확히 정한다.
  • 운영자를 설치·업그레이드할 때는 버전별 CRD 목록을 확인한다(26.8은 4개).
  • Keycloak과 DB 모두 2개 이상으로 두고, 실제로 서로 다른 노드·영역에 있는지 확인한다. 운영자의 기본 분산 규칙은 권장 수준이다.
  • 운영자가 만드는 Job 같은 부속 파드가 서버의 배치 규칙을 물려받는지 확인한다.
  • 비밀값(관리자 비밀번호, DB 비밀번호, 클라이언트 비밀값)은 Secret으로만 다루고, realm 파일에는 자리표시자를 쓴다.
  • 부트스트랩 관리자(temp-admin)는 처음 들어갈 때만 쓰고, 영구 관리자 계정을 만든 뒤 지운다.
  • 관리 콘솔(/admin)은 외부에 그대로 열지 말고, 내부망이나 별도 주소로만 접근하게 하는 방법을 검토한다.
  • 클라이언트는 정확한 redirect URI, PKCE, direct grant 끔으로 설정하고, 그룹 없는 사용자는 거부(strict)한다.
  • 로그인 성공·실패 지표와 DB 복제 지연, 마지막 백업 시각을 수집하고 알림을 건다.
  • 장애 전환 시간(이 실습은 DB 약 8초)을 측정해 기록하고, 백업은 정기적으로 복원해 본다.
  • SSO가 죽었을 때 쓸 비상용 관리자 로그인을 도구마다 남겨 둔다.
  • 시작 로그의 "Updating the configuration and installing your custom providers"는 시작할 때마다 설정 빌드를 한다는 뜻이다. 운영에서는 미리 빌드한 이미지(kc.sh build)와 startOptimized: true로 시작 시간을 줄이는 것을 검토한다.
16

자주 묻는 질문

Keycloak을 1개만 띄우면 안 되나요?

실습이나 사용자가 적은 내부 도구라면 1개로도 동작합니다. 다만 그 1개가 재시작되는 동안 연결된 모든 도구에 새로 로그인할 수 없습니다. 이 실습에서 2개로 띄운 경우에는 파드 하나를 죽여도 접속 33번이 모두 성공했고, 설정 변경 중 롤링 교체에서도 74번이 모두 성공했습니다.

DB를 클라우드 관리형(RDS 등)으로 쓰면 CloudNativePG는 필요 없나요?

필요 없습니다. Keycloak 리소스의 `db.host`와 Secret만 관리형 DB로 바꾸면 됩니다. 이중화, 장애 전환, 백업을 클라우드가 맡는 대신 비용이 들고, 이 글의 장애 훈련과 백업 복원 확인은 관리형 DB에서도 똑같이 해 봐야 합니다.

KeycloakOIDCClient로 클라이언트를 만들면 realm 가져오기가 필요 없나요?

26.8에서 KeycloakOIDCClient는 v2alpha1(미리보기 단계) API입니다. 이 글에서는 realm 전체(그룹, 사용자, 클라이언트)를 한 번에 만들기 위해 KeycloakRealmImport를 썼습니다. 클라이언트를 리소스로 계속 관리하는 방식은 API가 안정화되는 것을 보고 도입을 검토하는 편이 안전합니다.

작성 방식과 다음 편

이 글의 명령, 오류 메시지, 측정값은 1편의 실습 PC에서 직접 실행한 것이며, 설정은 Keycloak, Keycloak Operator, CloudNativePG, Grafana 공식 문서를 기준으로 했습니다. 비밀번호와 비밀값은 출력하지 않았습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 OpenTelemetry로 트레이스를 모아, 지금까지 만든 메트릭(4편)과 로그(5편)에 이어 세 번째 관측 신호를 붙입니다.

반응형

cert-manager로 Gateway API HTTPS 붙이기: 사설 CA, 자동 갱신, 만료 알림까지 kind 실습

반응형
로컬 쿠버네티스 실습 6편
cert-manager + Gateway API
HTTPS를 자동으로 유지하기

사설 CA를 만들고, 인증서 자동 갱신을 지켜보고, 만료 알림까지 실제로 울려 본 기록

cert-manager사설 CAGateway APIPrometheusRule

cert-manager는 Certificate 리소스에 적은 대로 인증서를 발급해 Secret에 넣고, 만료 전에 알아서 다시 발급하는 컨트롤러입니다. kind 클러스터에 cert-manager v1.21.3을 설치하고 자체 서명 → 루트 CA → 서버 인증서로 이어지는 사설 CA를 만든 뒤, Gateway API의 HTTPS 리스너에 연결했습니다. HTTP로 들어온 요청은 HTTPS로 옮기고, 인증서를 2분마다 갱신하게 해서 Envoy가 재시작 없이 새 인증서를 쓰는 것까지 확인했습니다. 운영에서 실제로 문제가 된 것은 인증서보다 그 주변이었습니다. hostPort를 쓰는 Envoy가 교체되지 못하고 멈췄고, 만료 알림 규칙은 지표의 namespace 라벨이 예상과 달라 엉뚱한 위치를 가리켰습니다. 두 문제의 원인과 해결 방법도 함께 정리했습니다.

1~5편의 kind 클러스터(노드 3개, Envoy Gateway v1.10.0, kube-prometheus-stack 93.1.0)를 이어서 씁니다. cert-manager v1.21.3 기준이며, 인증서는 사설 CA로만 발급합니다. 공인 인증서(Let's Encrypt 같은 ACME 발급)는 공개 도메인과 외부 접속이 필요해 로컬에서는 다루지 않고, 차이만 설명합니다. 사설 CA를 Windows에 등록하는 단계는 관리자 권한이 필요 없는 현재 사용자 저장소 기준입니다.

01

HTTPS 인증서와 CA, 먼저 개념부터

HTTPS 인증서는 "이 서버가 정말 grafana.localhost가 맞다"는 증명서입니다. 증명서에는 도메인 이름(SAN), 유효 기간, 공개키가 들어 있고, 이것을 CA(인증 기관)가 자기 비밀키로 서명합니다. 브라우저는 서명한 CA를 믿을 때만 인증서를 받아들입니다. 그래서 인증서 문제는 대부분 "누가 서명했고, 그 서명자를 누가 믿느냐"의 문제입니다.

공인 CA와 사설 CA 비교
구분 공인 CA 사설 CA
예 Let's Encrypt, 상용 인증 기관 조직이나 팀이 직접 만든 루트 CA
누가 믿나 브라우저·OS에 처음부터 들어 있음 직접 등록한 PC·서버만
발급 조건 도메인 소유 증명(외부에서 접속 가능해야 함) 제한 없음(.localhost, 내부 도메인도 가능)
주로 쓰는 곳 외부 사용자에게 공개하는 서비스 사내 서비스, 서비스 간 통신, 로컬·개발 환경
운영 부담 발급자는 신경 쓸 일이 적음 CA 비밀키 보관, 신뢰 배포, 폐기 관리가 내 책임

로컬의 grafana.localhost는 외부에서 접속할 수 없어서 공인 CA로는 인증서를 받을 수 없습니다. 이 글에서는 사설 CA를 만들어 씁니다. 운영 환경으로 옮길 때도 구조는 같고, 발급자(Issuer)만 사설 CA에서 ACME로 바꾸면 됩니다.

용어 정리

루트 CA는 스스로 서명한 최상위 인증서입니다. 서버 인증서는 루트 CA(또는 중간 CA)가 서명한 인증서입니다. 이 서명 관계를 따라 올라가는 것을 인증서 체인이라고 합니다. 브라우저는 체인 끝의 루트 CA가 신뢰 저장소에 있으면 서버 인증서를 믿습니다.

02

cert-manager 구성 요소와 설치

cert-manager는 인증서를 쿠버네티스 리소스로 다룹니다. 사람이 Certificate에 "어떤 이름으로, 얼마 동안, 누구에게 받을지"를 적으면, cert-manager가 발급 요청을 만들고 결과를 Secret에 저장하고 만료 전에 다시 발급합니다.

cert-manager 주요 리소스
리소스 역할 이 실습에서
Issuer / ClusterIssuer 인증서를 서명해 주는 발급자. Issuer는 한 네임스페이스, ClusterIssuer는 클러스터 전체에서 사용 selfsigned-bootstrap, lab-ca(둘 다 ClusterIssuer)
Certificate 원하는 인증서의 내용(도메인, 기간, 키 종류, 발급자, 저장할 Secret) lab-root-ca, lab-localhost-tls
CertificateRequest 발급할 때마다 만들어지는 실제 요청. 갱신 이력 확인에 사용 갱신될 때마다 하나씩 생기고 마지막 것만 남음
Secret(kubernetes.io/tls) 발급 결과. tls.crt, tls.key, ca.crt Gateway가 이 Secret을 읽음

설치는 공식 문서대로 OCI 차트를 썼습니다. 4편의 Prometheus는 release: kps 라벨이 붙은 ServiceMonitor만 수집하므로, 차트에서 ServiceMonitor를 켜면서 그 라벨도 붙였습니다. 이 라벨을 빼면 설치는 되지만 만료 지표가 수집되지 않아, 뒤에서 만들 알림이 아무것도 감시하지 못합니다. cert-manager Helm 설치 문서

# 06-cert-manager-values.yaml
crds:
enabled: true
prometheus:
enabled: true
servicemonitor:
enabled: true
labels:
release: kps

helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.21.3 -n cert-manager --create-namespace \
-f 06-cert-manager-values.yaml --wait

설치에는 24초가 걸렸고, 파드 3개(cert-manager, cainjector, webhook)와 CRD 6종이 생겼습니다. CRD 중 orders와 challenges는 ACME(공인 인증서) 발급에 쓰이는 것이라 이 실습에서는 쓰지 않습니다.

Headlamp에서 본 cert-manager 네임스페이스입니다. 세 파드가 모두 한 노드(lab-worker2)에 있고 컨트롤러도 1개라서, 이 노드가 멈추면 cert-manager 전체가 멈춥니다. 이미 발급된 인증서는 Secret에 남아 있어 HTTPS는 계속 동작하지만, 그동안 갱신과 새 발급은 일어나지 않습니다. 뒤에서 만드는 CertManagerAbsent 알림이 이 상황을 잡는 장치입니다.

운영 포인트

crds.enabled: true로 설치하면 CRD도 Helm이 관리합니다. 차트 기본값 crds.keep: true 덕분에 helm uninstall을 해도 CRD는 남습니다. CRD가 지워지면 모든 Certificate와 Issuer가 함께 지워지므로, 운영에서는 이 값을 끄지 않습니다. 또 차트 기본 컨트롤러는 1개라서, 공식 values 설명대로 운영에서는 2~3개로 늘리는 것을 검토합니다.

03

사설 CA 체인 만들기: 자체 서명 → 루트 CA → 서버 인증서

루트 CA를 직접 openssl로 만들어 Secret에 넣을 수도 있지만, cert-manager 안에서 만들면 CA 인증서도 다른 인증서처럼 기간과 갱신을 관리할 수 있습니다. 순서는 네 단계입니다.

  1. selfsigned-bootstrap: 자기 자신을 서명하는 발급자. 루트 CA를 한 번 만드는 데만 씁니다.
  2. lab-root-ca: 10년짜리 루트 CA 인증서(isCA: true). 결과 Secret에 CA 비밀키가 들어갑니다.
  3. lab-ca: 위 Secret의 CA 비밀키로 서명하는 발급자.
  4. lab-localhost-tls: grafana.localhost, keycloak.localhost용 서버 인증서. Gateway가 쓰는 인증서입니다.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-bootstrap
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: lab-root-ca
namespace: cert-manager # ClusterIssuer(CA)는 이 네임스페이스의 Secret을 읽는다
spec:
isCA: true
commonName: Lab Local Root CA
secretName: lab-root-ca
duration: 87600h # 10년
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-bootstrap
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: lab-ca
spec:
ca:
secretName: lab-root-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: lab-localhost-tls
namespace: default # Gateway와 같은 네임스페이스
spec:
secretName: lab-localhost-tls
dnsNames:
- grafana.localhost
- keycloak.localhost
duration: 2160h # 90일
renewBefore: 720h # 만료 30일 전에 갱신
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: lab-ca
kind: ClusterIssuer

ClusterIssuer가 CA 비밀키 Secret을 읽는 네임스페이스는 기본적으로 cert-manager가 설치된 네임스페이스입니다. 그래서 루트 CA Certificate는 cert-manager 네임스페이스에 만들었습니다. cert-manager CA 발급자 문서

적용하고 1초 만에 두 인증서가 Ready가 되었습니다. openssl로 확인한 결과는 아래와 같습니다.

issuer=CN = Lab Local Root CA
subject=
X509v3 Subject Alternative Name: critical
DNS:grafana.localhost, DNS:keycloak.localhost
verify: OK

서버 인증서의 subject가 비어 있는 것은 오류가 아닙니다. Certificate에 commonName을 적지 않고 dnsNames만 적었기 때문입니다. 요즘 브라우저는 CN이 아니라 SAN(Subject Alternative Name)만 보고 도메인을 확인하므로, 서버 인증서는 SAN만 두는 편이 깔끔합니다. 그래서 뒤에 나오는 브라우저 인증서 창에서도 발급 대상의 일반 이름(CN)이 "인증서에 속하지 않음"으로 표시됩니다.

운영 포인트

lab-root-ca Secret의 tls.key가 이 사설 CA의 도장입니다. 이 키가 새면 누구든 이 CA 이름으로 아무 도메인의 인증서를 만들 수 있고, 이 CA를 믿는 PC는 그 인증서도 믿습니다. 운영에서는 이 Secret을 읽을 수 있는 권한을 최소로 두고, 루트 CA는 클러스터 밖(HSM, Vault, AWS Private CA 등)에 두고 중간 CA만 클러스터에 두는 구성을 검토합니다.

04

Gateway에 HTTPS 리스너 붙이고 HTTP는 HTTPS로 보내기

3편의 Gateway에는 HTTP(80) 리스너만 있었습니다. 여기에 HTTPS(443) 리스너를 추가하고, cert-manager가 만든 Secret을 연결합니다. mode: Terminate는 Gateway(Envoy)에서 TLS를 풀고 안쪽 서비스에는 HTTP로 전달한다는 뜻입니다. Gateway API TLS 가이드

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: lab-gateway
namespace: default
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: lab-localhost-tls
allowedRoutes:
namespaces:
from: All

Grafana 라우트는 sectionName: https로 HTTPS 리스너에만 붙이고, HTTP 리스너에는 HTTPS로 옮기는 라우트를 따로 둡니다. 리다이렉트에 port: 8443을 꼭 적어야 합니다. 클러스터 안의 리스너는 443이지만 Windows에서 보이는 포트는 1편에서 연결한 8443이기 때문입니다. 포트를 빼면 브라우저가 https://grafana.localhost(443)로 가서 접속에 실패합니다.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: https-redirect
namespace: default
spec:
parentRefs:
- name: lab-gateway
sectionName: http
hostnames:
- grafana.localhost
- keycloak.localhost
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
port: 8443
statusCode: 301

kind 노드의 443은 Envoy 컨테이너 안에서 10443으로 바뀝니다. Envoy Gateway는 1024 미만 포트를 쓰지 않으려고 리스너 포트에 10000을 더해 컨테이너 포트로 씁니다. 그래서 3편에서 80 → 10080을 연결했던 EnvoyProxy 패치에 10443 → hostPort 443을 추가했습니다.

$ curl -s -o /dev/null -w "%{http_code} → %{redirect_url}\n" http://grafana.localhost:8080/login
301 → https://grafana.localhost:8443/login

$ curl -s -o /dev/null -w "%{http_code}\n" --cacert ca.crt https://grafana.localhost:8443/login
200

$ curl https://grafana.localhost:8443/login
curl: (60) SSL certificate problem: unable to get local issuer certificate

사설 CA를 지정하면 200, 지정하지 않으면 "발급자를 모른다"는 오류가 납니다. 인증서는 맞게 붙었고, 남은 것은 PC가 이 CA를 믿게 하는 일입니다.

마지막으로 Grafana의 root_url을 https://grafana.localhost:8443으로 바꿨습니다. Grafana가 만드는 리다이렉트와 링크가 이 값을 기준으로 하고, 다음 편의 SSO 로그인에서 돌아올 주소(콜백)도 이 값으로 만들어지기 때문입니다.

# 06-kps-grafana-values.yaml
grafana:
grafana.ini:
server:
root_url: https://grafana.localhost:8443

helm upgrade kps prometheus-community/kube-prometheus-stack --version 93.1.0 \
-n monitoring --reuse-values -f 06-kps-grafana-values.yaml
05

운영 함정 1: hostPort를 쓰는 Envoy가 교체되지 않는다

EnvoyProxy에 포트를 추가하자 Envoy Deployment가 새 파드를 만들었는데, 새 파드가 계속 Pending에 머물렀습니다.

0/3 nodes are available: 1 node(s) didn't have free ports for the requested pod ports,
2 node(s) didn't match Pod's node affinity/selector.

원인은 Deployment의 기본 교체 방식입니다. 기본값 RollingUpdate는 새 파드를 먼저 띄우고(maxSurge 25%, 1개 파드면 1개 추가) 준비되면 옛 파드를 내립니다. 그런데 Envoy는 hostPort 80·443을 쓰고 ingress-ready 노드 하나에만 뜨도록 되어 있어서, 옛 파드가 포트를 쥐고 있는 동안 새 파드는 들어갈 노드가 없습니다. 옛 파드는 그대로 동작하니 접속은 유지되지만, 설정 변경은 반영되지 않은 채로 멈춥니다. Deployment 교체 전략 문서

먼저 strategy.type: Recreate(옛 파드를 모두 내리고 새로 띄움)로 바꿔 봤지만, Envoy Gateway 로그에 아래 오류가 나고 반영되지 않았습니다.

Deployment.apps "envoy-default-lab-gateway-..." is invalid:
spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy type is 'Recreate'

이미 만들어진 Deployment에는 기본 rollingUpdate 값이 들어 있는데, type만 Recreate로 바꾸면서 그 값이 남아 충돌한 것입니다. 그래서 type은 RollingUpdate로 두고, "새 파드를 추가로 띄우지 않고(maxSurge 0), 한 개까지 내려도 된다(maxUnavailable 1)"로 설정했습니다. 결과적으로 옛 파드를 먼저 내리고 새 파드를 띄우는 Recreate와 같은 순서가 됩니다.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: kind-hostport
namespace: envoy-gateway-system
spec:
provider:
type: Kubernetes
kubernetes:
envoyDeployment:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
# nodeSelector, toleration, 10080/10443 hostPort 패치는 3편과 같다

교체는 72초 걸렸고, 그동안 Grafana 접속이 끊겼습니다. 포트를 노드에 직접 묶는 구성에서는 무중단 교체가 불가능하다는 뜻입니다.

운영에서는

hostPort는 kind처럼 LoadBalancer가 없는 로컬 환경을 위한 우회입니다. 운영 클러스터에서는 Envoy 서비스를 LoadBalancer(클라우드 로드밸런서)로 두고 replicas를 2개 이상으로 둡니다. 그러면 기본 RollingUpdate로 무중단 교체가 됩니다. hostPort나 hostNetwork를 꼭 써야 한다면, 교체 중 끊김을 감안해 점검 시간에 변경하고 노드 여러 대에 나눠 띄우는 구성을 검토합니다.

06

자동 갱신을 직접 보기: 2분마다 갱신시켜 보기

인증서 자동 갱신은 운영에서 가장 중요한 기능이지만, 90일짜리 인증서로는 실습 중에 볼 수 없습니다. 그래서 잠깐 duration: 1h, renewBefore: 58m으로 바꿔 발급 2분 뒤마다 갱신되게 했습니다.

$ kubectl get events -n default --field-selector involvedObject.name=lab-localhost-tls
Renewing certificate as renewal was scheduled at ...
Stored new private key in temporary Secret resource "lab-localhost-tls-xxxxx"
Created new CertificateRequest resource "lab-localhost-tls-5"
The certificate has been successfully issued

약 10분 동안 5번 갱신되었고, 확인한 동작은 아래와 같습니다.

  • 갱신할 때마다 비밀키도 새로 만듭니다. 이벤트의 "Stored new private key"가 그 단계입니다.
  • CertificateRequest는 갱신마다 하나씩 생기지만 마지막 것만 남습니다. 오래된 요청이 쌓이지 않습니다.
  • Envoy는 재시작 없이 새 인증서를 내보냅니다. 갱신 전후로 openssl 접속에서 만료 시각이 05:23:14에서 05:31:14로 바뀌었고, Envoy 파드의 재시작 횟수는 그대로였습니다.
  • Envoy Gateway가 Secret 변경을 감지해 Envoy 설정을 바꿔 주기 때문입니다. 인증서를 파일로 읽는 앱이라면 다시 읽기까지 시간이 걸리거나 재시작이 필요할 수 있습니다.

확인한 뒤 duration: 2160h(90일), renewBefore: 720h(30일 전)로 되돌렸습니다. renewBefore를 30일로 잡는 이유는 갱신이 실패하더라도 고칠 시간을 30일 남겨 두기 위해서입니다. 되돌리자 기존 인증서의 남은 기간(1시간)이 renewBefore보다 짧아 즉시 90일짜리로 다시 발급되었습니다. cert-manager Certificate 문서

cert-manager에는 따로 웹 화면이 없습니다. 인증서 현황은 kubectl로 보는데, 기본 출력에는 만료일이 없어서 열을 직접 지정하면 한눈에 보기 좋습니다.

$ kubectl get certificate -A -o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,READY:.status.conditions[?(@.type=="Ready")].status,ISSUER:.spec.issuerRef.name,NOT_AFTER:.status.notAfter,RENEWAL:.status.renewalTime,REV:.status.revision'
NAMESPACE NAME READY ISSUER NOT_AFTER RENEWAL REV
cert-manager lab-root-ca True selfsigned-bootstrap 2036-11-05T04:23:14Z 2033-07-07T12:23:14Z 1
default lab-localhost-tls True lab-ca 2027-02-06T05:47:29Z 2027-01-07T05:47:29Z 44

서버 인증서는 2027-02-06에 만료되고, 30일 전인 2027-01-07에 갱신이 예정되어 있습니다. REV 44는 2분 갱신 실험 동안 44번 발급되었다는 뜻입니다. Headlamp에서는 사이드바의 사용자 정의 리소스(Custom Resources) → certificates.cert-manager.io에서 같은 목록과 이벤트를 볼 수 있습니다.

운영 포인트: 루트 CA도 갱신된다

표에서 루트 CA(lab-root-ca)의 갱신 예정일이 2033-07-07로 잡혀 있습니다. renewBefore를 따로 정하지 않아 수명의 2/3 시점에 cert-manager가 루트 CA도 자동으로 다시 발급하기 때문입니다. 이 실습은 갱신할 때마다 새 비밀키를 만드는 기본 동작이라, 그날 새 루트 CA가 생기면 PC에 등록해 둔 옛 루트 CA로는 새로 발급된 서버 인증서를 믿지 못합니다. 운영에서는 루트 CA 교체를 자동 갱신에 맡기지 않고, 새 루트를 먼저 배포한 뒤 바꾸는 일정을 따로 관리합니다.

07

Windows가 사설 CA를 믿게 하기

브라우저에서 경고 없이 열려면 Windows가 루트 CA를 믿어야 합니다. 루트 CA의 공개 인증서(비밀키 아님)만 Secret에서 꺼내 Windows에 등록합니다.

# WSL: 루트 CA 공개 인증서만 꺼내기
kubectl get secret -n cert-manager lab-root-ca \
-o go-template='{{index .data "ca.crt" | base64decode}}' > lab-root-ca.crt
openssl x509 -in lab-root-ca.crt -noout -subject -enddate -fingerprint -sha1

# Windows PowerShell(일반 권한): 현재 사용자 신뢰 저장소에 등록
Import-Certificate -FilePath .\lab-root-ca.crt -CertStoreLocation Cert:\CurrentUser\Root

등록 명령을 실행하면 Windows가 보안 경고 창을 띄우고 지문(SHA1 Thumbprint)을 보여 줍니다. openssl로 확인한 지문과 같은지 보고 승인합니다. 이 확인이 중간에 파일이 바뀌지 않았는지 검증하는 단계입니다. 등록 후 브라우저를 완전히 다시 켜면 https://grafana.localhost:8443이 경고 없이 열립니다.

브라우저의 인증서 창에서 발급 기관이 Lab Local Root CA이고, 유효 기간이 90일로 되돌린 인증서의 기간(2026-11-08 ~ 2027-02-06)과 같은 것을 확인할 수 있습니다. 발급 대상의 일반 이름(CN)이 비어 있는 것은 앞에서 설명한 대로 SAN만 둔 인증서이기 때문입니다.

한 가지 더 확인한 것이 있습니다. Windows 기본 curl(schannel)로 접속하면 인증서를 믿는데도 실패합니다.

> curl.exe https://grafana.localhost:8443/login
schannel: next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012)

> curl.exe --ssl-no-revoke -s -o NUL -w "%{http_code}" https://grafana.localhost:8443/login
200

이 사설 CA는 폐기 목록(CRL)이나 OCSP 주소를 제공하지 않아서, schannel은 "폐기 여부를 확인할 수 없다"며 접속을 막습니다. 브라우저는 확인할 수 없으면 넘어가므로 문제가 없습니다. 사내 사설 CA를 운영할 때 일부 도구만 접속에 실패한다면 이 폐기 확인을 의심해 볼 만합니다.

주의

루트 CA를 신뢰 저장소에 넣는 것은 그 CA가 서명한 모든 인증서를 믿겠다는 뜻입니다. 실습이 끝나면 "사용자 인증서 관리"(certmgr.msc) → 신뢰할 수 있는 루트 인증 기관에서 Lab Local Root CA를 지웁니다. Firefox는 Windows 저장소가 아닌 자체 저장소를 쓰므로 따로 설정해야 합니다.

08

만료 알림 만들기: 자동 갱신을 믿되 감시한다

자동 갱신이 있어도 알림은 필요합니다. 발급자 설정이 바뀌거나, CA Secret이 지워지거나, ACME라면 도메인 확인이 실패해서 갱신이 조용히 멈출 수 있습니다. 갱신이 멈추면 인증서는 정해진 날에 그대로 만료되고, 그날 서비스가 한꺼번에 끊깁니다. cert-manager는 인증서마다 만료 시각, 갱신 예정 시각, Ready 상태를 Prometheus 지표로 내보냅니다. cert-manager Prometheus 지표 문서

알림에 쓴 cert-manager 지표
지표 뜻
certmanager_certificate_expiration_timestamp_seconds 인증서 만료 시각(유닉스 시간)
certmanager_certificate_renewal_timestamp_seconds cert-manager가 갱신하려는 시각
certmanager_certificate_ready_status Certificate의 Ready 상태(condition 라벨별 1/0)

규칙은 네 개를 두었습니다. 만료가 가까워지는 것만 보지 않고, 갱신 예정 시각이 지났는데 갱신되지 않은 상태와 지표 자체가 사라진 상태도 잡습니다. 특히 마지막 규칙이 중요합니다. 지표가 아예 안 들어오면 앞의 세 규칙은 계산할 값이 없어 조용히 아무 알림도 내지 않습니다.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cert-manager-alerts
namespace: cert-manager
labels:
release: kps # 4편의 Prometheus가 읽는 라벨
spec:
groups:
- name: cert-manager
rules:
- alert: CertificateExpiringSoon
expr: (certmanager_certificate_expiration_timestamp_seconds - time()) < 14 * 24 * 3600
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.exported_namespace }}/{{ $labels.name }} 인증서가 14일 안에 만료됩니다"
- alert: CertificateRenewalOverdue
expr: time() > certmanager_certificate_renewal_timestamp_seconds + 3600
for: 10m
labels:
severity: warning
- alert: CertificateNotReady
expr: certmanager_certificate_ready_status{condition="True"} != 1
for: 10m
labels:
severity: critical
- alert: CertManagerAbsent
expr: absent(up{job="cert-manager"} == 1)
for: 10m
labels:
severity: critical

14일 기준은 renewBefore(30일)보다 짧게 잡았습니다. 정상이라면 30일 전에 갱신되므로 14일 경고는 "갱신이 2주 넘게 실패하고 있다"는 뜻이 됩니다. 갱신 직전에 매번 울리는 알림은 결국 무시되기 때문에, 사람이 손을 대야 하는 상황에서만 울리게 합니다.

운영 함정 2

알림 문구에 처음에는 $labels.namespace를 썼는데, 실제 지표를 보니 default에 있는 인증서가 namespace="cert-manager"로 나왔습니다. Prometheus가 지표를 수집할 때 수집 대상(cert-manager 파드)의 네임스페이스로 namespace 라벨을 덮어쓰고, 원래 값은 exported_namespace로 옮기기 때문입니다. 이걸 모르면 알림이 엉뚱한 네임스페이스를 가리켜 대응이 늦어집니다. 인증서 위치는 exported_namespace로 써야 합니다.

certmanager_certificate_ready_status{condition="True"}
{name="lab-localhost-tls", namespace="cert-manager", exported_namespace="default", issuer_name="lab-ca"} 1
{name="lab-root-ca", namespace="cert-manager", exported_namespace="cert-manager", issuer_name="selfsigned-bootstrap"} 1

수정 전에는 알림 요약이 cert-manager/lab-localhost-tls로 나왔고, exported_namespace로 바꾼 뒤에는 default/lab-localhost-tls로 실제 위치를 가리켰습니다.

규칙이 정말 울리는지도 확인했습니다. 이때 인증서는 아직 갱신 관찰용 1시간짜리였으니 "14일 안에 만료" 조건에 해당합니다. 규칙을 넣자 바로 pending이 되었고, for: 10m이 지난 뒤 firing으로 바뀌어 Alertmanager까지 전달되었습니다. 그다음 인증서를 90일로 되돌리자 새 인증서가 발급되었고, 1분 안에 알림이 해제되었습니다.

05:36:53 CertificateExpiringSoon pending (조건 충족, 10분 지켜봄)
05:47:08 CertificateExpiringSoon firing

# Alertmanager
CertificateExpiringSoon warning active | default/lab-localhost-tls 인증서가 14일 안에 만료됩니다 | 남은 시간 58m 20s

# 90일로 되돌린 뒤
notBefore=2026-11-08T05:47:29Z notAfter=2027-02-06T05:47:29Z renewalTime=2027-01-07T05:47:29Z
05:48:29 CertificateExpiringSoon inactive

renewalTime이 만료 30일 전(2027-01-07)으로 잡힌 것도 확인할 수 있습니다. 알림은 이렇게 한 번 일부러 울려 봐야 라벨, 문구, 전달 경로가 맞는지 알 수 있습니다. 규칙을 만들고 한 번도 울려 보지 않으면, 정작 필요할 때 엉뚱한 내용으로 오거나 오지 않는 것을 그때 알게 됩니다.

09

운영 체크리스트

이 실습에서 확인한 내용을 운영 기준으로 정리하면 아래와 같습니다.

  • 인증서는 직접 만들지 말고 Certificate 리소스로 선언하고, 기간(duration)과 갱신 시점(renewBefore)을 명시한다.
  • renewBefore는 갱신 실패를 고칠 시간이 충분하도록 잡는다(90일 인증서라면 30일 전).
  • 사설 CA 비밀키 Secret은 읽기 권한을 최소로 두고, 가능하면 루트 CA는 클러스터 밖에 둔다.
  • Gateway와 인증서 Secret은 같은 네임스페이스에 두거나, 다른 네임스페이스라면 ReferenceGrant로 명시적으로 허용한다.
  • 만료 임박, 갱신 지연, Ready 아님, 지표 없음의 네 가지를 알림으로 감시하고, 인증서 위치는 exported_namespace 라벨로 표시한다.
  • hostPort·hostNetwork로 띄운 게이트웨이는 무중단 교체가 안 되므로 교체 전략(maxSurge 0)과 점검 시간을 정해 둔다. 운영에서는 LoadBalancer와 2개 이상 replicas를 쓴다.
  • 인증서를 파일로 읽는 앱은 갱신 후 다시 읽는지 확인한다. Envoy Gateway처럼 Secret 변경을 감지하는 구성인지가 중요하다.
  • 사설 CA를 PC에 등록할 때는 지문을 확인하고, 쓰지 않으면 지운다.
11

자주 묻는 질문

Let's Encrypt로 바꾸려면 무엇을 바꾸면 되나요?

Certificate와 Gateway 설정은 그대로 두고, 발급자를 ACME ClusterIssuer로 바꾸고 Certificate의 `issuerRef`만 그 이름으로 바꾸면 됩니다. 대신 인증서에 넣을 도메인이 공개 DNS에 등록되어 있고, HTTP-01 방식이라면 외부에서 그 도메인의 80 포트로 접속할 수 있어야 합니다. `.localhost`처럼 외부에서 확인할 수 없는 이름에는 공인 인증서를 받을 수 없습니다.

Ingress를 쓸 때와 무엇이 다른가요?

Ingress는 주석(annotation)을 달면 cert-manager가 Certificate를 자동으로 만들어 주는 방식을 많이 씁니다. Gateway API도 cert-manager의 Gateway 지원을 켜면 Gateway 주석으로 같은 일을 할 수 있습니다. 이 글은 동작을 잘 보이게 하려고 Certificate를 직접 만들고 Gateway의 `certificateRefs`로 연결했습니다.

인증서가 갱신되면 서비스가 끊기나요?

이 실습의 Envoy Gateway는 Secret 변경을 감지해 재시작 없이 새 인증서를 썼고, 2분 간격으로 5번 갱신되는 동안 파드 재시작은 없었습니다. 끊김 여부는 인증서를 쓰는 쪽이 Secret이나 파일 변경을 바로 반영하는지에 달려 있습니다.

작성 방식과 다음 편

이 글의 명령, 오류 메시지, 결과는 1편의 실습 PC에서 직접 실행한 것이며, 설정은 cert-manager·Gateway API·Envoy Gateway 공식 문서를 기준으로 했습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 이 HTTPS 위에 Keycloak을 운영 모드(Keycloak Operator + PostgreSQL)로 설치하고, Grafana 로그인을 SSO로 바꿉니다. 이번에 인증서에 미리 넣어 둔 keycloak.localhost가 그때 쓰입니다.

반응형

Fluent Bit과 Loki로 쿠버네티스 로그 모으기: kind 실습과 Grafana 조회, AWS 확장 예시

반응형
로컬 쿠버네티스 실습 5편
Fluent Bit → Loki → Grafana
파드 로그를 한곳에서 보기

수집기와 저장소를 나눠 설치하고, 실제로 겪은 오류 4개를 고친 과정

Fluent BitLokiLogQLGrafana

Fluent Bit과 Loki는 경쟁 제품이 아니라 역할이 다릅니다. Fluent Bit은 노드마다 떠서 컨테이너 로그를 읽고 쿠버네티스 정보를 붙여 보내는 수집기이고, Loki는 그 로그를 받아 저장하고 LogQL로 찾게 해 주는 저장소입니다. kind 클러스터에 Loki를 단일 구성으로, Fluent Bit을 DaemonSet으로 설치해 연결했고, web 파드에 보낸 요청 5개가 Grafana에서 5줄 모두 검색되는 것까지 확인했습니다. 설치 중에는 차트 스키마 오류, 약 11GiB를 요청하는 기본 캐시, inotify 한도 부족 같은 문제를 차례로 만났고 그 해결 방법도 함께 정리했습니다.

1~4편의 kind 클러스터(노드 3개, Gateway API, kube-prometheus-stack의 Grafana)를 이어서 씁니다. Loki 차트 grafana-community/loki 21.0.0(Loki 3.7.9), Fluent Bit 차트 fluent/fluent-bit 0.58.3(Fluent Bit 5.1.3) 기준입니다. Loki 확장 구성, 보관 기간 설정, 알림은 다루지 않고, AWS로 보내는 부분은 구성 예시로만 소개합니다.

01

Fluent Bit과 Loki는 무엇이 다른가

로그 시스템은 보통 두 부분으로 나뉩니다. 로그를 모아서 보내는 수집기와, 받아서 저장하고 검색하게 해 주는 저장소입니다. Fluent Bit은 앞쪽, Loki는 뒤쪽이라 함께 쓰는 경우가 많습니다.

Fluent Bit과 Loki의 역할 비교
구분 Fluent Bit Loki
역할 로그 수집·가공·전달(에이전트) 로그 저장·검색(백엔드)
설치 위치 노드마다 하나씩(DaemonSet) 클러스터에 한 곳(필요하면 여러 개로 확장)
주로 하는 일 컨테이너 로그 파일을 읽고 파드 정보를 붙여 목적지로 보냄 받은 로그를 저장하고 LogQL로 검색
연결 대상 Loki, Elasticsearch·OpenSearch, CloudWatch Logs, S3 등 Fluent Bit, Grafana Alloy 같은 수집기에서 받음
화면 없음 Grafana에서 조회

Loki는 로그 본문 전체가 아니라 namespace, pod 같은 라벨에만 색인을 만듭니다. 그래서 저장 부담이 적고, Prometheus와 같은 라벨 방식이라 Grafana에서 메트릭과 로그를 같은 라벨로 오가기 쉽습니다. 대신 본문 검색은 라벨로 범위를 먼저 좁혀야 빠릅니다. Fluent Bit이 왜 필요한지와 필터 구조는 아래 관련 글에서 더 자세히 다룹니다.

02

Loki 설치: 차트 위치부터 바뀌었다

Loki 공식 설치 문서는 이제 grafana-community 저장소의 차트를 안내합니다. 실습 시점에 grafana-community/loki는 21.0.0(Loki 3.7.9)이었고, 예전 위치인 grafana/loki는 7.5.0(Loki 3.6.16)에 설명이 Grafana Enterprise Logs용으로 바뀌어 있었습니다. 예전 글을 따라 할 때는 저장소 주소부터 확인합니다. Loki 단일 구성 설치 문서

차트 기본값은 인증이 켜져 있고(auth_enabled: true) 저장소가 S3입니다. 실습용으로는 인증을 끄고 노드 디스크(filesystem)에 저장하도록 바꿨습니다. 다른 배포 모드의 복제 수는 공식 예시처럼 모두 0으로 둡니다(아래는 그중 일부).

deploymentMode: Monolithic
loki:
auth_enabled: false
commonConfig:
replication_factor: 1
storage:
type: filesystem
schemaConfig:
configs:
- from: "2024-04-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: loki_index_
period: 24h
singleBinary:
replicas: 1
persistence:
size: 5Gi
read:
replicas: 0
write:
replicas: 0
backend:
replicas: 0

처음에는 공식 문서 예시에 있던 minio: enabled: false도 넣었는데 설치가 바로 거부됐습니다. 차트 21.0.0의 설정 검사(스키마)에 minio 항목이 없기 때문입니다. 문서와 차트 버전이 어긋날 수 있으니, 오류 메시지에 나온 키를 지우면 됩니다.

Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
loki:
- at '': additional properties 'minio' not allowed
helm repo add grafana-community https://grafana-community.github.io/helm-charts
helm install loki grafana-community/loki --version 21.0.0 -n loki --create-namespace -f loki-values.yaml --wait
03

기본 캐시가 메모리 11GiB를 요청한다

설치는 61초 만에 끝났지만 파드 목록을 보면 Loki 본체 외에 게이트웨이, loki-canary(노드마다), 캐시 두 개가 함께 떠 있었습니다. 캐시의 메모리 요청량을 확인하니 로컬 실습에는 너무 컸습니다.

기본으로 켜지는 캐시의 메모리 요청
파드 메모리 요청 근거
loki-chunks-cache-0 9830Mi chunksCache.allocatedMemory 기본값 8192(MB)의 1.2배
loki-results-cache-0 1229Mi resultsCache.allocatedMemory 기본값 1024(MB)의 1.2배

합치면 약 11GiB입니다. 실습 PC는 WSL에 메모리가 23GiB 잡혀 있어서 떴지만, 메모리가 적은 PC라면 이 파드 때문에 다른 파드가 자리를 못 잡을 수 있습니다. 로컬에서는 캐시와 canary를 끕니다.

chunksCache:
enabled: false
resultsCache:
enabled: false
lokiCanary:
enabled: false
test:
enabled: false

test.enabled: false도 필요합니다. canary만 끄고 업그레이드하면 Helm test requires the Loki Canary to be enabled라는 오류로 거부되는데, 차트의 helm 테스트가 canary를 쓰기 때문입니다. 네 값을 모두 넣고 helm upgrade하자 파드가 loki-0과 게이트웨이 두 개로 줄었습니다.

04

Fluent Bit 설치: 출력만 Loki로 바꾸기

Fluent Bit 차트의 기본 설정은 컨테이너 로그를 읽는 tail 입력, kubelet 로그를 읽는 systemd 입력, 쿠버네티스 정보를 붙이는 kubernetes 필터, 그리고 Elasticsearch(elasticsearch-master)로 보내는 출력으로 되어 있습니다. 출력을 Loki로 바꾸고, kind 노드에는 필요 없는 systemd 입력을 뺐습니다. Fluent Bit Loki 출력

tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
config:
outputs: |
[OUTPUT]
Name loki
Match kube.*
Host loki.loki.svc.cluster.local
Port 3100
Labels job=fluent-bit, namespace=$kubernetes['namespace_name'], pod=$kubernetes['pod_name'], container=$kubernetes['container_name']
Remove_Keys kubernetes
Line_Format json

Labels의 $kubernetes['…']는 kubernetes 필터가 붙인 값을 꺼내 Loki 라벨로 쓰는 표기입니다. 라벨은 검색 범위를 좁히는 기준이므로 namespace, pod, container처럼 값의 종류가 많지 않은 것만 씁니다. Remove_Keys kubernetes는 라벨로 쓴 정보를 로그 본문에서 빼서 저장 공간을 줄입니다. tolerations가 없으면 Fluent Bit이 제어 플레인 노드에 뜨지 않아, 3편의 Envoy 프록시처럼 그 노드에 있는 파드의 로그를 놓칩니다.

helm repo add fluent https://fluent.github.io/helm-charts
helm install fluent-bit fluent/fluent-bit --version 0.58.3 -n logging --create-namespace -f fluent-bit-values.yaml
05

CrashLoopBackOff: Too many open files 해결

설치 직후 Fluent Bit 파드 3개가 모두 CrashLoopBackOff가 됐고 재시작이 5번까지 쌓였습니다. 로그에는 tail 입력이 시작하자마자 실패한 기록이 있었습니다.

[error] [/src/fluent-bit/plugins/in_tail/tail_fs_inotify.c:425 errno=24] Too many open files
[error] failed initialize input tail.0
[error] [engine] input initialization failed

파일 개수 문제가 아니라 inotify 한도 문제입니다. Fluent Bit은 로그 파일 변화를 inotify로 감시하는데, kind 노드 3개와 그 안의 파드가 모두 WSL 커널 하나를 같이 써서 fs.inotify.max_user_instances 기본값 128이 모자랐습니다. kind 문서의 알려진 문제에 나오는 권장값으로 WSL에서 올리고, 재부팅 후에도 유지되게 파일로 남겼습니다. kind 알려진 문제

sysctl -w fs.inotify.max_user_instances=512 fs.inotify.max_user_watches=524288
printf 'fs.inotify.max_user_instances = 512\nfs.inotify.max_user_watches = 524288\n' > /etc/sysctl.d/99-kind-inotify.conf
kubectl -n logging rollout restart ds/fluent-bit

kind 노드는 커널을 공유하므로 노드 컨테이너 안에서도 바로 512로 보였고, 다시 시작한 Fluent Bit 3개가 제어 플레인을 포함해 모두 Running이 됐습니다. 로그에는 [output:loki:loki.0] configured, hostname=loki.loki.svc.cluster.local:3100이 찍혔습니다.

06

Grafana에서 로그 보기: 데이터 소스와 LogQL

4편의 Grafana에 Loki를 연결합니다. kube-prometheus-stack의 Grafana는 grafana_datasource: "1" 라벨이 붙은 ConfigMap을 사이드카가 읽어 데이터 소스로 등록하므로, Grafana를 다시 시작할 필요가 없습니다.

apiVersion: v1
kind: ConfigMap
metadata:
name: loki-datasource
namespace: monitoring
labels:
grafana_datasource: "1"
data:
loki-datasource.yaml: |
apiVersion: 1
datasources:
- name: Loki
type: loki
uid: loki
access: proxy
url: http://loki.loki.svc.cluster.local:3100

적용하고 20초 안에 데이터 소스 목록에 Loki가 생겼고, 상태 점검에서 Data source successfully connected.가 나왔습니다. 다만 그 전에 열어 둔 Grafana 화면의 Explore 데이터 소스 목록에는 Loki가 보이지 않았습니다. 웹 화면이 페이지를 열 때 받아 둔 목록을 쓰기 때문이라, 브라우저를 새로고침하면 나타납니다. 확인용으로 Windows에서 http://localhost:8080/?from=loki-test-1부터 -5까지 요청을 보낸 뒤 Grafana의 Explore에서 Loki를 고르고 검색했습니다.

{namespace="default", container="nginx"} |= "loki-test"

중괄호 안이 라벨로 범위를 좁히는 부분이고, |=는 그 안에서 본문에 문자열이 들어간 줄을 거릅니다. 요청 5개가 nginx 접근 로그 5줄로 모두 검색됐고, 파드별로는 3줄과 2줄이었습니다. sum by (pod) (count_over_time({…} |= "loki-test" [15m]))처럼 바꾸면 줄 수를 숫자로 세어 메트릭처럼 그래프로 그릴 수도 있습니다.

브라우저로 http://localhost:8080/?from=loki-test-6, -7을 열고 같은 쿼리를 Grafana Explore에서 실행하면 아래처럼 보입니다. 브라우저 요청은 캐시 때문에 상태 코드가 200 대신 304로 찍히기도 합니다. Logs volume 그래프에는 unknown 10건과 error 1건이 나왔는데, error는 브라우저가 자동으로 요청한 favicon.ico를 nginx가 찾지 못해 남긴 오류 로그였습니다. 그 줄의 referrer에 loki-test가 들어 있어 함께 검색된 것이고, nginx 오류 로그는 [error]가 찍혀 stderr로 나오므로 error로 분류됩니다. 접근 로그에는 수준 표시가 없어 unknown으로 나옵니다.

로그를 모으는 네임스페이스는 default, envoy-gateway-system, kube-system, logging, loki, monitoring 여섯 곳이었습니다. Loki는 Fluent Bit이 붙인 라벨 외에 service_name과 stream 라벨을 스스로 추가합니다.

쿼리를 직접 쓰지 않아도 됩니다. Explore의 Builder 모드에서 Label filters에 namespace = monitoring을 고르면 아래에 {namespace="monitoring"} 식이 만들어지고, 실습 화면에서는 Grafana 자신의 로그가 214줄 나왔습니다. 위쪽 Logs volume 그래프는 시간대별 로그 양을 error·info·warning으로 나눠 보여 줬는데(이때 error 9, info 138, warning 30), 이 수준은 Fluent Bit 설정에 넣은 값이 아니라 로그 본문의 level=info 같은 표시를 Grafana와 Loki가 읽어 detected_level 필드로 나눈 것입니다. 에러가 몰린 시간대를 그래프에서 먼저 찾고 그 구간의 로그만 펼쳐 보는 식으로 쓰면 편합니다.

07

Fluent Bit의 확장성: AWS로 함께 보내는 구성 예시

Fluent Bit은 출력을 여러 개 둘 수 있어서, 같은 로그를 Loki와 다른 저장소로 동시에 보낼 수 있습니다. 예를 들어 운영 환경에서는 실시간 조회는 Loki, 장애 대응용 보관은 CloudWatch Logs, 장기 보관은 S3로 나누는 구성을 생각할 수 있습니다. 아래는 이 실습 클러스터에 적용하지 않은 구성 예시로, 설정 이름은 Fluent Bit 공식 문서 기준이고 로그 그룹·버킷 이름은 가상의 값입니다. Fluent Bit CloudWatch 출력

[OUTPUT]
Name cloudwatch_logs
Match kube.*
Region ap-northeast-2
Log_Group_Name /lab/kubernetes/containers
Log_Stream_Prefix from-fluent-bit-
Auto_Create_Group On
Log_Retention_Days 7

[OUTPUT]
Name s3
Match kube.*
Region ap-northeast-2
Bucket example-lab-log-archive
Total_File_Size 50M
Upload_Timeout 10m
Compression gzip
S3_Key_Format /kubernetes/%Y/%m/%d/%H/$UUID.gz

같은 Match kube.*를 가진 출력이 여러 개면 Fluent Bit은 같은 로그를 각각에 보냅니다. CloudWatch Logs 출력은 Auto_Create_Group On이면 로그 그룹이 없을 때 만들고, S3 출력은 Total_File_Size나 Upload_Timeout 중 먼저 닿는 조건에서 파일을 올립니다. 공식 문서에 적힌 필요 권한은 아래와 같습니다.

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:PutRetentionPolicy"],
"Resource": "LOG_GROUP_ARN_PATTERN"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "LOG_BUCKET_OBJECTS_ARN"
}
]
}

Fluent Bit 파드가 AWS에 접근하려면 이 권한을 가진 자격 증명이 필요합니다. 로컬 kind에서는 자격 증명을 따로 넣어야 하고, 키를 매니페스트에 직접 쓰지 않도록 주의합니다. LOG_GROUP_ARN_PATTERN은 리전과 계정 ID, /lab/kubernetes/로 시작하는 로그 그룹 이름을 담은 CloudWatch Logs 로그 그룹 ARN으로, LOG_BUCKET_OBJECTS_ARN은 버킷 example-lab-log-archive 안의 모든 객체를 가리키는 S3 ARN(버킷 ARN 뒤에 /*)으로 바꿉니다. 공식 문서의 예시 정책은 리소스를 *로 두지만, 이렇게 로그 그룹과 버킷 이름으로 좁히는 편이 안전합니다.

09

자주 묻는 질문

Promtail이나 Grafana Alloy 대신 Fluent Bit을 써도 되나요?

됩니다. Loki는 수집기를 가리지 않고 푸시 API로 로그를 받습니다. Grafana 문서와 그대로 맞추고 싶다면 Grafana Alloy가 편하고, 같은 로그를 Loki 말고 CloudWatch Logs·S3·Elasticsearch로도 보내야 한다면 Fluent Bit이 맞습니다.

Loki 게이트웨이는 꼭 필요한가요?

이 실습에서는 Fluent Bit과 Grafana 모두 Loki 서비스(3100)에 직접 연결했기 때문에 게이트웨이를 거치지 않습니다. 게이트웨이는 여러 구성 요소로 나눈 배포에서 요청을 나눠 주거나 인증을 앞에서 받을 때 쓰입니다.

작성 방식과 다음 편

이 글의 명령, 오류 메시지, 결과는 1편의 실습 PC에서 직접 실행한 것이며, 설치와 설정은 Loki·Fluent Bit·kind 공식 문서를 기준으로 했습니다. AWS 출력 부분만 실행하지 않은 구성 예시입니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 Keycloak을 설치하고 Grafana 로그인을 SSO로 바꿉니다.

반응형

kube-prometheus-stack 설치와 Grafana 접속: kind 클러스터 모니터링과 target down 해결

반응형
로컬 쿠버네티스 실습 4편
kube-prometheus-stack으로
Prometheus·Grafana 띄우기

helm 한 번으로 설치하고, Gateway로 Grafana를 열고, down 6개를 0개로 만든 기록

kube-prometheus-stackGrafanaPrometheusGateway API

kube-prometheus-stack helm 차트를 쓰면 Prometheus, Grafana, Alertmanager, node-exporter, kube-state-metrics가 한 번에 설치됩니다. 실습 PC의 kind 클러스터에서는 81초 만에 모든 파드가 Running이 됐고 메모리는 약 1.4GB를 더 썼습니다. Grafana는 3편의 Gateway에 grafana.localhost 호스트 이름으로 HTTPRoute를 붙여 Windows 브라우저에서 열었습니다. 설치 직후 Prometheus 수집 대상 28개 중 6개가 down이었는데, kind가 제어 플레인 구성 요소의 메트릭 포트를 127.0.0.1에만 열기 때문이었고 바인딩 주소를 바꾸자 28개 모두 up이 됐습니다.

1~3편에서 만든 kind 클러스터(노드 3개, Envoy Gateway의 lab-gateway가 Windows localhost:8080을 받음)를 이어서 씁니다. 차트는 kube-prometheus-stack 93.1.0, Grafana 13.3.0 기준입니다. 알림 설정, 장기 보관, 운영 환경 보안 설정은 다루지 않습니다.

01

kube-prometheus-stack에 들어 있는 것

kube-prometheus-stack은 쿠버네티스 모니터링에 필요한 구성 요소를 묶은 helm 차트입니다. Prometheus를 직접 설정 파일로 운영하지 않고, Prometheus Operator가 ServiceMonitor 같은 리소스를 읽어 수집 대상을 관리합니다. kube-prometheus-stack 차트

설치된 주요 구성 요소
구성 요소하는 일실습에서 뜬 파드
Prometheus메트릭을 주기적으로 긁어 저장prometheus-…-0 (2/2)
Prometheus OperatorServiceMonitor 등을 읽어 Prometheus 설정 관리…-operator (1/1)
Grafana대시보드로 메트릭 시각화kps-grafana (3/3)
Alertmanager경고를 모아 전달alertmanager-…-0 (2/2)
node-exporter노드 CPU·메모리·디스크 수집노드마다 1개씩 3개
kube-state-metrics디플로이먼트·파드 같은 리소스 상태를 메트릭으로1개
02

helm으로 설치하기

prometheus-community 저장소를 추가하고 설치합니다. --wait를 붙이면 모든 파드가 준비될 때까지 기다린 뒤 끝납니다.

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update prometheus-community
helm install kps prometheus-community/kube-prometheus-stack -n monitoring --create-namespace --wait --timeout 10m

실습 시점의 차트 버전은 93.1.0이었고 설치는 81초가 걸렸습니다. 모든 서비스는 ClusterIP로 만들어지며, Grafana 서비스 이름은 릴리스 이름을 붙인 kps-grafana(포트 80)입니다. WSL 메모리 사용량은 설치 전 1.9GiB에서 설치 후 3.3GiB가 됐습니다. 참고로 kind에는 metrics-server가 없어서 kubectl top nodes는 Metrics API not available을 냅니다. 이 글의 메트릭은 모두 Prometheus가 따로 수집합니다.

03

Gateway에 Grafana 붙이기: 다른 네임스페이스 라우트 허용

3편의 Gateway(lab-gateway)는 default 네임스페이스에 있고 Grafana는 monitoring 네임스페이스에 있습니다. Grafana용 HTTPRoute를 monitoring에 만들고, grafana.localhost라는 호스트 이름으로 web 앱과 구분합니다.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: grafana
namespace: monitoring
spec:
parentRefs:
- name: lab-gateway
namespace: default
hostnames:
- grafana.localhost
rules:
- backendRefs:
- name: kps-grafana
port: 80

이 라우트만 먼저 적용해 보면 붙지 않습니다. HTTPRoute 상태에 아래처럼 나오고, grafana.localhost로 보낸 요청은 404가 돌아왔습니다. Gateway 리스너는 기본적으로 같은 네임스페이스의 라우트만 받기 때문입니다. Gateway 리소스 설명

Accepted=False (NotAllowedByListeners) No listeners included by this parent ref allowed this attachment.

Gateway 리스너에 allowedRoutes를 추가해 다른 네임스페이스의 라우트도 받게 합니다. 실습 환경이라 from: All로 열었고, 실제 운영에서는 from: Selector로 라벨이 붙은 네임스페이스만 허용하는 편이 안전합니다.

listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All

적용 후 HTTPRoute는 Accepted=True가 됐고, Gateway의 attachedRoutes는 2(web, grafana)가 됐습니다. 호스트 이름이 없는 web 라우트는 그대로 나머지 요청을 받습니다.

04

Windows에서 grafana.localhost로 접속

Windows는 localhost로 끝나는 이름을 내 PC 주소로 풀어 줍니다. 실습 PC에서 Resolve-DnsName grafana.localhost를 실행하면 ::1이 나왔고, hosts 파일을 고치지 않아도 바로 접속됐습니다.

PS> curl.exe -s http://grafana.localhost:8080/api/health
{
"database": "ok",
"version": "13.3.0",
...
}
PS> curl.exe -s -o NUL -w "HTTP %{http_code}" http://grafana.localhost:8080/login
HTTP 200

브라우저에서 http://grafana.localhost:8080을 열면 Grafana 13.3.0 로그인 화면이 나옵니다. 사용자 이름은 admin이고, 비밀번호는 차트가 만든 시크릿에 들어 있습니다.

kubectl get secret -n monitoring kps-grafana -o jsonpath='{.data.admin-password}' | base64 -d; echo

로그인하면 차트가 미리 넣어 둔 대시보드가 있습니다. 실습 클러스터에는 대시보드용 ConfigMap이 29개 있었고, Dashboards 메뉴를 열면 Alertmanager, CoreDNS, etcd와 Kubernetes / Compute Resources / …, Kubernetes / Networking / …, Node Exporter / … 같은 대시보드가 태그와 함께 나옵니다. 따로 만들지 않아도 노드·네임스페이스·파드 단위로 바로 볼 수 있습니다.

예를 들어 Kubernetes / Compute Resources / Pod 대시보드에서 namespace를 default, pod를 2편에서 만든 web-… 파드로 고르면 그 파드의 CPU와 메모리가 그래프로 나옵니다. 실습 화면에서 nginx 컨테이너는 CPU를 거의 쓰지 않았고(0에 가까움), 메모리는 Working Set 기준 12.0MiB, RSS 8.37MiB였습니다. 요청이 거의 없는 nginx라 값이 작지만, 같은 화면에서 리소스 요청·제한(Quota)과 실제 사용량을 나란히 볼 수 있어 파드 크기를 정할 때 쓸 수 있습니다. 실습 PC에서 어떤 파드가 자원을 많이 쓰는지 찾을 때도 이 대시보드부터 보면 편합니다.

05

target down 6개: kind에서 생기는 이유와 해결

설치 직후 Prometheus 수집 대상을 확인하면 28개 중 22개만 up이었습니다. Prometheus 화면의 Status → Targets에서 볼 수도 있고, API로 세어 볼 수도 있습니다.

설치 직후 down이었던 수집 대상
대상포트오류
kube-controller-manager10257connection refused
kube-scheduler10259connection refused
kube-etcd2381connection refused
kube-proxy (노드 3개)10249connection refused

모두 connection refused라는 점이 실마리입니다. Prometheus는 노드 주소로 접속하는데, kind가 만든 이 구성 요소들은 메트릭 포트를 노드 안의 127.0.0.1에서만 엽니다. 실제로 제어 플레인 노드 안의 정적 파드 매니페스트에는 --bind-address=127.0.0.1이, etcd에는 --listen-metrics-urls=http://127.0.0.1:2381이 들어 있었고, kube-proxy 설정의 metricsBindAddress는 빈 값(기본값 127.0.0.1:10249)이었습니다.

실습 클러스터이므로 바인딩 주소를 0.0.0.0으로 바꿨습니다. kube-proxy는 ConfigMap을 고친 뒤 데몬셋을 다시 시작하고, 나머지 셋은 제어 플레인 노드 컨테이너 안의 매니페스트를 고치면 kubelet이 파드를 다시 만듭니다.

kubectl -n kube-system get cm kube-proxy -o yaml \
| sed 's/metricsBindAddress: .*/metricsBindAddress: 0.0.0.0:10249/' \
| kubectl apply -f -
kubectl -n kube-system rollout restart ds/kube-proxy
docker exec lab-control-plane sh -c "
sed -i 's/--bind-address=127.0.0.1/--bind-address=0.0.0.0/' /etc/kubernetes/manifests/kube-controller-manager.yaml /etc/kubernetes/manifests/kube-scheduler.yaml
sed -i 's#--listen-metrics-urls=http://127.0.0.1:2381#--listen-metrics-urls=http://0.0.0.0:2381#' /etc/kubernetes/manifests/etcd.yaml"

약 40초 뒤 네 구성 요소가 다시 Running이 됐고, 그다음 수집 주기에 28개 모두 up으로 바뀌었습니다. 이때 켜져 있던 경고는 Watchdog 하나뿐이었는데, 경고 전달 경로가 살아 있는지 확인하려고 항상 켜 두는 정상 경고입니다.

백업 파일 위치 주의

매니페스트를 고치기 전에 백업을 같은 /etc/kubernetes/manifests 폴더에 두면 kubelet이 그 파일까지 파드로 읽어 같은 구성 요소가 두 번 뜰 수 있습니다. 이 실습에서는 백업을 폴더 밖(/root/manifest-backup)에 두었습니다. 또 이 수정은 kind 클러스터를 다시 만들면 사라지므로, 다시 만들 때 같은 작업을 반복해야 합니다.

07

자주 묻는 질문

운영 클러스터에서도 bind-address를 0.0.0.0으로 바꿔도 되나요?

그대로 따라 하면 안 됩니다. 메트릭 포트가 모든 네트워크에 열리므로 운영 환경에서는 노드 방화벽이나 네트워크 정책으로 Prometheus만 접근하게 막아야 합니다. EKS 같은 관리형 클러스터는 제어 플레인 구성 요소를 사용자가 직접 바꿀 수 없으므로 수집 방식이 다릅니다.

Grafana를 localhost:8080/grafana 같은 경로로 열 수는 없나요?

가능하지만 Grafana에 하위 경로 설정(root_url과 serve_from_sub_path)을 따로 해야 합니다. 이 글은 설정을 바꾸지 않아도 되는 호스트 이름 방식(grafana.localhost)을 썼습니다.

작성 방식과 다음 편

이 글의 명령과 출력은 1편의 실습 PC에서 직접 실행한 결과이며, 설치와 설정은 kube-prometheus-stack 차트와 Gateway API 공식 문서를 기준으로 했습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 Loki를 설치해 파드 로그를 모으고, 같은 Grafana 화면에서 메트릭과 로그를 함께 봅니다.

반응형

NGINX Ingress 대신 Gateway API: kind 클러스터 앱을 Windows localhost로 접속하기

반응형
로컬 쿠버네티스 실습 3편
NGINX Ingress 대신 Gateway API
kind 앱을 localhost:8080으로

Envoy Gateway를 설치하고, 로드밸런서 없는 kind에서 Windows 브라우저까지 연결한 과정

Gateway APIEnvoy GatewayHTTPRoutekind

kind 클러스터에서 Gateway API를 쓰려면 Gateway API 리소스 정의와 구현체가 필요한데, Envoy Gateway helm 차트 하나로 둘 다 설치됩니다. 남는 문제는 kind에 로드밸런서가 없다는 점입니다. 이 글에서는 Envoy 프록시를 8080 포트가 연결된 제어 플레인 노드에 띄우고 노드의 80번 포트를 직접 받게(hostPort) 설정해서, Windows 브라우저의 localhost:8080이 Gateway → HTTPRoute → web 파드로 이어지게 만들었습니다. 적용 후 약 20초 만에 Gateway가 준비됐고, 요청 6번이 파드 2개에 3건씩 나뉘어 들어가는 것까지 확인했습니다.

1편에서 만든 kind 클러스터(노드 3개, Windows 8080 → 제어 플레인 80 포트 연결)와 2편에서 만든 web Deployment(nginx 파드 2개)를 그대로 씁니다. Envoy Gateway v1.10.0, Kubernetes v1.37.0 기준이며 HTTPS와 여러 도메인 라우팅은 다루지 않습니다.

01

왜 Ingress가 아니라 Gateway API인가

쿠버네티스에서 외부 요청을 서비스로 보내는 방법은 오랫동안 Ingress였고, 그 구현체로 NGINX Ingress Controller가 가장 많이 쓰였습니다. GitHub에서 확인하면 kubernetes/ingress-nginx 저장소는 2026년 3월 이후 새 커밋이 없습니다. 새로 실습 환경을 만든다면 Ingress의 후속 표준인 Gateway API로 시작하는 편이 낫습니다. Gateway API 공식 문서

Gateway API는 역할을 리소스 세 단계로 나눕니다. 이 구분을 알면 아래 매니페스트가 왜 여러 개인지 이해하기 쉽습니다.

Gateway API의 주요 리소스
리소스 정하는 것 이 글에서 만든 것
GatewayClass 어떤 구현체(컨트롤러)를 쓸지 eg (Envoy Gateway)
Gateway 어느 포트·프로토콜로 요청을 받을지 lab-gateway (HTTP 80)
HTTPRoute 받은 요청을 어느 서비스로 보낼지 web (/ → web 서비스 80)
02

Envoy Gateway 설치: CRD까지 helm 한 번에

kind는 Gateway API 리소스를 기본으로 넣지 않습니다. 설치 전에 kubectl api-resources --api-group=gateway.networking.k8s.io를 실행하면 아무것도 나오지 않습니다. Envoy Gateway의 helm 차트가 리소스 정의(CRD)와 컨트롤러를 함께 설치합니다. Envoy Gateway 빠른 시작

helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.10.0 -n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system deployment/envoy-gateway --for=condition=Available

컨트롤러가 준비되기까지 18초가 걸렸습니다. 설치 중에 인증서를 만드는 eg-gateway-helm-certgen 잡이 한 번 실행되고 Completed로 끝납니다. 설치된 리소스 종류는 GatewayClass, Gateway, HTTPRoute, GRPCRoute, TLSRoute 등 10가지였습니다.

확인해 볼 점: 함께 깔리는 Gateway API 버전

같은 날 Gateway API 최신 릴리스는 v1.7.0이었지만, Envoy Gateway v1.10.0 차트가 설치한 CRD에는 v1.6.1 experimental 채널이 적혀 있었습니다. 구현체가 지원하는 버전이 따로 있으므로, Gateway API CRD를 직접 최신으로 올리기 전에 구현체의 지원 버전을 먼저 확인합니다. 확인 명령: kubectl get crd gateways.gateway.networking.k8s.io -o jsonpath='{.metadata.annotations}'

03

로드밸런서 없는 kind에서 Windows까지 잇는 방법

Envoy Gateway는 Gateway를 만들면 Envoy 프록시 파드와 LoadBalancer 서비스를 만듭니다. 클라우드라면 이 서비스에 외부 주소가 붙지만 kind에는 로드밸런서가 없습니다. 선택지는 세 가지입니다.

kind에서 Gateway에 접속하는 방법
방법 장점 단점
kubectl port-forward 설정 없이 바로 확인 가능(공식 빠른 시작의 방법) 명령을 켜 둔 동안만 접속, 매번 다시 실행
MetalLB 같은 로드밸런서 구현 클라우드와 비슷하게 주소가 붙음 붙는 주소가 WSL 안 Docker 네트워크 대역이라 Windows 브라우저에서 바로 쓰려면 추가 설정이 필요할 수 있음
노드 포트 직접 받기(hostPort) 1편의 8080 연결을 그대로 사용, 항상 접속 가능 Envoy 파드를 특정 노드에 고정해야 함

이 글은 세 번째 방법을 썼습니다. 1편에서 Windows 8080을 제어 플레인 노드 컨테이너의 80번 포트에 연결해 두었으므로, Envoy가 그 노드의 80번 포트를 직접 받으면 됩니다. Envoy 프록시의 모양은 EnvoyProxy라는 Envoy Gateway 전용 리소스로 바꿉니다. EnvoyProxy 설정 안내

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: kind-hostport
namespace: envoy-gateway-system
spec:
provider:
type: Kubernetes
kubernetes:
envoyService:
type: ClusterIP
envoyDeployment:
replicas: 1
pod:
nodeSelector:
ingress-ready: "true"
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
patch:
type: StrategicMerge
value:
spec:
template:
spec:
containers:
- name: envoy
ports:
- containerPort: 10080
hostPort: 80
protocol: TCP

설정마다 이유가 있습니다. envoyService.type: ClusterIP는 붙지 않을 외부 주소를 기다리지 않게 합니다. nodeSelector의 ingress-ready: "true"는 1편에서 제어 플레인에 붙여 둔 라벨이고, 제어 플레인에는 일반 파드가 올라가지 못하게 막는 테인트가 있어서 tolerations로 허용합니다. 마지막 patch가 핵심인데, 노드의 80번 포트를 Envoy 컨테이너의 10080번 포트로 연결합니다.

80이 아니라 10080인 이유는 Envoy Gateway가 1024보다 작은 특권 포트를 컨테이너 안에서 높은 번호로 바꿔 열기 때문입니다. Gateway 리스너를 80으로 만들면 Envoy는 실제로 10080에서 기다립니다. 처음에는 이 점을 모르면 hostPort를 80 → 80으로 잡기 쉽습니다.

04

GatewayClass·Gateway·HTTPRoute 만들기

GatewayClass는 위의 EnvoyProxy를 parametersRef로 가리켜서, 이 클래스로 만드는 모든 Gateway가 그 설정을 따르게 합니다. web Deployment에는 아직 서비스가 없으므로 서비스도 함께 만듭니다.

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: kind-hostport
namespace: envoy-gateway-system
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: lab-gateway
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: lab-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web
port: 80

HTTPRoute의 parentRefs가 어느 Gateway에 붙을지, backendRefs가 요청을 받을 서비스를 정합니다. 서비스(web, 포트 80 → 파드 80)까지 하나의 파일에 넣어 kubectl apply -f로 적용했고, 상태가 준비될 때까지 기다렸습니다.

$ kubectl wait --for=condition=Programmed gateway/lab-gateway --timeout=180s
gateway.gateway.networking.k8s.io/lab-gateway condition met
$ kubectl get gatewayclass,gateway
NAME CONTROLLER ACCEPTED
eg gateway.envoyproxy.io/gatewayclass-controller True
NAME CLASS PROGRAMMED
lab-gateway eg True

적용부터 Programmed까지 약 20초가 걸렸습니다. HTTPRoute 상태에도 Accepted=True와 ResolvedRefs=True가 나와야 합니다. ResolvedRefs가 False라면 backendRefs의 서비스 이름이나 포트가 틀린 경우가 많습니다. Envoy 프록시 파드는 의도대로 lab-control-plane 노드에 떴고, envoy 컨테이너의 10080 포트에 hostPort 80이 붙어 있었습니다.

05

Windows 브라우저로 접속하고 부하 분산 확인

이제 Windows에서 접속합니다. 요청 경로는 Windows localhost:8080 → kind 제어 플레인 노드 80 → Envoy 10080 → web 서비스 → nginx 파드입니다.

PS> curl.exe -s -o NUL -w "HTTP %{http_code}" http://localhost:8080/
HTTP 200
PS> curl.exe -s http://localhost:8080/ | Select-String "<title>"
<title>Welcome to nginx!</title>

Windows 브라우저에서 http://localhost:8080을 열어도 nginx 환영 페이지가 나왔습니다. 마지막으로 요청이 파드 2개로 나뉘는지 봤습니다. Windows에서 /?n=1부터 /?n=6까지 6번 요청한 뒤 각 파드의 nginx 접근 로그를 세면 됩니다.

for p in $(kubectl get pods -l app=web -o name); do
echo "$p $(kubectl logs "$p" --since=5m | grep -c 'GET /?n=')건"
done

결과는 web-…-5gdxz 3건, web-…-k5682 3건이었습니다. Gateway가 서비스 뒤의 파드들로 요청을 나눠 보낸다는 것을 로그로 확인할 수 있습니다.

PowerShell에서 bash 명령을 넘길 때

위 반복문을 PowerShell에서 wsl -- bash -c "..."로 한 줄에 넘기면 $(...) 부분을 PowerShell이 먼저 해석해서 bash 문법 오류가 납니다. 실습 중 실제로 겪은 문제로, 스크립트 파일로 저장한 뒤 wsl -- bash 파일로 실행하면 해결됩니다.

07

자주 묻는 질문

hostPort 대신 port-forward만 써도 되나요?

확인만 할 때는 됩니다. Envoy Gateway 빠른 시작도 로드밸런서가 없으면 kubectl port-forward로 Envoy 서비스에 접속하는 방법을 안내합니다. 다만 명령을 켜 둔 동안에만 접속되므로, 다음 편부터 여러 앱을 계속 띄워 둘 실습 환경에는 hostPort 방식이 편합니다.

Gateway의 ADDRESS에 나오는 주소로 접속하면 되나요?

이 구성에서는 Envoy 서비스를 ClusterIP로 바꿨기 때문에 ADDRESS에는 클러스터 안에서만 쓰는 주소가 나옵니다. Windows에서는 그 주소가 아니라 localhost:8080으로 접속합니다.

작성 방식과 다음 편

이 글의 명령과 출력은 1편의 실습 PC에서 직접 실행한 결과이며, 설치와 설정은 Gateway API와 Envoy Gateway 공식 문서를 기준으로 했습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 kube-prometheus-stack으로 Prometheus와 Grafana를 설치하고, Grafana도 이 Gateway를 통해 Windows 브라우저에서 엽니다.

반응형

Headlamp 설치와 사용법: 로컬 쿠버네티스를 화면으로 보고 파드 배포하기

반응형
로컬 쿠버네티스 실습 2편
Headlamp 설치와 사용법
클릭으로 보고, YAML로 배포하기

WSL2 안의 kind 클러스터를 Windows 앱에서 보고 파드까지 배포해 본 기록

Headlamp쿠버네티스 GUIkindWSL2

Headlamp는 쿠버네티스 클러스터를 화면으로 보고 관리하는 데스크톱 앱입니다. Windows에서는 winget으로 설치하고, 사용자 폴더의 .kube\config에 클러스터 접속 정보를 넣으면 별도 설정 없이 클러스터가 목록에 나옵니다. 보기 전용이 아니라 왼쪽 아래 '+ 생성' 버튼에 YAML을 붙여 넣고 '적용'을 누르면 Deployment를 바로 만들 수 있고, 실제로 만든 파드 2개가 Running이 되는 것까지 확인했습니다.

1편에서 WSL2 Ubuntu 24.04 안에 만든 kind 클러스터(kind-lab, 노드 3개)를 Windows 11에 설치한 Headlamp 0.46.0으로 연결한 결과입니다. 화면 이름은 Headlamp 한국어 화면 기준이며, 로그 보기·파드 터미널·삭제는 이 글에서 다루지 않습니다.

01

Headlamp란: Kubernetes Dashboard 대신 쓰는 GUI

Headlamp는 쿠버네티스 리소스를 목록과 상세 화면으로 보여 주는 오픈소스 GUI입니다. 데스크톱 앱으로 설치할 수도 있고 클러스터 안에 웹 앱으로 올릴 수도 있습니다. 이 글은 Windows 데스크톱 앱을 씁니다. Headlamp 저장소

쿠버네티스 GUI를 고를 때 예전에는 Kubernetes Dashboard나 Lens를 먼저 떠올렸습니다. 2026년 11월 기준 GitHub에서 확인하면 Kubernetes Dashboard 저장소는 kubernetes-retired 조직으로 옮겨져 보관(archived) 상태이고, Lens의 공개 저장소는 2025년 2월 이후 갱신이 없습니다. Headlamp는 쿠버네티스 공식 하위 프로젝트 조직(kubernetes-sigs)에서 개발되고 있어 새로 시작한다면 먼저 검토할 만합니다.

쿠버네티스 클러스터를 보는 방법 비교
방법형태이런 경우에 맞음
kubectl명령어모든 작업의 기본. 자동화와 스크립트
k9s터미널 대시보드터미널에서 키보드로 빠르게 둘러볼 때
Headlamp데스크톱·웹 앱구조를 화면으로 익히거나 클릭으로 확인할 때
VS Code Kubernetes 확장편집기 사이드바YAML을 편집하면서 클러스터를 같이 볼 때
02

Windows에 Headlamp 설치(winget)

Windows 11에는 패키지 관리자 winget이 기본으로 들어 있습니다. 먼저 패키지 이름을 확인하고 설치합니다.

winget search --id Headlamp --source winget
winget install --id Headlamp.Headlamp -e --source winget

실제로 설치된 버전은 0.46.0이었고, 설치 파일은 Headlamp의 GitHub 릴리스에서 내려받았습니다. winget이 설치 파일 해시를 확인한 뒤 설치합니다.

찾음 Headlamp [Headlamp.Headlamp] 버전 0.46.0
다운로드 중 https://github.com/kubernetes-sigs/headlamp/releases/download/v0.46.0/Headlamp-0.46.0-win-x64.exe
설치 관리자 해시를 확인했습니다.
설치 성공

설치가 끝나면 시작 메뉴에서 Headlamp를 실행할 수 있습니다.

03

WSL2 kind 클러스터를 Headlamp에 연결하기

Headlamp는 Windows 사용자 폴더의 .kube\config 파일을 읽어 클러스터 목록을 만듭니다. kind 클러스터의 접속 정보는 WSL 안에 있으므로 kind get kubeconfig로 꺼내 Windows 쪽에 저장합니다. 이미 .kube\config가 있다면 덮어쓰지 말고 백업부터 합니다.

New-Item -ItemType Directory -Force "$env:USERPROFILE\.kube" | Out-Null
$cfg = (wsl -d Ubuntu-24.04 -u root -- kind get kubeconfig --name lab) -join "`n"
[IO.File]::WriteAllText("$env:USERPROFILE\.kube\config", $cfg + "`n")

[IO.File]::WriteAllText를 쓴 이유는 Windows PowerShell 5.1의 > 리디렉션이 UTF-16으로 저장하기 때문입니다. 저장된 파일의 server 값은 https://127.0.0.1:포트였습니다. kind는 API 서버를 WSL 안의 127.0.0.1에 열어 두는데, WSL2는 이 주소를 Windows에서도 그대로 쓸 수 있게 해 줍니다. Headlamp를 열기 전에 Windows에서 실제로 닿는지 확인할 수 있습니다.

PS> curl.exe -sk -o NUL -w "livez HTTP %{http_code}" https://127.0.0.1:포트/livez
livez HTTP 200

200이 나오면 Headlamp를 엽니다. 실습 PC에서는 클러스터 추가 설정 없이 첫 화면에 kind-lab이 바로 나왔습니다. 클러스터 상세 화면에는 Kubeconfig: 항목에 파일 경로가 표시되는데, 경로에 Windows 계정명이 들어가므로 화면을 캡처해 공유할 때는 가립니다.

04

화면 둘러보기: 노드·파드는 어디서 보나

kind-lab에 들어가면 왼쪽 메뉴가 쿠버네티스 리소스 종류별로 나뉘어 있습니다. 한국어 화면에서 본 메뉴는 아래와 같습니다.

Headlamp 왼쪽 메뉴(한국어 화면)
메뉴볼 수 있는 것
Clusters연결된 클러스터 목록과 노드
맵리소스 사이의 연결 관계
워크로드파드, 디플로이먼트, 스테이트풀 셋, 데몬셋, 레플리카 셋, 잡, 크론잡 등
스토리지 / 네트워크볼륨, 서비스 등
게이트웨이(베타)Gateway API 리소스(다음 편부터 사용)
보안 / 구성서비스 어카운트·역할, ConfigMap·Secret 등
커스텀 리소스CRD로 추가한 리소스

파드 목록은 워크로드 → 파드에서 봅니다. 시스템 파드까지 보려면 위쪽 네임스페이스 선택을 모든 네임스페이스로 바꿉니다. 1편에서 만든 클러스터라면 kube-system의 CoreDNS, kindnet, kube-proxy와 local-path-storage의 프로비저너까지 파드 13개가 Running으로 보입니다.

05

'+ 생성' 버튼으로 파드 배포하기

Headlamp는 보기 전용이 아닙니다. 왼쪽 아래 + 생성 버튼을 누르면 '생성 / 적용' 편집 창이 열립니다. 창에는 에디터·Form·문서·Review Changes 탭과 Upload File/URL 버튼이 있고, 아래쪽에 닫기·Dry Run·적용 버튼이 있습니다. 에디터 탭에 아래 YAML을 붙여 넣었습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80

nginx 컨테이너 하나를 가진 파드를 2개 유지하라는 Deployment입니다. selector.matchLabels와 template.metadata.labels의 app: web이 같아야 Deployment가 자기 파드를 찾습니다. 적용을 누른 뒤 WSL 셸에서 kubectl로 결과를 확인했습니다.

$ kubectl get deploy web
NAME READY UP-TO-DATE AVAILABLE AGE
web 2/2 2 2 20s
$ kubectl get pods -l app=web -o custom-columns=NAME:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName
NAME STATUS NODE
web-75d8d959fb-5gdxz Running lab-worker
web-75d8d959fb-k5682 Running lab-worker2

Headlamp가 만든 리소스라는 기록도 클러스터에 남습니다. Deployment의 metadata.managedFields를 보면 이 리소스를 바꾼 주체가 Headlamp 0.46.0 (windows ...)로, 그다음이 kube-controller-manager로 적혀 있습니다. Headlamp가 kubectl처럼 API 서버에 직접 요청을 보낸다는 뜻이므로, Headlamp에서 한 작업도 kubectl로 한 작업과 똑같이 클러스터에 반영됩니다.

Dry Run 먼저

적용 옆의 Dry Run은 실제로 만들지 않고 API 서버가 요청을 받아들이는지만 확인하는 버튼입니다. YAML을 처음 써 볼 때는 Dry Run으로 오류가 없는지 본 뒤 적용하면 안전합니다.

06

클러스터가 Unavailable로 나올 때

한동안 쓰지 않다가 Headlamp를 보니 kind-lab 아래에 Unavailable이 표시됐습니다. Windows에서 /livez를 다시 호출하자 응답이 없었고(HTTP 000), wsl -l -v로 보니 Ubuntu-24.04가 Stopped였습니다. 열린 WSL 셸이나 실행 중인 명령이 없으면 WSL이 스스로 꺼지고, 그 안의 Docker와 kind 노드도 함께 멈춥니다.

PS> wsl -l -v
NAME STATE VERSION
* Ubuntu-24.04 Stopped 2
PS> wsl -d Ubuntu-24.04 -u root

WSL을 다시 켜는 것만으로 해결됐습니다. 켜자마자 Docker가 시작되고 kind 노드 컨테이너 3개가 자동으로 다시 떴습니다. 다만 켜진 직후 몇십 초 동안은 kubectl get nodes가 Forbidden 오류를 냈고, 잠시 뒤 다시 실행하자 노드 3개가 모두 Ready였습니다. Headlamp를 쓰는 동안 Windows 터미널에 Ubuntu 탭을 하나 열어 두면 이 문제를 피할 수 있습니다.

이렇게 WSL이 꺼졌다 켜지는 일이 반복되면 Headlamp 파드 목록의 '재시작' 열에 숫자가 쌓입니다. 실습 PC에서는 약 30분 사이에 kindnet·kube-proxy·CoreDNS 같은 시스템 파드가 9회, local-path-provisioner가 12회로 표시됐습니다. kubectl로 직전 종료 이유를 보면 대부분 Unknown, 종료 코드 255였는데, 컨테이너가 스스로 죽은 것이 아니라 WSL과 함께 갑자기 멈췄다는 뜻입니다. local-path-provisioner는 API 서버가 준비되기 전에 먼저 떠서 Error(종료 코드 1)로 몇 번 더 재시작했습니다. 모두 Running으로 돌아왔다면 이 환경에서는 걱정하지 않아도 되는 숫자입니다.

kubectl get pods -A -o custom-columns='NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,REASON:.status.containerStatuses[0].lastState.terminated.reason,EXIT:.status.containerStatuses[0].lastState.terminated.exitCode'
08

자주 묻는 질문

Headlamp는 보기 전용인가요?

아닙니다. 이 글처럼 YAML로 리소스를 만들 수 있고, Headlamp 저장소의 README는 생성·수정·삭제, 로그, 터미널 접속을 기능으로 소개합니다. 버튼은 접속한 사용자의 권한에 맞춰 보이므로, kind 기본 kubeconfig처럼 관리자 권한이면 모든 작업 버튼이 나옵니다.

kind 클러스터를 다시 만들면 Headlamp 설정도 바꿔야 하나요?

kind는 클러스터를 만들 때마다 API 서버 포트를 새로 정합니다. 클러스터를 지우고 다시 만들었다면 kind get kubeconfig로 Windows의 .kube\config를 다시 저장해야 합니다.

작성 방식과 다음 편

이 글의 명령과 출력, 화면 이름은 1편의 실습 PC에서 직접 확인한 결과이며, 파드 배포는 Headlamp 화면에서 직접 적용했습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 이 클러스터에 Gateway API를 올려 Windows 브라우저에서 localhost:8080으로 web 파드에 접속합니다.

반응형

Windows에서 무료 쿠버네티스 실습 환경 만들기: WSL2 + Docker Engine + kind

반응형
로컬 쿠버네티스 실습 1편
Windows에서 무료 쿠버네티스 실습 환경
WSL2 + Docker Engine + kind

클라우드 비용 없이 내 PC에 노드 3개짜리 클러스터를 만든 과정과 실제로 겪은 문제

WSL2Docker Enginekindkubectl

Windows 11에서는 WSL2에 Ubuntu를 설치하고, 그 안에 Docker Engine과 kind를 올리면 클라우드 비용 없이 제어 플레인 1개와 워커 2개로 된 쿠버네티스 클러스터를 만들 수 있습니다. 실제로 해 보니 클러스터 생성은 52초면 끝났고, 시간이 걸린 부분은 WSL 설치 뒤 재부팅과 배포판 설치였습니다. Docker Desktop 없이 Docker 공식 apt 저장소로 설치하고, kubectl·kind·helm은 공식 배포처 파일을 SHA256으로 확인한 뒤 설치합니다.

Windows 11 Pro(빌드 26200), WSL 3.0.3, Ubuntu 24.04 LTS 기준입니다. 설치한 버전은 Docker 29.9.3, kubectl v1.37.2, kind v0.33.0, helm v4.3.1이며 클러스터 노드는 kindest/node:v1.37.0입니다. 회사 PC처럼 관리자 권한이나 가상화 설정이 막힌 환경은 다루지 않습니다.

01

왜 WSL2 + Docker Engine + kind인가

클라우드에서 쿠버네티스를 실습하면 관리형 컨트롤 플레인, 노드, 로드밸런서 비용이 시간 단위로 나옵니다. 로컬 PC에서는 이 비용이 없고, 클러스터를 지웠다가 1분 안에 다시 만들 수 있어서 설정을 망가뜨려 보는 실습에도 부담이 없습니다.

kind(Kubernetes IN Docker)는 쿠버네티스 노드 하나를 Docker 컨테이너 하나로 띄우는 도구입니다. 컨테이너만 늘리면 노드가 늘어나므로 제어 플레인과 워커를 나눈 다중 노드 구성을 PC 한 대에서 만들 수 있습니다. kind 빠른 시작

이 글에서 쓰는 구성 요소
구성 요소 역할 설치 위치
WSL2 + Ubuntu 24.04 리눅스 커널과 systemd가 있는 실행 환경 Windows
Docker Engine kind 노드 컨테이너를 실행 Ubuntu 안
kind Docker 컨테이너로 쿠버네티스 노드를 생성 Ubuntu 안
kubectl 클러스터에 명령을 보내는 CLI Ubuntu 안
helm 다음 편부터 쓸 오픈소스 패키지 설치 도구 Ubuntu 안

Docker Desktop을 쓰지 않은 이유는 두 가지입니다. WSL 안에 Docker Engine만 두면 구성 요소가 적어 문제가 생겨도 원인을 좁히기 쉽고, Docker Desktop은 회사 규모에 따라 유료 구독이 필요할 수 있어서 개인 실습 환경을 설명할 때 조건이 하나 더 붙기 때문입니다.

02

1단계: 가상화 확인과 WSL 설치(재부팅 필수)

먼저 CPU 가상화가 BIOS에서 켜져 있는지 확인합니다. 일반 권한 PowerShell에서 아래 명령을 실행해 둘 다 True면 다음 단계로 갑니다.

$p = Get-CimInstance Win32_Processor | Select-Object -First 1
$p.VirtualizationFirmwareEnabled
$p.SecondLevelAddressTranslationExtensions

실습 PC(Ryzen 5 3600X)에서는 둘 다 True였습니다. 이 PC에는 wsl.exe가 있었지만 wsl --status를 실행하면 WSL이 설치되어 있지 않다는 안내가 나왔습니다. wsl.exe는 Windows에 기본으로 들어 있는 진입점일 뿐이므로, 파일이 있다고 WSL이 설치된 것은 아닙니다.

WSL 설치는 Windows 기능을 켜는 작업이라 관리자 권한 PowerShell에서 실행합니다. WSL 설치 안내

wsl --install -d Ubuntu-24.04

여기서 실제로 막힌 부분이 있습니다. 설치 직후 재부팅하지 않은 상태에서는 wsl --version은 정상으로 나오는데 wsl -l -v는 설치된 배포가 없다고 나왔습니다. 배포판 설치를 다시 시도해도 "시스템을 다시 시작하면 변경 사항이 적용됩니다"라는 메시지만 나왔고, 이때 HypervisorPresent 값은 False였습니다. 가상 머신 플랫폼은 재부팅해야 켜지므로 WSL 설치 뒤에는 반드시 재부팅합니다.

재부팅 뒤에도 배포판이 없다면

재부팅 후 HypervisorPresent는 True가 됐지만 Ubuntu는 자동으로 설치되지 않았습니다. 이때는 일반 권한 PowerShell에서 wsl --install -d Ubuntu-24.04를 한 번 더 실행하면 다운로드와 설치가 바로 진행됩니다. 관리자 권한은 필요 없었습니다.

03

2단계: Ubuntu 안에 Docker Engine 설치

Ubuntu 셸에서 Docker 공식 apt 저장소를 등록하고 설치합니다. Ubuntu 기본 저장소의 docker.io 패키지가 아니라 Docker가 배포하는 패키지를 쓰는 방식입니다. Docker Engine Ubuntu 설치 안내

이 글의 명령은 wsl -d Ubuntu-24.04 -u root로 연 root 셸에서 실행했습니다. 일반 사용자 셸이라면 각 명령 앞에 sudo를 붙입니다.

apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME:-$VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

signed-by는 이 저장소의 패키지를 Docker 서명 키로만 검증하도록 묶는 설정입니다. Ubuntu 24.04의 코드명은 noble이라 실제로 등록된 줄은 아래와 같았습니다.

deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable

WSL의 Ubuntu 24.04는 PID 1이 systemd라서 Docker 서비스가 일반 리눅스처럼 바로 실행됐습니다. 설치 후 확인 결과는 아래와 같습니다.

$ docker version --format 'Client {{.Client.Version}} / Server {{.Server.Version}}'
Client 29.9.3 / Server 29.9.3
$ systemctl is-active docker
active
$ docker run --rm hello-world
Hello from Docker!
04

3단계: kubectl·kind·helm 설치와 체크섬 확인

세 도구는 모두 실행 파일 하나라서 공식 배포처에서 받아 /usr/local/bin에 넣으면 됩니다. 받은 파일이 배포된 파일과 같은지 SHA256 체크섬을 먼저 확인합니다. 아래는 kubectl 예시입니다. kubectl 설치 안내

KV=$(curl -fsSL https://dl.k8s.io/release/stable.txt)
curl -fsSLO "https://dl.k8s.io/release/$KV/bin/linux/amd64/kubectl"
curl -fsSLO "https://dl.k8s.io/release/$KV/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | sha256sum --check
install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

kind는 https://kind.sigs.k8s.io/dl/버전/kind-linux-amd64와 같은 위치의 .sha256sum 파일로, helm은 https://get.helm.sh/helm-버전-linux-amd64.tar.gz와 .sha256sum 파일로 같은 방식으로 확인했습니다. 세 파일 모두 OK가 나온 뒤 설치했고, 설치된 버전은 아래와 같습니다.

Client Version: v1.37.2
Kustomize Version: v5.8.1
kind v0.33.0 go1.26.7 linux/amd64
v4.3.1+gc0d08d0
실제로 겪은 오류: curl (23)

최신 버전 번호를 curl ... | grep -m1 tag_name 식으로 뽑았더니 curl: (23) Failure writing output to destination이 나왔습니다. grep -m1이 첫 줄을 찾자마자 파이프를 닫아 curl이 쓰기 오류를 내고, set -o pipefail을 켠 스크립트가 그 자리에서 멈춘 것입니다. 응답을 변수에 먼저 담은 뒤 grep하도록 바꾸자 해결됐습니다.

05

4단계: 노드 3개짜리 kind 클러스터 만들기

노드 구성은 설정 파일로 정합니다. 제어 플레인 1개와 워커 2개를 두고, 다음 편에서 Gateway API를 시험할 수 있게 제어 플레인의 80·443 포트를 Windows 쪽 8080·8443으로 연결했습니다.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: lab
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 8080
protocol: TCP
- containerPort: 443
hostPort: 8443
protocol: TCP
- role: worker
- role: worker

extraPortMappings는 노드 컨테이너의 포트를 호스트 포트로 내보내는 설정이고, ingress-ready=true 라벨은 이후 게이트웨이 컨트롤러를 이 노드에 두기 위한 표시입니다. 80·443을 그대로 쓰지 않은 이유는 Windows에서 이미 쓰는 포트와 겹치지 않게 하기 위해서입니다.

kind create cluster --config kind-lab.yaml --wait 180s

노드 이미지(kindest/node:v1.37.0) 다운로드를 포함해 52초가 걸렸습니다. 출력 마지막에 Ready after 8s가 나오고 kubectl 컨텍스트가 kind-lab으로 바뀝니다.

06

워커가 NotReady로 나올 때: 확인과 동작 테스트

생성 직후 바로 kubectl get nodes를 실행하면 제어 플레인만 Ready이고 워커 2개는 NotReady였습니다. 워커가 붙은 지 10초밖에 지나지 않은 시점이었고, kind 출력에도 Waiting ≤ 3m0s for control-plane = Ready라고 나오듯 --wait는 제어 플레인만 기다립니다. 모든 노드를 기다리려면 아래 명령을 씁니다.

$ kubectl wait --for=condition=Ready nodes --all --timeout=180s
node/lab-control-plane condition met
node/lab-worker condition met
node/lab-worker2 condition met
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
lab-control-plane Ready control-plane 33s v1.37.0
lab-worker Ready <none> 21s v1.37.0
lab-worker2 Ready <none> 21s v1.37.0

노드 목록만으로는 워커가 실제로 파드를 받는지 알 수 없으므로 nginx 파드 2개를 띄워 봤습니다. 약 6초 만에 둘 다 Running이 됐고 lab-worker와 lab-worker2에 하나씩 배치됐습니다. 확인한 뒤 바로 지웁니다.

kubectl create deployment hello --image=nginx:alpine --replicas=2
kubectl rollout status deploy/hello --timeout=120s
kubectl get pods -l app=hello -o wide
kubectl delete deployment hello
만들어진 클러스터 구성
항목 값
노드 lab-control-plane, lab-worker, lab-worker2 (모두 Docker 컨테이너)
노드 OS·런타임 Debian 13, containerd 2.3.4, 커널은 WSL2 커널 공유
네트워크(CNI) kindnet
스토리지 local-path-provisioner (기본 StorageClass)
WSL에 잡힌 자원 CPU 12개, 메모리 약 23GB (PC 48GB의 절반)
08

자주 묻는 질문

WSL 설치 후 재부팅을 꼭 해야 하나요?

네. 실습 PC에서는 재부팅 전까지 하이퍼바이저가 꺼져 있어(HypervisorPresent False) 배포판이 설치되지 않았습니다. 재부팅 후에도 배포판이 없으면 일반 권한으로 wsl --install -d Ubuntu-24.04를 다시 실행합니다.

일반 사용자로 docker와 kubectl을 쓰려면 어떻게 하나요?

사용자를 docker 그룹에 넣고(sudo usermod -aG docker 사용자명) WSL 셸을 다시 연 뒤, kind export kubeconfig --name lab으로 그 사용자의 kubeconfig에 클러스터 접속 정보를 씁니다. 이 글은 root 셸에서 진행했습니다.

작성 방식과 다음 편

이 글의 명령과 출력은 위에 적은 PC에서 직접 실행한 결과이며, 설치 순서는 Microsoft·Docker·Kubernetes·kind·Helm 공식 문서를 기준으로 했습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 이 클러스터를 Windows 앱 Headlamp로 연결해 화면으로 보고, WSL이 꺼졌다 켜졌을 때 클러스터가 다시 살아나는지도 확인합니다.

반응형

Bedrock Knowledge Bases 검색 설정과 재정렬: 필요한 문서를 먼저 찾는 방법 | Bedrock 28편

반응형
Amazon Bedrock 완전 가이드 · 28편
Knowledge Bases 검색 설정과 재정렬

필요한 문서를 먼저 찾는 방법

Amazon Bedrock Knowledge Bases는 질문할 때마다 검색 설정을 바꿀 수 있습니다. 가져올 청크 수는 numberOfResults(기본 5개, 1~100)로, 검색 범위는 메타데이터 필터로, 결과 순서는 재정렬 모델로, 여러 주제가 섞인 질문은 질의 분해로 조정합니다. 설정을 바꾸기 전에 Retrieve로 검색 결과의 원문·위치·메타데이터·점수를 먼저 보고, 문제가 수집·검색·생성 중 어디서 생겼는지 가른 뒤 한 번에 하나씩 바꿉니다.

고객 관리형 Knowledge Base의 비정형 데이터 소스를 기준으로 Retrieve·RetrieveAndGenerate 요청의 검색 설정(검색 개수, 메타데이터 필터, 재정렬, 질의 분해)과 검색 문제의 진단 순서를 설명합니다. 관리형 Knowledge Base와 다른 점은 해당 위치에 따로 표시합니다. 두 API의 응답 처리와 생성 프롬프트는 29편, 구조화 데이터 검색은 30편에서 다룹니다.

01

검색 개수와 검색 범위

Knowledge Base에 질문하면 Bedrock은 질문과 가까운 청크를 찾아 기본적으로 최대 5개를 돌려줍니다. 이 개수는 Retrieve나 RetrieveAndGenerate 요청의 numberOfResults로 1부터 100까지 정할 수 있으며, 값을 바꿔도 Knowledge Base 자체 설정은 바뀌지 않고 그 요청에만 적용됩니다. 검색 설정 안내

아래는 가상의 사내 서비스 안내 도우미가 Retrieve를 호출할 때의 요청 본문 구성 예시입니다. Knowledge Base ID는 본문이 아니라 요청 경로(/knowledgebases/KNOWLEDGE_BASE_ID/retrieve)에 들어갑니다.

{
"retrievalQuery": {"text": "회의실 예약은 며칠 전까지 할 수 있나요?"},
"retrievalConfiguration": {
"vectorSearchConfiguration": {
"numberOfResults": 10
}
}
}

numberOfResults는 상한입니다. 질문과 관련된 청크가 적거나 필터로 후보가 줄면 더 적게 돌아옵니다. 26편에서 다룬 계층적 분할을 쓰면 이 값은 자식 청크 수에 해당하고, 같은 부모를 가진 자식 청크는 부모 청크 하나로 바뀌어 반환되므로 결과 수가 요청보다 적을 수 있습니다.

개수를 늘리면 필요한 청크가 결과에 들어올 가능성은 커지지만, RetrieveAndGenerate에서는 모든 결과가 생성 프롬프트에 들어가므로 입력 토큰과 비용이 늘고 질문과 상관없는 내용도 함께 섞입니다. 프롬프트가 글자 수 한도를 넘는다는 오류가 나면 공식 문서도 검색 개수를 줄이거나 더 작은 청크로 다시 수집하라고 안내합니다. 기본값 5개에서 시작하고, 답에 필요한 청크가 결과 밖으로 밀려나는 것을 확인했을 때 늘리는 편이 안전합니다.

검색 범위는 기본적으로 요청한 Knowledge Base에 연결된 모든 데이터 소스입니다. 범위를 좁히는 방법은 세 가지입니다.

  • Knowledge Base 분리: 공개 범위나 업무 영역이 크게 다르면 Knowledge Base를 따로 만들고, 애플리케이션이 호출할 Knowledge Base를 고릅니다(25편).
  • 데이터 소스 지정: Bedrock이 모든 청크에 붙이는 기본 메타데이터 x-amz-bedrock-kb-data-source-id로 필터를 걸면 특정 데이터 소스에서만 찾습니다.
  • 사용자 정의 메타데이터: 부서·연도·문서 유형처럼 문서에 붙인 값으로 필터를 겁니다(다음 섹션).

관리형 Knowledge Base는 vectorSearchConfiguration 대신 managedSearchConfiguration에 같은 이름의 numberOfResults와 filter를 넣습니다. 관리형은 항상 하이브리드 검색을 쓰며 의미 검색만 따로 고를 수 없습니다.

02

명시적·암시적 메타데이터 필터

메타데이터 필터는 결과를 돌려주기 전에 메타데이터 조건에 맞지 않는 청크를 빼는 기능입니다. 애플리케이션이 조건을 직접 넣는 방식을 명시적(수동) 필터, 모델이 질문을 읽고 조건을 만들어 적용하는 방식을 암시적 필터라고 합니다. 두 방식 모두 25편에서 다룬 .metadata.json 파일이나 26편의 변환 Lambda로 문서에 메타데이터가 붙어 있어야 동작합니다. RetrievalFilter API

아래는 시설팀 문서 중 2026년 이후에 작성된 것만 찾는 명시적 필터의 구성 예시입니다. department와 doc_year는 가상의 메타데이터 이름이며, doc_year는 숫자로 저장했다고 가정합니다.

"vectorSearchConfiguration": {
"numberOfResults": 5,
"filter": {
"andAll": [
{"equals": {"key": "department", "value": "facility"}},
{"greaterThanOrEquals": {"key": "doc_year", "value": 2026}}
]
}
}

andAll은 모든 조건을, orAll은 하나 이상의 조건을 만족하는 청크를 남깁니다. 두 연산자는 조건을 2개 이상 5개 이하로 묶으며, 묶음 안에 묶음을 한 단계까지 넣을 수 있습니다. 조건이 하나뿐이면 andAll 없이 equals 같은 연산자를 filter 바로 아래에 둡니다.

주요 필터 연산자와 값 형식
연산자값 형식저장소 조건
equals·notEquals문자열·숫자·불리언공통 연산자
greaterThan·lessThan 등 범위 비교숫자공통 연산자
in·notIn문자열 목록OpenSearch Serverless와 Neptune Analytics에서 가장 잘 지원
startsWith문자열OpenSearch Serverless만 지원
stringContains·listContains문자열OpenSearch Serverless에서 가장 잘 지원, S3 Vectors는 stringContains 불가

필터를 쓰려면 저장소 쪽 조건도 맞아야 합니다. OpenSearch Serverless는 벡터 인덱스가 faiss 엔진이어야 하며, nmslib 엔진으로 만든 인덱스라면 새 인덱스를 만들고 Knowledge Base도 새로 만들어야 합니다. S3 Vectors와 관리형 Knowledge Base는 startsWith와 stringContains를 쓸 수 없습니다. 기존 Aurora 인덱스에 메타데이터를 추가할 때는 메타데이터를 한 열에 모아 저장하도록 사용자 정의 메타데이터 열을 지정하고 그 열에 인덱스를 만드는 방법이 권장됩니다.

암시적 필터는 vectorSearchConfiguration의 implicitFilterConfiguration에 메타데이터 설명과 필터를 만들 모델을 지정합니다. 공식 문서 기준으로 필터 생성은 Anthropic Claude 모델이 지원합니다.

"implicitFilterConfiguration": {
"modelArn": "FILTER_MODEL_ARN",
"metadataAttributes": [
{"key": "department", "type": "STRING", "description": "문서를 관리하는 부서. 예: facility, hr"},
{"key": "doc_year", "type": "NUMBER", "description": "문서를 작성한 연도. 예: 2026"}
]
}

FILTER_MODEL_ARN은 필터를 만들 Claude 모델의 ARN으로 바꿉니다. metadataAttributes에는 1~25개의 속성을 넣고, type은 STRING·NUMBER·BOOLEAN·STRING_LIST 중 하나입니다. 모델은 description을 보고 "올해 시설팀 안내에서 주차 등록 방법 알려줘" 같은 질문에서 부서와 연도 조건을 뽑아내므로, 설명에 값의 예시와 형식을 구체적으로 적을수록 필터가 정확해집니다.

암시적 필터를 쓰면 검색 전에 모델 호출이 한 번 더 일어나므로 응답이 늦어지고 모델 사용 비용이 늘 수 있습니다. 또 모델이 만든 조건이므로 질문 표현에 따라 달라질 수 있습니다. 따라서 사용자가 볼 수 있는 문서를 제한하는 권한 조건은 암시적 필터에 맡기지 않고, 애플리케이션이 인증된 사용자 정보로 만든 명시적 필터나 Knowledge Base 분리로 통제합니다.

03

재정렬의 역할과 비용

Knowledge Base의 기본 순위는 질문 벡터와 청크 벡터가 얼마나 가까운지(하이브리드 검색이면 원문 일치도 포함)로 정해집니다. 재정렬 모델(reranker)은 이렇게 뽑힌 후보 청크를 질문과 함께 다시 읽고 관련성 점수를 새로 계산해 순서를 바꿉니다. 재정렬 결과는 Knowledge Base의 기본 순위를 대체하며, 텍스트 데이터에만 쓸 수 있습니다. 재정렬 모델 안내

재정렬은 "넓게 찾고 좁게 고르는" 구성에 쓰입니다. 아래 구성 예시는 후보 30개를 검색한 뒤 재정렬해 상위 5개만 돌려받습니다.

"vectorSearchConfiguration": {
"numberOfResults": 30,
"rerankingConfiguration": {
"type": "BEDROCK_RERANKING_MODEL",
"bedrockRerankingConfiguration": {
"modelConfiguration": {"modelArn": "RERANK_MODEL_ARN"},
"numberOfRerankedResults": 5
}
}
}

RERANK_MODEL_ARN은 아래 표의 재정렬 모델 ARN으로 바꿉니다. numberOfResults는 재정렬에 넘길 후보 수, numberOfRerankedResults는 재정렬 후 돌려받을 수입니다. numberOfRerankedResults를 numberOfResults보다 크게 주어도 최대 numberOfResults개만 반환됩니다. 예외는 다음 섹션의 질의 분해를 함께 쓸 때로, 이때는 numberOfRerankedResults를 numberOfResults의 5배까지 줄 수 있습니다. 선택 항목인 metadataConfiguration은 재정렬할 때 메타데이터를 모두 참고할지(ALL), 고른 필드만 참고할지(SELECTIVE)를 정합니다.

재정렬 모델과 단일 리전 지원
모델모델 ID지원 리전
Amazon Rerank 1.0amazon.rerank-v1:0도쿄, 캐나다(중부), 프랑크푸르트, 오레곤
Cohere Rerank 3.5cohere.rerank-v3-5:0도쿄, 캐나다(중부), 프랑크푸르트, 버지니아 북부, 오레곤

이 표에 서울 리전은 없습니다. 서울 리전의 Knowledge Base에서 재정렬을 쓰려는 경우 만들기 전에 재정렬 지원 리전을 다시 확인하고, 지원하지 않으면 재정렬 없이 검색 개수·필터·하이브리드 검색으로 조정하거나 지원 리전에 Knowledge Base를 두는 방안을 비교합니다. 다른 리전에 둘 때는 데이터가 저장·처리되는 위치가 바뀌므로 데이터 위치 요건도 함께 검토합니다.

권한은 어느 API로 재정렬하느냐에 따라 필요한 주체가 다릅니다.

재정렬 사용 방식별 필요 권한
사용 방식호출하는 사용자·역할Knowledge Base 서비스 역할
Retrieve + 재정렬bedrock:Retrievebedrock:Rerank, 재정렬 모델에 대한 bedrock:InvokeModel
RetrieveAndGenerate + 재정렬bedrock:RetrieveAndGenerate, bedrock:Rerank, 재정렬 모델과 생성 모델에 대한 bedrock:InvokeModel추가 권한 없음
Rerank API 단독 호출bedrock:Rerank, 재정렬 모델에 대한 bedrock:InvokeModel해당 없음

Cohere Rerank처럼 외부 제공사 모델을 쓰려면 사용하는 IAM 역할에 aws-marketplace:ViewSubscriptions와 aws-marketplace:Subscribe 권한도 필요합니다. 콘솔에서 재정렬 모델을 고를 때 서비스 역할 권한을 자동으로 고칠 수 있지만, 직접 만든 사용자 정의 역할은 Bedrock이 고칠 수 없으므로 정책을 직접 추가합니다. 재정렬 권한

재정렬 요금은 쿼리 단위로 매겨지며, 쿼리 하나는 재정렬 모델을 한 번 호출하는 것으로 최대 100개의 청크를 담을 수 있습니다. 처리된 쿼리 수는 CloudWatch의 SearchUnits 지표로 확인합니다. 재정렬은 질문마다 호출이 하나 더 늘어 비용과 지연이 더해지지만, 생성 모델에 넘기는 청크를 줄이면 생성 단계의 입력 토큰은 줄어듭니다. 단가는 Bedrock 요금 페이지에서 확인합니다.

관리형 Knowledge Base는 서비스가 관리하는 재정렬이 기본으로 켜져 있습니다. managedSearchConfiguration의 rerankingModelType을 CUSTOM으로 주면 직접 고른 재정렬 모델을, NONE으로 주면 재정렬 없이 검색합니다. 서비스 관리형 재정렬(MANAGED)은 관리형 임베딩을 쓰는 Knowledge Base에서만 쓸 수 있습니다.

04

질의 변환·분해와 검색 전략

질의 분해(query decomposition)는 여러 주제가 섞인 질문을 작은 하위 질문으로 나눠 각각 검색하는 기능입니다. 예를 들어 "3층 회의실 예약 방법과 방문객 주차 등록 방법을 알려줘"라는 질문을 회의실 예약과 주차 등록에 관한 두 하위 질문으로 나누면, 한 번의 벡터 검색으로는 한쪽 주제의 청크가 밀려날 수 있는 문제를 줄일 수 있습니다. QueryTransformationConfiguration API

질의 분해는 RetrieveAndGenerate 요청의 orchestrationConfiguration에서 켭니다. 현재 지정할 수 있는 변환 유형은 QUERY_DECOMPOSITION 하나입니다.

"retrieveAndGenerateConfiguration": {
"type": "KNOWLEDGE_BASE",
"knowledgeBaseConfiguration": {
"knowledgeBaseId": "KNOWLEDGE_BASE_ID",
"modelArn": "GENERATION_MODEL_ARN",
"orchestrationConfiguration": {
"queryTransformationConfiguration": {"type": "QUERY_DECOMPOSITION"}
}
}
}

KNOWLEDGE_BASE_ID는 Knowledge Base ID로, GENERATION_MODEL_ARN은 답변을 생성할 모델의 ARN으로 바꿉니다. 이 옵션을 켜면 질문 하나에 Knowledge Base 검색이 여러 번 실행될 수 있어 지연이 늘어납니다. 단순한 질문이 대부분이라면 끄고, 여러 조건이나 비교가 들어간 질문이 많을 때 켜서 결과를 비교합니다.

Retrieve에는 질의 분해 옵션이 없습니다. Retrieve로 검색 결과를 직접 처리하는 구성(29편)에서 같은 효과가 필요하면, 애플리케이션이 질문을 나누고 하위 질문마다 Retrieve를 호출한 뒤 결과를 합치는 방식으로 설계해야 합니다. RetrieveAndGenerate의 orchestration 프롬프트도 질문을 검색어로 바꾸는 단계에 쓰이며, 프롬프트 구성은 29편에서 다룹니다.

검색 설정은 질문 유형에 따라 고릅니다. 아래 표는 증상과 먼저 시도할 설정을 짝지은 것입니다.

질문 유형별 검색 전략
질문·증상먼저 시도할 설정관련 편
오류 코드·제품 번호처럼 글자가 정확해야 하는 질문하이브리드 검색(overrideSearchType)27편
부서·연도·문서 유형 같은 조건이 붙은 질문명시적 또는 암시적 메타데이터 필터이 편 2섹션
맞는 청크가 후보에는 있지만 순위가 낮음검색 개수를 늘리고 재정렬이 편 3섹션
여러 주제가 한 질문에 섞임질의 분해이 편 4섹션
답에 필요한 내용이 여러 청크에 흩어짐검색 개수 조정, 계층적 분할 검토26편

설정은 한 번에 하나씩 바꿉니다. 여러 설정을 동시에 바꾸면 결과가 좋아지거나 나빠진 원인을 알 수 없기 때문입니다. 실제 사용자 질문과 비슷한 질문 20~30개와 각 질문에 맞는 정답 문서를 정해 두고, 설정을 바꿀 때마다 같은 질문으로 정답 문서가 몇 위에 오는지 비교하면 변경 효과를 판단할 수 있습니다.

05

검색 누락과 무관한 결과의 진단

답변이 틀렸을 때 가장 먼저 할 일은 검색과 생성을 분리해 보는 것입니다. Retrieve를 호출하거나 콘솔의 Knowledge Base 테스트 창에서 응답 생성을 끄면, 모델이 답을 만들기 전의 검색 결과를 그대로 볼 수 있습니다. 결과마다 청크 원문(content), 원본 문서 위치(location), 메타데이터(metadata), 관련성 점수(score)가 돌아옵니다. Retrieve로 검색 결과 확인

아래는 AWS SDK for Python(Boto3)으로 검색 결과의 순위·점수·위치·메타데이터만 출력하는 진단용 예시입니다. KNOWLEDGE_BASE_ID를 바꾸고, 실행하는 자격 증명에 해당 Knowledge Base에 대한 bedrock:Retrieve 권한이 있어야 합니다.

import boto3

client = boto3.client("bedrock-agent-runtime", region_name="REGION")
response = client.retrieve(
knowledgeBaseId="KNOWLEDGE_BASE_ID",
retrievalQuery={"text": "방문객 주차 등록은 어떻게 하나요?"},
retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": 20}},
)
for rank, result in enumerate(response["retrievalResults"], start=1):
print(rank, round(result.get("score", 0), 4), result.get("location"))
print(" ", result.get("metadata"))

REGION은 Knowledge Base가 있는 리전 코드로 바꿉니다. 평소보다 큰 20개를 가져오는 이유는 정답 문서가 기본 5개 밖에 있는지 확인하기 위해서입니다. 점수는 같은 설정 안에서 결과의 순서를 비교하는 데 쓰고, 재정렬이나 검색 방식을 바꾼 결과끼리 점수 값을 직접 비교하지 않습니다. 점수로 결과를 잘라 내는 기준을 정하려면 정해 둔 질문 세트로 점수 분포를 먼저 확인합니다.

검색 결과를 본 뒤에는 증상에 따라 원인을 좁힙니다.

검색 증상별 점검 순서
증상먼저 확인할 것조치
결과가 0건필터의 키 이름(대소문자 포함)과 값 형식(숫자를 문자열로 저장했는지), OpenSearch Serverless 인덱스 엔진필터 없이 같은 질문을 다시 검색해 필터 문제인지 가르기
정답 문서가 20개 안에도 없음문서가 수집되었는지(수집 작업 상태와 실패 원인), 파싱 결과에 내용이 남았는지25편의 수집 점검, 26편의 파서·분할 점검
정답 문서가 6위 이하질문과 문서의 표현 차이, 코드·고유 명사 포함 여부검색 개수 조정, 하이브리드 검색, 재정렬
무관한 문서가 상위오래된 버전·다른 부서 문서가 섞였는지, 청크가 너무 큰지메타데이터 필터, 재정렬, 분할 크기 조정
결과가 요청한 수보다 적음계층적 분할의 부모 청크 치환, numberOfRerankedResults 상한정상 동작인지 확인 후 개수 조정
검색 결과는 맞는데 답변이 틀림생성 프롬프트와 모델 설정29편의 생성 단계 점검

필터를 걸었는데 결과가 0건이라면, 메타데이터 파일을 추가하거나 고친 뒤 데이터 소스를 다시 동기화했는지도 확인합니다. .metadata.json을 고쳐도 동기화 전에는 저장소의 메타데이터가 바뀌지 않습니다. 반대로 필터를 걸었는데도 다른 부서 문서가 섞인다면, 그 문서에 메타데이터가 아예 없거나 다른 값으로 붙어 있는지 Retrieve 결과의 metadata에서 확인합니다.

반응형

Bedrock Knowledge Bases 임베딩과 벡터 저장소: 차원·인덱스·유사도 이해하기 | Bedrock 27편

반응형
Amazon Bedrock 완전 가이드 · 27편
Knowledge Bases 임베딩과 벡터 저장소

차원·인덱스·유사도 이해하기

Amazon Bedrock Knowledge Bases에서 임베딩 모델은 청크를 정해진 길이(차원)의 숫자 벡터로 바꾸고, 벡터 저장소는 이 벡터를 인덱스에 저장해 질문 벡터와 가까운 것을 찾습니다. 벡터 인덱스의 차원은 임베딩 모델의 출력 차원과 같아야 하며, 하이브리드 검색은 일부 저장소에서만 쓸 수 있습니다. 임베딩 모델과 저장소는 만든 뒤 바꾸기 어려우므로, 지원 리전·검색 방식·비용 구조를 함께 보고 고릅니다.

고객 관리형 Knowledge Base를 기준으로 임베딩 모델과 차원, 벡터 인덱스와 필드 매핑, 지원 벡터 저장소와 S3 Vectors 선택 기준, 의미 검색과 하이브리드 검색, 저장소 변경과 비용을 설명합니다. 관리형 Knowledge Base와 다른 점은 해당 위치에 따로 표시합니다.

01

임베딩 모델과 벡터 차원

임베딩 모델은 텍스트를 숫자 목록(벡터)으로 바꿉니다. 이 목록의 길이를 차원이라고 하며, 의미가 비슷한 문장일수록 벡터끼리 가깝게 놓이도록 학습되어 있습니다. 검색은 질문을 같은 모델로 벡터로 바꾼 뒤, 저장된 청크 벡터 중 가까운 것을 찾는 방식으로 이루어집니다. Knowledge Bases 지원 모델

고객 관리형 Knowledge Base의 주요 텍스트 임베딩 모델
모델벡터 형식차원서울 리전 단일 리전 지원
Amazon Titan Text Embeddings V2부동소수점, 바이너리256·512·1024 중 선택지원
Cohere Embed Multilingual v3부동소수점, 바이너리1024표에 없음
Cohere Embed English v3부동소수점, 바이너리1024표에 없음
Amazon Titan Embeddings G1 - Text부동소수점1536표에 없음

한국어 문서가 중심이라면 다국어를 지원하는 모델을 고릅니다. Titan Text Embeddings V2는 100개 이상의 언어를, Cohere Embed Multilingual v3는 이름 그대로 다국어를 지원합니다. 서울 리전에서 Knowledge Base를 만든다면, 단일 리전 지원 표 기준으로는 Titan Text Embeddings V2를 쓸 수 있고 Cohere v3 모델은 표에 없습니다. 이 표는 바뀔 수 있으므로 만들기 전에 다시 확인합니다.

차원이 클수록 의미를 더 세밀하게 담을 수 있지만, 벡터 하나가 차지하는 저장 공간과 검색 계산량도 늘어납니다. 바이너리 벡터는 차원마다 1비트만 써서 부동소수점(차원당 32비트)보다 저장 비용이 훨씬 작지만, 표현이 덜 정밀합니다. 바이너리 벡터를 쓰려면 임베딩 모델과 벡터 저장소가 모두 바이너리를 지원해야 합니다.

관리형(Bedrock Managed) Knowledge Base는 기본적으로 내장 임베딩 모델을 쓰고, 직접 고를 때는 float32 형식의 1,024차원 모델만 지정할 수 있습니다.

02

저장소·인덱스·필드 매핑

고객 관리형 Knowledge Base는 벡터 저장소 안의 인덱스(검색용 데이터 구조)에 청크를 저장합니다. OpenSearch Serverless는 콘솔에서 Knowledge Base를 만들 때 Bedrock이 저장소와 인덱스를 자동으로 만들어 주는 방법도 있습니다. 직접 만든다면 인덱스를 미리 만들고, Bedrock이 어떤 필드에 무엇을 쓸지 연결(필드 매핑)해야 합니다.

아래는 OpenSearch Serverless에 1,024차원 벡터용 인덱스를 만드는 매핑의 구성 예시입니다. 필드 이름은 가상의 예시입니다.

PUT guide-kb-index
{
"settings": {"index": {"knn": true}},
"mappings": {
"properties": {
"guide_vector": {
"type": "knn_vector",
"dimension": 1024,
"method": {"name": "hnsw", "engine": "faiss", "space_type": "l2"}
},
"guide_text": {"type": "text", "index": true},
"guide_metadata": {"type": "text", "index": false}
}
}
}

dimension은 임베딩 모델의 출력 차원과 같아야 합니다. Titan Text Embeddings V2를 512차원으로 쓰기로 했다면 여기도 512여야 하며, 차원이 맞지 않으면 벡터를 인덱스에 저장할 수 없습니다. space_type은 벡터 사이의 거리를 재는 방식(유사도 기준)입니다. Knowledge Base를 만들 때는 세 필드를 다음처럼 연결합니다.

  • 벡터 필드(vectorField): 임베딩 벡터를 저장할 필드. 예시의 guide_vector
  • 텍스트 필드(textField): 청크의 원문을 저장할 필드. 예시의 guide_text
  • 메타데이터 필드(metadataField): Bedrock이 관리하는 메타데이터를 저장할 필드. 예시의 guide_metadata

텍스트 필드를 검색 가능한(filterable) 텍스트로 만들어 두어야 4섹션의 하이브리드 검색을 쓸 수 있습니다. 예시에서 텍스트 필드에 "index": true를 준 이유입니다.

03

지원 저장소와 S3 Vectors 등의 선택 기준

고객 관리형 Knowledge Base가 연결할 수 있는 저장소는 AWS 서비스와 외부 서비스로 나뉩니다. 이미 운영 중인 저장소가 있다면 그것을 연결할 수 있는지부터 확인합니다.

벡터 저장소별 특징
저장소특징맞는 경우
Amazon OpenSearch Serverless콘솔에서 자동 생성 가능, 하이브리드 검색 지원가장 일반적인 시작점
Amazon OpenSearch Service 관리형 클러스터기존 클러스터 재사용이미 OpenSearch 클러스터를 운영 중
Amazon Aurora PostgreSQL(pgvector)SQL 데이터베이스 안에 벡터 저장, 하이브리드 검색 지원관계형 데이터와 함께 관리하고 싶을 때
Amazon S3 VectorsS3 기반 벡터 저장, 사용량 기반 비용검색량 대비 문서가 많고 비용이 중요할 때
Amazon Neptune Analytics그래프 기반 검색(GraphRAG)문서 간 관계를 따라가는 질문(30편)
Pinecone·Redis Enterprise Cloud·MongoDB Atlas외부 서비스, MongoDB는 하이브리드 검색 지원이미 해당 서비스를 쓰고 있을 때

S3 Vectors는 벡터를 S3 방식으로 저장하는 서비스로, AWS는 비용이 낮은 저장소로 소개합니다. 대신 Knowledge Base 저장소로 쓸 때 다음 제약을 알아 둬야 합니다. Knowledge Bases와 S3 Vectors

  • 메타데이터 크기: 벡터당 메타데이터는 최대 40KB이고, 이 중 필터에 쓸 수 있는 메타데이터는 2KB까지입니다.
  • 원문 저장 위치: 벡터 인덱스를 만들 때 청크 원문(AMAZON_BEDROCK_TEXT)과 Bedrock이 관리하는 메타데이터(AMAZON_BEDROCK_METADATA)를 필터 불가 메타데이터 키로 지정해야 큰 청크와 필터용 메타데이터를 함께 담을 수 있습니다. 표가 있는 문서에서 붙는 표 전체·페이지 전체 맥락(26편)도 메타데이터 크기에 포함됩니다. 이 키 목록은 인덱스를 만든 뒤 바꿀 수 없습니다.
  • 분할 방식: 메타데이터 크기 한도 때문에 계층적 분할은 권장되지 않습니다(26편).
  • 검색 방식: 하이브리드 검색 지원 저장소에 포함되지 않아 의미 검색만 쓸 수 있습니다.
04

의미 검색과 하이브리드 검색

의미 검색(semantic search)은 벡터만 비교합니다. "휴게실 쓸 수 있는 시간"으로 물어도 "운영 시간"이 적힌 문서를 찾을 수 있다는 장점이 있지만, 오류 코드나 제품 번호처럼 글자가 정확히 같아야 하는 검색에는 약할 수 있습니다. 하이브리드 검색은 벡터 비교에 원문 텍스트 검색을 더해 두 장점을 함께 노립니다. 예를 들어 "출입증 오류 E-1024"처럼 코드가 들어간 질문에서 해당 코드가 적힌 안내문을 찾는 데 도움이 됩니다.

하이브리드 검색은 Amazon RDS(Aurora PostgreSQL), OpenSearch Serverless, MongoDB 저장소 중 검색 가능한 텍스트 필드가 있는 경우에만 쓸 수 있습니다. 조건이 맞지 않으면 요청해도 의미 검색으로 동작합니다. 검색 방식 설정

검색 방식은 Retrieve나 RetrieveAndGenerate 요청의 검색 설정에서 정합니다. 값을 지정하지 않으면 Bedrock이 저장소 구성에 맞는 방식을 고릅니다.

"retrievalConfiguration": {
"vectorSearchConfiguration": {
"overrideSearchType": "HYBRID"
}
}

HYBRID는 벡터와 원문을 함께, SEMANTIC은 벡터만 검색합니다. 어느 쪽이 나은지는 질문 유형에 달려 있으므로, 코드나 고유 명사가 들어간 질문과 일반 문장 질문을 섞어 두 방식을 비교해 봅니다. 검색 개수와 필터 등 나머지 검색 설정은 28편에서 다룹니다.

05

저장소 변경·재수집·비용 고려사항

임베딩 모델이나 벡터 저장소를 바꾸는 일은 사실상 Knowledge Base를 새로 만드는 일입니다. 모델이 바뀌면 기존 벡터와 새 질문 벡터를 비교할 수 없고, 저장소가 바뀌면 벡터를 옮길 곳이 달라지므로 모든 문서를 다시 수집해야 합니다. 관리형 Knowledge Base도 내장 임베딩과 직접 지정한 임베딩 사이를 바꾸려면 새로 만들어야 합니다.

비용은 세 부분으로 나눠 봅니다.

임베딩과 저장소 관련 비용
비용 항목발생 시점줄이는 방법
임베딩 모델 호출수집할 때(청크마다), 검색할 때(질문마다)증분 동기화 유지, 불필요한 재수집 피하기
저장소 저장 비용보관하는 동안 계속차원 축소나 바이너리 벡터 검토, 오래된 문서 정리
저장소 검색·운영 비용검색 요청과 저장소 가동검색량에 맞는 저장소 선택

저장소마다 과금 구조가 다르다는 점이 중요합니다. 검색이 적고 문서가 많다면 사용량에 따라 내는 저장소가 유리할 수 있고, 검색이 많고 하이브리드 검색이 필요하다면 그에 맞는 저장소가 필요합니다. 고르기 전에 예상 문서 수, 하루 질문 수, 하이브리드 검색 필요 여부를 정리하고 각 저장소의 요금 페이지로 계산해 봅니다.

Knowledge Base를 삭제해도 직접 만든 벡터 저장소는 남아 비용이 계속 나갈 수 있습니다(25편). 시험용으로 만든 저장소는 시험이 끝나면 따로 정리합니다.

반응형