OTel → VictoriaLogs 멀티클러스터 HTTPS: TLS 에러와 인증서(ca·cert·crt·pfx) 총정리

다른 클러스터의 OTel Gateway가 중앙(mgmt) VictoriaLogs로 로그를 HTTPS로 보내기 시작하면 TLS 에러가 줄줄이 납니다. 대부분은 “TLS를 두 곳에서 종료해 프로토콜이 꼬였거나”, “사설 CA를 클라이언트가 신뢰하지 않아서“입니다. 이 글은 실제로 겪은 에러 메시지 5종을 증상→원인→해결로 짚고, 그 과정에서 정리하게 된 인증서 개념(ca·cert·crt 차이, .pfx 변환)을 openssl 실습과 함께 다룹니다. 같은 시리즈의 멀티클러스터 중앙집중편에서 세운 구조에 HTTPS를 입히는 후속편입니다.

전제: 멀티클러스터(dev/stg/prod = 로그 생성, mgmt = 중앙 VictoriaLogs cluster), 각 클러스터 OTel Gateway → mgmt 앞단 Contour(Gateway API 구현체) → vmauth, 폐쇄망 + 사내 사설 CA(공인 CA 아님).


🎯 1. 상황: 다른 클러스터에서 mgmt VictoriaLogs로 HTTPS 전송 #

각 애플리케이션 클러스터(dev/stg/prod)의 OTel Gateway가 로그를 mgmt 클러스터로 보냅니다. mgmt 앞단에는 Contour가 있어 외부 진입을 받고, 그 뒤로 vmauth가 적재/조회를 라우팅합니다. 클러스터 내부 통신이던 것을 클러스터 경계를 넘겨 HTTPS로 바꾸는 순간, 새로 세 가지가 필요해집니다 — 노출·TLS·인증. 이 글은 그중 TLS/인증서에서 막힌 지점들입니다.

핵심 난관은 두 부류입니다. ① TLS를 어디서 종료하느냐(프로토콜 꼬임)와 ② 사설 CA를 클라이언트가 신뢰하게 만드는 법(인증서 체인). 순서대로 풀어봅니다.


🧩 2. 헷갈리는 용어부터: ca vs cert vs crt #

세 단어가 비슷해 보이지만 같은 층위가 아닙니다.

  • crt파일 확장자일 뿐입니다(.crt·.pem·.cer). “인증서를 담은 파일"이라는 뜻이지 내용물 종류를 지정하지 않습니다. 서버 인증서든 CA 인증서든 .crt 파일에 담길 수 있습니다.
  • cert(certificate) — 인증서 일반을 가리킵니다. 문맥상 보통 서버 인증서를 뜻할 때가 많습니다.
  • ca(Certificate Authority) — 다른 인증서를 발급·서명한 상위 인증서입니다. 서버 인증서가 진짜인지 검증하는 기준이 됩니다.

한 줄로 정리하면, crt는 “그릇(파일)”, ca·cert는 “내용물의 종류"입니다. crt = ca가 아니라 “ca 인증서가 crt 파일에 담긴다“가 맞습니다.

인증서 체인이란? #

TLS 인증서는 보통 루트 CA → (중간 CA) → 서버 인증서의 체인 구조입니다. 서버 인증서는 서버가 “나 이 도메인 맞다"고 제시하는 것이고, CA 인증서는 그 서버 인증서를 발급·서명한 상위입니다. 클라이언트는 서버 인증서를 받아 → 그것을 발급한 CA를 신뢰하는지 확인 → 신뢰하면 통과시킵니다.

flowchart TB
    subgraph CHAIN["인증서 체인 (발급 계층)"]
        direction TB
        ROOT["루트 CA<br/>(최상위, 자체 서명)"]
        INTER["중간 CA<br/>(Issuing CA)"]
        SERVER["서버 인증서<br/>(도메인용)"]
        ROOT -->|"서명"| INTER
        INTER -->|"서명"| SERVER
    end
    CLIENT["클라이언트<br/>(OTel Gateway)"]
    SERVER -.->|"① 서버가 인증서 제시"| CLIENT
    CLIENT -->|"② 발급 CA를 신뢰하는가?"| CHECK{"CA 보유?"}
    CHECK -->|"예 → 검증 통과"| OK["연결 성립"]
    CHECK -->|"아니오 → x509 unknown authority"| FAIL["연결 거부"]

⚠️ 중간 CA만 있으면 검증에 실패할 수 있습니다. 신뢰 사슬을 완성하려면 루트까지 필요할 수 있으니, CA 파일에는 체인 전체(중간+루트)를 담는 게 안전합니다(6번에서 확인법을 다룹니다).


