문제

HTTPS 엔드포인트에 명령을 날렸더니 — 헬스 체크, API 호출, openssl s_client, 배포 스크립트의 curl — 이런 형태로 죽는다:

verify error:num=20:unable to get local issuer certificate

openssl s_client에선 같은 게 맨 아래에 이렇게 나온다:

Verify return code: 20 (unable to get local issuer certificate)

curl에선 이렇게 감싸진다:

curl: (60) SSL certificate problem: unable to get local issuer certificate

같은 근본 원인, 세 가지 다른 겉모습. 모두를 헷갈리게 하는 단어는 local이다: “내 로컬 머신의 문제”라는 뜻이 아니다. OpenSSL이 로컬에서 가진 모든 것 — 서버가 보낸 인증서와 자신이 아는 신뢰 앵커 — 을 보고도 체인을 계속 쌓는 데 필요한 발급자 인증서를 못 찾았다는 뜻이다. 그 구멍은 서버 쪽일 수도 내 쪽일 수도 있고, 어느 쪽이냐에 따라 해법이 완전히 갈린다.

증상

  • openssl verify·openssl s_client·curl·wget·런타임(Python requests, Node, Java, Go)·모니터링 프로브가 HTTPS 호스트에 대해 error 20 / curl 60을 보고한다.
  • 같은 URL이 웹 브라우저에선 유효한 자물쇠로 멀쩡히 열리는 경우가 많다.
  • 모든 곳에서 실패할 수도, 특정 머신이나 컨테이너 하나에서만 실패할 수도 있다.

실제로 무슨 뜻인가

TLS 인증서는 체인의 맨 아래다. 리프 인증서(당신 도메인용)는 중간 CA가 서명하고, 그 중간은 — 직접 또는 더 많은 중간을 거쳐 — 루트가 서명한다. 클라이언트는 루트가 신뢰 저장소에 있으니 루트를 믿는다. 하지만 당신의 특정 중간은 들고 다니지 않는다 — 수백 개이고 교체된다. 그래서 리프를 검증하려면 클라이언트가 리프에서 이미 신뢰하는 루트까지 끊김 없는 경로를 세워야 하고, 루트만 빼고 모든 링크가 필요하다.

error 20은 OpenSSL이 이렇게 말하는 것이다: 인증서 하나에 닿았고, 계속하려면 그 발급자가 필요한데, 그 발급자가 보이는 어디에도 없다. 체인이 당신이 제시한 리프와 클라이언트가 신뢰하는 루트 사이 어딘가에서 끊겼다. 남은 질문은 어느 끝이 링크를 떨어뜨렸는가뿐이다.

(형제 메시지인 **error 21, “unable to verify the first certificate”**는 구멍이 맨 앞에 있다 — 리프만 제시되고 발급자가 아예 없다. **error 2, “unable to get issuer certificate”**는 ‘신뢰 인증서 목록이 완전하지 않다’는 밀접한 경우다. OpenSSL 자체 문서도 2와 20의 세부 문구를 역사적으로 혼동해왔으니 구분을 과하게 읽지 말 것 — 셋 다 실무상 같은 걸 뜻한다: 신뢰 루트까지의 경로가 완성되지 않았다.)

갈림길: 서버가 너무 적게 보냈나 vs 내 머신이 너무 적게 신뢰하나

원인은 둘이고, 명령 하나가 그 둘을 가른다.

서버가 불완전한 체인을 보냈다(서버 쪽). 압도적으로 흔한 원인. 서버가 리프 인증서만 설정하고 중간을 빠뜨린 것 — nginx에선 ssl_certificate를 fullchain.pem이 아니라 맨 cert.pem에 가리킨 경우다. 서버가 클라이언트에게 받은 적 없는 기관이 서명한 리프를 건네고, 클라이언트는 간극을 못 메워 error 20이 난다. 이 서버는 명백히 잘못 설정된 것이다 — 브라우저가 빠진 중간을 AIA로 조용히 가져와 덮어주니 멀쩡해 보일 뿐이다. 엄격한 클라이언트는 어느 머신에서든 똑같이 실패한다.

