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

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

작성 Haneul Seo2026년 9월 19일 업데이트13 min

안녕하세요, BlueByte입니다. 노트북에서는 잘 되던 curl https://…가 빌드 에이전트나 컨테이너 안, 혹은 사무실 네트워크에서 curl: (60) SSL certificate problem: unable to get local issuer certificate로 실패합니다. 서버는 살아 있고 브라우저는 같은 URL을 경고 없이 열어 주는데, curl만 핸드셰이크를 거부합니다. 오늘은 이 메시지가 뜻하는 것, 빠진 발급자(issuer)가 숨어 있을 수 있는 네 곳, 그 넷을 갈라 주는 명령 두 개, 원인별 해결, 그리고 다음 러너가 같은 이유로 멈추지 않게 하는 방법까지 하나씩 짚어보겠습니다.

"local issuer"가 뜻하는 것, 그리고 종료 코드 60을 공유하는 네 가지 메시지

curl은 TLS로 한 바이트라도 전송하기 전에, 매뉴얼 표현 그대로 인증서에 URL의 호스트 이름과 일치하는 이름이 들어 있는지, 그리고 인증서 저장소(cert store)에 있는 CA 인증서로 서명되었는지 확인합니다. 저장소란 curl이 빌드될 때 넣어졌거나 실행 시 지정된 신뢰 루트 인증서 모음입니다. 오류 60은 이 두 번째 검사가 실패한 것이고, 뒤에 붙는 문구는 TLS 백엔드(대부분의 Linux 빌드에서는 OpenSSL)가 어느 고리가 끊어졌는지 알려 주는 부분입니다:

curl: (60) SSL certificate problem: unable to get local issuer certificate
curl: (60) SSL certificate problem: self-signed certificate in certificate chain
curl: (60) SSL certificate problem: self-signed certificate
curl: (60) SSL certificate problem: certificate has expired

"unable to get local issuer certificate"는 curl이 서버가 보낸 체인을 따라가다가, 발급자를 자기 저장소에서 찾을 수 없는 인증서에 닿아 멈췄다는 뜻입니다. 네 가지 모두 종료 코드는 60 — Peer certificate cannot be authenticated with known CA certificates — 이라서, 서로를 구분해 주는 것은 문구뿐입니다.

발급자가 사라지는 네 곳

서버가 불완전한 체인을 보냅니다. 사이트 인증서는 중간 CA가 서명하고, 중간 CA는 여러분 저장소에 있는 루트가 서명합니다. 서버는 자기 인증서와 함께 중간 인증서도 보내야 하는데, 리프(leaf)만 보내면 curl은 리프에서 루트까지 이어 줄 방법이 없습니다. 브라우저는 빠진 중간 인증서를 스스로 받아 오기 때문에 이 실수를 가려 주지만, curl과 OpenSSL은 그러지 않습니다. "Chrome에서는 되는데 curl에서는 안 된다"가 전형적인 신호입니다.

TLS 검사 프록시가 사이트를 다시 서명합니다. HTTPS를 복호화하는 방화벽은 회사 자체 CA가 발급한 인증서를 내밉니다. 관리되는 노트북은 IT가 그 CA를 OS 저장소에 넣어 두었기 때문에 신뢰하지만, 컨테이너나 CI 러너, 새로 만든 VM 안의 curl은 그 CA를 본 적이 없습니다.

curl이 여러분 생각과 다른 저장소를 읽고 있습니다. CURL_CA_BUNDLE, SSL_CERT_FILE, SSL_CERT_DIR이 루트가 빠진 파일을 가리키거나, conda·Git for Windows·벤더 툴체인에 딸린 curl이 자체 번들을 쓰거나, Windows에서는 curl.exe가 실행 파일 옆·현재 디렉터리·%PATH% 어딘가에 있는 curl-ca-bundle.crt를 집어 쓰는 경우입니다.

저장소가 오래됐습니다. 베이스 이미지나 오래 살아 있는 VM의 ca-certificates 패키지가 사이트가 이제 체인을 잇는 루트보다 오래된 경우입니다. 메시지는 같고 프록시는 없습니다.

원인을 갈라 주는 명령 두 개

먼저 curl에 어느 저장소를 썼는지 물어봅니다. -v를 붙이면 OpenSSL 백엔드는 실패 직전에 파일과 디렉터리를 찍어 줍니다:

curl -v https://incomplete-chain.badssl.com/ -o /dev/null 2>&1 | grep -E 'CAfile|CApath|certificate problem'
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: /etc/ssl/certs
* SSL certificate problem: unable to get local issuer certificate

CAfile이 기대한 경로가 아니라면 바로 해결 3으로 가시면 됩니다. 아니라면 서버가 실제로 무엇을 보냈는지 봅니다:

echo | openssl s_client -connect incomplete-chain.badssl.com:443 -servername incomplete-chain.badssl.com 2>/dev/null | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
 0 s:CN = *.badssl.com
   i:C = US, O = Let's Encrypt, CN = …
    Verify return code: 21 (unable to verify the first certificate)