🔀 3. 첫 번째 벽: “Client sent an HTTP request to an HTTPS server” #

가장 먼저 만난 에러입니다.

1Client sent an HTTP request to an HTTPS server

의미: 요청은 http인데 받는 쪽은 https를 기다립니다. 프로토콜 불일치입니다.

원인: TLS가 두 곳(Contour + vmauth)에 이중으로 걸려 꼬였습니다. Contour가 TLS를 종료한 뒤 내부로는 http를 보내는데, vmauth가 tls: true라 https를 기대하니 거부합니다.

해결: TLS는 경로상 한 곳에서만 종료합니다. Contour가 이미 TLS를 처리하면 vmauth의 TLS는 끄고(tls: false) Contour에 맡깁니다. 즉 클라이언트→Contour는 https, Contour→vmauth는 http입니다.

flowchart LR
    subgraph BAD["❌ 이중 TLS (충돌)"]
        direction LR
        C1["클라이언트"] -->|"https"| CT1["Contour<br/>(TLS 종료)"]
        CT1 -->|"http"| VM1["vmauth<br/>tls: true<br/>(https 기대)"]
        VM1 -.->|"Client sent HTTP<br/>to HTTPS server"| ERR1["에러"]
    end
    subgraph GOOD["✅ 한 곳에서만 종료"]
        direction LR
        C2["클라이언트"] -->|"https"| CT2["Contour<br/>(TLS 종료, 인증서)"]
        CT2 -->|"http"| VM2["vmauth<br/>tls: false"]
        VM2 -->|"정상"| OK2["로그 적재"]
    end
1# Contour가 TLS를 종료하면 vmauth는 http로
2vmauth:
3  http:
4    tls: false

⚠️ 반대로 vmauth의 tls를 켜면(true) 기존에 http로 붙던 것들(내부 Gateway·Grafana·기존 클라이언트)이 전부 https로 바뀌어야 끊기지 않습니다. 그래서 진입점(Contour) 한 곳으로 TLS를 일원화하는 편이 관리가 쉽습니다. 참고로 반대 방향 불일치 에러 http: server gave HTTP response to HTTPS client는 “클라이언트는 https인데 서버가 http로 응답"한 경우이며, 목적지의 실제 프로토콜에 endpoint를 맞추면 됩니다.


🔒 4. 두 번째 벽: “x509: certificate signed by unknown authority” #

프로토콜을 맞췄더니 이번엔 인증서 신뢰 문제입니다.

1x509: certificate signed by unknown authority

의미: 클라이언트가 서버 인증서를 발급한 CA를 신뢰하지 않습니다. 공인 CA가 아닌 사설 CA일 때 흔합니다(시스템 기본 신뢰 스토어에 없으니까요).

해결: 서버 인증서를 발급한 CA 인증서를 클라이언트에 넣어 신뢰하게 만듭니다. 여기서 중요한 건 서버 인증서 전체가 아니라 “발급 CA"만 넣는다는 점입니다(이유는 8번). 클라이언트(OTel Gateway) exporter의 tls.ca_file로 그 CA를 지정합니다.

💡 원인 격리 팁: “경로·프로토콜은 맞는데 CA 신뢰만 문제"인지 빠르게 확인하려면 임시로 검증을 건너뛰어 봅니다. 연결이 되면 원인은 CA 신뢰가 맞습니다.

1tls:
2  insecure_skip_verify: true   # ⚠️ 원인 격리용. 운영 금지(중간자 공격 취약), CA 확보 후 제거

📁 5. 세 번째 벽: “no such file: /etc/certs/ca.crt” #

CA를 지정했더니 exporter가 아예 시작을 못 합니다.

1failed to load CA CertPool File: ... open /etc/certs/ca.crt: no such file or directory

의미: exporter 설정의 ca_file 경로에 파일이 실제로 없습니다. 지정만 하고 마운트를 안 했을 때 납니다.

원인/해결: CA 인증서를 secret으로 만들어 그 경로에 실제로 마운트해야 합니다. 이때 자주 헷갈리는 게 volume 이름 / mountPath / secret key의 관계입니다.

  • 최종 파일 경로 = mountPath + secret의 key 이름입니다. volume 이름은 경로와 무관한 내부 식별용입니다.
  • secret 생성 시 --from-file=ca.crt=./ca.crt왼쪽(ca.crt)이 key = 파일명이 됩니다. 이 key 이름과 ca_file의 파일명이 일치해야 합니다.