내 신뢰 저장소가 비었거나 낡았다(클라이언트 쪽). OpenSSL은 자체 신뢰 앵커를 전혀 내장하지 않는다 — 운영체제의 CA 번들에 의존하고, 그 번들은 배포판이 설치해 OpenSSL이 가리키게 한다. 그 번들이 없거나 비었거나 낡았거나, ca-certificates를 설치한 적 없는 최소 컨테이너(Alpine, scratch, 슬림 베이스 이미지) 안이라면, 제대로 체인이 걸린 완벽히 유효한 공개 인증서도 error 20으로 실패한다. 이때 신호는 이 환경에서만 실패하고 같은 호스트가 평범한 데스크톱에선 멀쩡히 검증된다는 점이다. 기업의 TLS 가로채기 프록시도 같은 걸 만든다: 내 머신이 신뢰 안 하는 내부 루트로 트래픽을 재서명한다.

결정하는 명령 하나

openssl s_client -connect example.com:443 -showcerts

-showcerts는 서버가 보낸 인증서를, 보낸 순서 그대로 정확히 출력한다 — 문서도 이게 검증된 체인이 아니라 그냥 선을 타고 내려온 것일 뿐이라고 명시한다. 개수를 세라:

  • 인증서 하나뿐(리프만)? 서버가 불완전한 체인을 보내는 것. 서버 쪽이다. 리프 + 중간을 서빙하도록 서버를 고쳐라.
  • 풀 체인(리프, 그다음 중간 하나 이상)인데 그래도 Verify return code: 20? 인증서는 멀쩡하다, 내 신뢰 저장소가 문제다. 클라이언트 쪽이다. CA를 고치거나 가리켜라.

이 구분 하나가, 범인이 내 컨테이너일 때 서버를 ‘고치는’ 일이나, 서버가 애초에 중간을 안 보냈을 때 CA 번들을 재설치하는 일에서 구해준다.

DechoNet으로 진단

  • SSL 점검은 바깥에서 실제 TLS 핸드셰이크를 수행해 호스트가 실제로 서빙하는 인증서 체인을 보고한다 — 내 머신 신뢰 저장소와 무관하게, 서버가 중간을 제시하는지 리프에서 멈추는지 확인할 수 있다. 점검은 완전하고 유효한 체인을 보여주는데 로컬 도구가 여전히 error 20이면 문제는 내 쪽이고, 체인이 불완전하면 서버 쪽이다.
  • HTTP 점검은 독립된 지점에서 HTTPS 엔드포인트가 요청을 완료하는지 확인해, ‘TLS 설정 전체가 모두에게 깨졌다’와 ‘내 환경만 거부한다’를 가른다.

해결 체크리스트

  • openssl s_client -connect host:443 -showcerts를 돌려 서버가 보낸 인증서 개수를 세라.
  • 서버 쪽(리프만 왔다): 풀 체인을 서빙하도록 서버 재설정 — nginx에선 ssl_certificate가 cert.pem이 아니라 fullchain.pem(리프 + 중간)을 가리켜야 한다. 리로드 후 재확인.
  • 클라이언트 쪽(풀 체인이 왔는데도 error 20): 시스템 CA 번들(ca-certificates) 설치·갱신, 특히 최소 컨테이너에서. 또는 -CAfile chain.pem / -CApath로 OpenSSL이 신뢰 저장소를 가리키게 하라.
  • 기업 TLS 가로채기 프록시 뒤라면 검증을 끄지 말고 그 내부 루트를 신뢰 저장소에 추가하라.
  • openssl verify -untrusted intermediate.pem -CAfile root.pem leaf.pem으로 수정 확인, 또는 s_client 재실행해 Verify return code: 0 (ok) 확인.
  • -k / --insecure나 NODE_TLS_REJECT_UNAUTHORIZED=0으로 덮지 말 것; 빠진 발급자를 공급하지 않고 검증만 끈다.

에스컬레이션 시점

  • 서버가 불완전한 체인을 보내는데 내가 통제하지 못하는 경우 — 서드파티 API, 벤더 엔드포인트 — 고칠 사람은 그쪽이다. 리프만 나온 s_client -showcerts 출력을 보내라; 명백한 증거다.
  • 체인이 완전하고 신뢰 저장소도 최신인데 그래도 한 환경에서만 실패하면, openssl s_client -connect host:443 -showcerts -CAfile <bundle>을 캡처해 되는 머신과 안 되는 머신의 신뢰 저장소를 비교해 빠졌거나 남는 루트를 찾아라.
  • 근본 문제가 끊긴 링크 체인이 아니라 진짜로 신뢰되지 않거나 self-signed인 발급자라면 다른 오류 패밀리다 — 신뢰 앵커 대 체인의 구분은 NET::ERR_CERT_AUTHORITY_INVALID와 curl: (60) SSL certificate problem을 보라.

내 도메인에서 바로 확인

무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.