깊이 0에 인증서 하나뿐이고 깊이 1이 없다면 중간 인증서가 빠진 것 — 첫 번째 원인입니다. 발급자가 공개 CA가 아니라 회사 이름이라면 프록시가 다시 서명한 것 — 두 번째 원인입니다. 공개 CA의 완전한 체인인데도 실패한다면 curl이 읽는 저장소에 루트가 없는 것 — 세 번째나 네 번째 원인입니다.

해결 1: 서버가 리프만 보내는 경우

진짜 해결은 서버 쪽입니다. 중간 인증서가 포함된 파일(certbot의 fullchain.pem, 혹은 CA가 제공하는 번들)을 서빙하도록 바꾸고 reload합니다. 서버가 남의 것이고 오늘 당장 전송이 되어야 한다면, 리프의 Authority Information Access 확장에 적힌 URL에서 빠진 발급자를 받아 번들 사본에 덧붙이고 --cacert로 넘겨 주면 됩니다:

HOST=incomplete-chain.badssl.com
echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -outform PEM > leaf.pem
AIA=$(openssl x509 -in leaf.pem -noout -ext authorityInfoAccess | grep -oE 'http[^ ]+' | grep -vi ocsp | head -1)
curl -sS "$AIA" | openssl x509 -inform DER -out issuer.pem
cat /etc/ssl/certs/ca-certificates.crt issuer.pem > bundle.pem
curl -sS --cacert bundle.pem "https://$HOST/" -o /dev/null -w '%{http_code} %{ssl_verify_result}\n'
200 0

발급자는 여전히 여러분 저장소의 루트로 검증됩니다. 서버가 빠뜨린 고리 하나만 여러분이 채워 준 것이라 겁먹지 않아도 됩니다. 대부분의 CA는 발급자를 DER 형식으로 제공하는데, openssl x509가 불평하면 -inform DER를 빼 보세요. 이 번들은 그 호스트 하나에만 쓰고, 서버 쪽 수정 요청을 남겨 두시기 바랍니다.

해결 2: 검사 프록시의 CA를 curl이 보는 곳에 신뢰시키기

IT에서 CA 인증서를 받아 기본 번들이 만들어지는 OS 신뢰 소스에 넣고 다시 빌드합니다:

# Debian / Ubuntu — 파일 확장자는 .crt 여야 합니다
sudo cp corp-root-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# RHEL / Fedora / Amazon Linux
sudo cp corp-root-ca.pem /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust

Debian에서는 이 명령이 몇 개를 추가했는지 알려 주는데, 0 added가 나오면 대개 파일 이름이 .crt로 끝나지 않은 것입니다. 명령 한 번만 통과시키려면 시스템을 건드리지 않고 --cacert corp-root-ca.pem을 쓰면 됩니다. --ca-native 플래그(curl 8.2.0 이상)는 운영체제 저장소를 검색 대상에 추가하지만, 무엇을 읽을 수 있는지는 TLS 백엔드에 달려 있습니다. OpenSSL 빌드는 Windows에서 동작하고, Apple 시스템에서는 libcurl이 Apple SecTrust로 빌드된 경우(curl 8.17.0)에만 동작합니다. curl --version이 백엔드를 알려 줍니다.

해결 3·4: 루트가 들어 있는 저장소를 curl에 가리키기

env | grep -E 'CURL_CA_BUNDLE|SSL_CERT_FILE|SSL_CERT_DIR'

오래된 변수는 unset하거나 배포판 번들 — Debian·Ubuntu는 /etc/ssl/certs/ca-certificates.crt, RHEL은 /etc/pki/tls/certs/ca-bundle.crt — 을 가리키게 합니다. 경로가 없거나 파일이 비어 있으면 curl: (77) error setting certificate file이라는 다른 오류가 납니다. 체인이 아니라 경로 문제입니다. 저장소가 오래됐다면 패키지를 올립니다:

sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates

Fedora·RHEL은 sudo dnf upgrade ca-certificates입니다. 그리고 네, -k/--insecure는 검사를 건너뛰어 메시지를 없애 줍니다. 매뉴얼의 문장은 "WARNING: using this option makes the transfer insecure."입니다. 개발 장비에서 한 번 들여다볼 때는 괜찮지만, 아티팩트를 내려받는 파이프라인에서는 틀린 선택입니다.

실제 사례: 브라우저는 초록, 러너는 빨강

한 CI 러너가 서버 인증서를 갱신한 다음 날 아침부터 curl https://packages.example.internal/…에서 오류 60으로 실패하기 시작했습니다. Chrome에서는 자물쇠가 멀쩡해서 팀은 러너의 번들을 의심했습니다. curl -v는 평소의 CAfile을 보여 줬고, openssl s_client는 깊이 0에 인증서 하나와 Verify return code: 21을 보여 줬습니다. 갱신 과정에서 fullchain.pem 대신 cert.pem이 설치되었고, Chrome은 그동안 중간 인증서를 스스로 받아 오고 있었던 것입니다. nginx가 fullchain.pem을 가리키도록 바꾸고 reload하자 클라이언트는 손대지 않고도 러너가 초록으로 돌아왔습니다.

고친 뒤 확인하고, 다시 생기지 않게 하기

curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://packages.example.internal/

200 0이 원하는 결과입니다. ssl_verify_result는 검증에 성공했을 때만 0이고, openssl s_client는 이제 깊이 0·1과 Verify return code: 0 (ok)를 보여 줘야 합니다. 재발을 막으려면 갱신 때마다 브라우저가 아니라 curl로 테스트하고, 회사 CA를 update-ca-certificates로 베이스 이미지와 러너 템플릿에 구워 넣고, ca-certificates를 이미지의 정기 업그레이드 목록에 넣어 두고, -k는 커밋하지 마세요 — 고정된 번들을 --cacert로 넘기는 것도 타자 수는 같습니다.

다른 60 오류들, 그리고 자체 저장소를 가진 도구와의 차이

"in chain"이 없는 self-signed certificate는 서버 자신의 인증서가 곧 발급자라는 뜻으로, 해결 2처럼 그 CA를 추가해야 하는 사내 서비스입니다. self-signed certificate in certificate chain은 검사 CA가 체인에 드러난 프록시 케이스입니다. certificate has expired는 서버의 날짜 문제이거나 클라이언트 시계가 틀린 것이니 date부터 확인하세요. SSL: no alternative certificate subject name matches target host name은 체인은 멀쩡한데 URL의 이름이 인증서에 없다는 뜻입니다. 자체 신뢰 저장소를 가진 도구들도 같은 식으로 실패하고 각자 스위치가 있습니다: git(http.sslCAInfo), pip(--cert), Node(NODE_EXTRA_CA_CERTS).

다음에 이 오류를 만나면 이 순서를 거꾸로 따라가 보세요. curl -v로 저장소를, openssl s_client로 체인을 확인하고, 고리가 빠진 쪽을 고치면 됩니다.

관련 질문

브라우저는 사이트를 잘 여는데 curl만 오류 60으로 실패하는 이유가 무엇인가요?

거의 모든 경우가 두 가지 이유로 설명됩니다. 브라우저는 빠진 중간 인증서를 스스로 받아 오기 때문에, 리프만 보내는 서버가 Chrome에서는 멀쩡하고 curl에서는 깨져 보입니다. 그리고 관리되는 노트북의 OS 신뢰 저장소에는 회사의 TLS 검사 CA가 이미 들어 있는데, 컨테이너·CI 러너·새 VM은 그 CA를 본 적이 없습니다. openssl s_client가 어느 쪽인지 보여 줍니다. 깊이 0에 인증서 하나뿐이면 전자, 발급자가 회사 이름이면 후자입니다.

그냥 -k를 붙이고 넘어가면 안 되나요?

-k/--insecure는 검증을 통째로 건너뜁니다. 매뉴얼 문장은 "WARNING: using this option makes the transfer insecure."입니다. 개발 장비에서 한 번 들여다보는 용도라면 괜찮습니다. 아티팩트를 내려받는 스크립트나 파이프라인이라면 대신 올바른 번들을 --cacert로 넘기거나 CA를 시스템 저장소에 한 번 추가하세요. 타자 수는 같고 전송은 계속 검증됩니다.

제 curl은 실제로 어느 CA 파일을 쓰나요?

아무 HTTPS URL에나 curl -v를 실행하면 OpenSSL 백엔드가 TLS 핸드셰이크 직전에 CAfile과 CApath를 찍어 줍니다. 배포판 번들이 아니라면 환경 변수 CURL_CA_BUNDLE, SSL_CERT_FILE, SSL_CERT_DIR을 확인하고, conda나 Git for Windows에 딸린 curl은 자체 번들을 쓴다는 점을 기억하세요. curl 8.10.0 이상에서는 --dump-ca-embed가 바이너리에 내장된 번들이 있다면 출력해 줍니다.

update-ca-certificates를 실행했는데도 여전히 실패합니다. 무엇을 놓쳤을까요?

Debian·Ubuntu에서는 /usr/local/share/ca-certificates/에 넣는 파일이 .crt로 끝나야 하고, 아니면 무시됩니다. 명령 출력에 "1 added"가 있어야 합니다. 그다음 curl -v로 CAfile이 환경 변수의 경로가 아니라 /etc/ssl/certs/ca-certificates.crt인지, 실행되는 curl이 시스템 것인지(which curl) 확인하세요. 오류가 (60)이 아니라 (77)이라면 번들 경로 자체가 잘못된 것입니다.

openssl은 "unable to verify the first certificate"라 하고 curl은 "unable to get local issuer certificate"라 합니다. 같은 문제인가요?

대개 그렇습니다. curl은 자기가 걸린 인증서에 대한 OpenSSL 검증 오류를 보고하고, s_client의 요약 줄은 서버가 인증서를 하나만 보냈고 그것을 신뢰 루트에 잇지 못했을 때 코드 21을 보고합니다. 둘 다 서버가 보낸 것 또는 여러분 저장소에서 중간 인증서가 빠졌다는 뜻이고, 요약 위의 깊이 목록이 어느 쪽인지 알려 줍니다.

참고 자료

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

같은 카테고리 다른 글

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)

V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트

컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.

Docker