flowchart LR
    subgraph SECRET["Secret victoria-ca"]
        KEY["key: ca.crt<br/>(= 파일명)"]
    end
    subgraph VOL["Volume 정의"]
        VNAME["name: victoria-ca<br/>(경로와 무관,<br/>내부 식별용)"]
    end
    subgraph MOUNT["VolumeMount"]
        MP["mountPath: /etc/certs"]
    end
    RESULT["최종 파일 경로<br/>/etc/certs/ca.crt"]
    SECRET -->|"secretName 참조"| VOL
    VOL -->|"name 매칭"| MOUNT
    MP -->|"mountPath +"| RESULT
    KEY -->|"secret key"| RESULT
    RESULT -->|"ca_file 로 지정"| EXP["otlphttp exporter<br/>tls.ca_file"]
1# CA secret 생성 (각 클러스터마다) — 왼쪽 ca.crt가 key=파일명
2kubectl -n <ns> create secret generic victoria-ca --from-file=ca.crt=./ca.crt
 1extraVolumes:
 2  - name: victoria-ca            # volume 이름(경로와 무관, 내부 식별용)
 3    secret:
 4      secretName: victoria-ca    # 위에서 만든 CA secret
 5extraVolumeMounts:
 6  - name: victoria-ca
 7    mountPath: /etc/certs        # 이 폴더에 secret의 key들이 파일로 펼쳐짐
 8    readOnly: true
 9
10config:
11  exporters:
12    otlphttp/victorialogs:
13      endpoint: https://<vlc-domain>/insert/opentelemetry   # endpoint 키면 /v1/logs 자동 부착
14      tls:
15        ca_file: /etc/certs/ca.crt    # = mountPath + secret key
16      headers:
17        VL-Stream-Fields: "env,k8s.namespace.name"
1# 마운트 확인
2kubectl -n <ns> exec <gateway-pod> -- ls -l /etc/certs/
3kubectl -n <ns> exec <gateway-pod> -- cat /etc/certs/ca.crt | head -1   # -----BEGIN CERTIFICATE-----
4kubectl -n <ns> get secret victoria-ca -o jsonpath='{.data}' | tr ',' '\n'   # 실제 key 이름 확인

💡 SAN 불일치 에러 x509: certificate is valid for X, not Y인증서의 도메인(SAN)과 접속 주소가 다를 때 납니다. 올바른 도메인으로 접속하거나, 인증서에 해당 주소를 SAN으로 추가하세요(확인법은 6번).


🔑 6. .pfx에서 필요한 것만 뽑기 (openssl) #

사내에서 인증서를 .pfx(PKCS#12) 하나로 받는 경우가 많습니다. .pfx는 여러 요소를 한 봉투에 담은 형식입니다 — 개인키 + 서버 인증서 + (CA 체인이 포함될 수도). 필요한 것만 openssl로 분리해 씁니다.

 1# 0) 봉투 안에 뭐가 들었는지 먼저 확인 (서버 + CA 체인이 몇 개인지)
 2openssl pkcs12 -in cert.pfx -nokeys -info
 3
 4# 1) 개인키 추출 (-nodes: 암호화 안 함. k8s secret에 넣을 땐 보통 이 형태)
 5openssl pkcs12 -in cert.pfx -nocerts -out tls.key -nodes
 6
 7# 2) 서버 인증서만 (-clcerts: 최종 엔티티 인증서)
 8openssl pkcs12 -in cert.pfx -clcerts -nokeys -out tls.crt
 9
10# 3) CA 인증서(체인)만  ← 클라이언트 검증용
11openssl pkcs12 -in cert.pfx -cacerts -nokeys -out ca.crt

CA 파일에는 CA만 담겨야 합니다(개인키·서버 인증서가 섞이면 안 됩니다). 그래서 -cacerts -nokeys로 뽑습니다. 그다음 체인이 완전한지 확인합니다.

1# CA 체인 개수 확인
2grep -c "BEGIN CERTIFICATE" ca.crt
3# 2 이상: 중간+루트 포함(좋음)
4# 1: 중간 CA만 → 루트 CA를 별도로 이어붙여야 검증될 수 있음

같은 CA로 발급됐는지, 접속 주소·유효기간·SAN이 맞는지도 openssl로 확인합니다.

 1# 서버가 제시하는 발급 CA(issuer) 확인
 2echo | openssl s_client -connect <domain>:443 2>/dev/null | openssl x509 -noout -issuer
 3
 4# secret 안 인증서의 발급 CA 확인 (issuer가 같으면 같은 CA → 하나로 공용 가능)
 5kubectl -n <ns> get secret <tls-secret> -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -issuer
 6
 7# 서버가 제시하는 전체 체인 뽑기 (pfx가 없을 때 대안)
 8openssl s_client -connect <domain>:443 -showcerts </dev/null 2>/dev/null
 9
10# 인증서 내용/유효기간/SAN
11openssl x509 -in tls.crt -noout -text        # 전체
12openssl x509 -in tls.crt -noout -dates       # 유효기간
13openssl x509 -in tls.crt -noout -ext subjectAltName   # SAN(도메인)

🗂️ 7. 서버 인증서 secret과 CA secret은 다르다 #

여기서 많이 실수합니다. 두 secret은 내용도 용도도 다릅니다.

구분서버 인증서 secretCA secret
내용tls.crt + tls.keyca.crt만 (개인키 없음)
타입kubernetes.io/tlsgeneric(Opaque)
용도그 클러스터가 서버로 제시클라이언트가 서버를 검증

기존 서버 tls secret의 이름만 ca.crt로 바꾼다고 CA가 되지 않습니다 — 내용물이 서버 인증서지 CA가 아니기 때문입니다. CA를 담은 새 secret을 따로 만들어야 하고, 둘은 공존합니다(서버용 유지 + CA용 추가). 그리고 secret은 클러스터 간 공유가 안 되므로 각 클러스터마다 별도로 생성해야 합니다.


♻️ 8. 관리 포인트 줄이기: CA만, 그리고 재사용 #

왜 서버 인증서 전체가 아니라 CA만 넣을까요? 갱신에 강하기 때문입니다.

  • CA는 서버 인증서보다 훨씬 오래 유효합니다(루트 CA는 수년~십수년). 서버 인증서가 갱신돼도 같은 CA로 재발급되면 클라이언트의 CA는 그대로 둬도 됩니다.
  • 반대로 서버 인증서(tls.crt+tls.key)를 클라이언트에 복사해두면, 갱신 때마다 모든 클라이언트를 다 바꿔야 합니다. 그래서 비권장입니다.
  • 여러 서버가 같은 사내 루트 CA로 발급됐다면, CA 하나로 공용할 수 있습니다(6번의 issuer 비교로 확인).
시나리오클라이언트 CA 변경?
서버 인증서 갱신(같은 CA로 재발급)불필요 — 그대로
발급 CA 자체가 교체됨필요 — 새 CA로 교체

💡 규모가 커지면 사내 루트 CA를 노드 신뢰 스토어에 배포하거나, trust-manager 같은 도구로 CA 번들을 네임스페이스에 자동 배포(GitOps)하면 클러스터마다 수동으로 secret 만드는 부담을 줄일 수 있습니다.


✅ 9. 정리: 에러별 원인·해결 요약표 #

에러 메시지원인해결
Client sent an HTTP request to an HTTPS server이중 TLS(Contour+vmauth) 프로토콜 꼬임TLS는 한 곳만 종료. vmauth tls: false
http: server gave HTTP response to HTTPS client반대 방향 프로토콜 불일치endpoint를 목적지 실제 프로토콜에 맞춤
x509: certificate signed by unknown authority사설 CA를 클라이언트가 불신발급 CAca_file로 넣어 신뢰
no such file: /etc/certs/ca.crtca_file 경로에 파일 미마운트CA secret 생성 + mountPath에 마운트(경로 = mountPath+key)
x509: certificate is valid for X, not Y인증서 SAN과 접속 주소 불일치올바른 도메인 접속 또는 SAN 추가

핵심 원칙 두 가지만 기억하면 됩니다 — TLS는 경로상 한 곳에서만 종료하고, 클라이언트에는 서버 인증서가 아니라 발급 CA(체인 전체)만 넣습니다.


❓ 자주 묻는 질문 #

Q. 클라이언트에 서버 인증서(tls.crt)를 넣으면 안 되나요? 동작은 할 수 있지만 비권장입니다. 서버 인증서가 갱신될 때마다 모든 클라이언트를 바꿔야 합니다. 발급 CA만 넣으면 같은 CA로 재발급되는 한 클라이언트는 손댈 필요가 없습니다.

Q. .crt면 CA인가요? 아니요. .crt는 파일 확장자일 뿐입니다. 서버 인증서도 CA 인증서도 .crt에 담깁니다. 내용물이 무엇인지는 openssl x509 -in file.crt -noout -text로 확인하세요.

Q. ca.crt에 인증서가 1개만 보이는데 검증이 안 됩니다. 중간 CA만 있고 루트가 빠졌을 수 있습니다(grep -c "BEGIN CERTIFICATE"로 개수 확인). 루트 CA를 이어붙여 체인 전체를 담으세요.

Q. insecure_skip_verify: true로 두면 안 되나요? 원인 격리용 임시로만 쓰고 운영에서는 금지입니다. 인증서 검증을 건너뛰어 중간자 공격에 취약해집니다. CA를 확보하면 반드시 제거하세요.


📚 참고 #