들어가며
SSL/TLS 인증서의 기본 개념, 발급 방법, Nginx 설정은 [SSL/TLS 인증서 완벽 가이드](/blog/devops/2026-03-08-ssl-certificate-complete-guide)에서 다뤘다. 이 글은 그 연장선에서 **운영** 관점에 집중한다. 인증서를 한 번 발급하는 것은 쉽다. 문제는 수십 개의 도메인을 운영하면서 단 한 건의 만료 사고 없이 인증서를 관리하는 것이다.
실제 인증서 만료 사고는 대형 서비스에서도 빈번하게 발생한다. 2020년 Microsoft Teams가 인증서 만료로 수 시간 장애를 겪었고, Spotify, LinkedIn 등도 같은 문제를 경험했다. 이런 사고의 공통점은 **자동화의 부재가 아니라 운영 프로세스의 부재**였다.
이 플레이북은 다음 질문에 답한다.
- 인증서 갱신 시 서비스를 중단하지 않으려면 어떻게 해야 하는가?
- 만료 30일 전에 자동으로 알림을 받으려면 무엇을 구축해야 하는가?
- 새벽 3시에 인증서 만료 장애가 발생하면 어떤 순서로 대응해야 하는가?
- dev/staging/prod 환경별로 인증서를 어떻게 분리 관리하는가?
1. 인증서 라이프사이클 관리
인증서 운영은 단순히 "발급하고 갱신한다"가 아니다. 체계적인 라이프사이클 관리가 필요하다.
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 발급 │ → │ 배포 │ → │ 모니터링 │ → │ 갱신 │ → │ 폐기 │
│ Issuance │ │ Deploy │ │ Monitor │ │ Renewal │ │ Revoke │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
↑ │
└──────────────────────────────────────────────┘
(자동 갱신 사이클)
1.1 발급 (Issuance)
발급 단계에서 결정해야 할 사항들이다.
| 결정 항목 | 선택지 | 권장 |
| ----------- | --------------------------------- | ------------------------ |
| CA 선택 | Let's Encrypt / DigiCert / ACM | 환경에 따라 (아래 참고) |
| 키 알고리즘 | RSA 2048 / RSA 4096 / ECDSA P-256 | ECDSA P-256 (성능+보안) |
| 인증서 범위 | 단일 도메인 / 와일드카드 / SAN | 와일드카드 + apex SAN |
| 검증 방식 | HTTP-01 / DNS-01 | DNS-01 (와일드카드 필수) |
ECDSA를 권장하는 이유는 RSA 2048 대비 키 크기가 작고 (256bit vs 2048bit), TLS handshake 성능이 약 2~5배 빠르며, 동일 보안 강도에서 CPU 부하가 낮기 때문이다.
ECDSA 키로 Let's Encrypt 인증서 발급
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
--key-type ecdsa \
--elliptic-curve secp256r1 \
-d "*.example.com" \
-d "example.com"
1.2 배포 (Deployment)
인증서를 발급받은 후 실제 서비스에 적용하는 과정이다. 단일 서버라면 간단하지만, 여러 서버에 걸쳐 있을 때는 배포 전략이 필요하다.
#!/bin/bash
/usr/local/bin/deploy-cert.sh
인증서 배포 스크립트 (다중 서버)
CERT_DIR="/etc/letsencrypt/live/example.com"
SERVERS=("web01" "web02" "web03")
REMOTE_CERT_DIR="/etc/nginx/ssl"
DEPLOY_LOG="/var/log/cert-deploy.log"
deploy_cert() {
local server=$1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $server" >> "$DEPLOY_LOG"
인증서 파일 전송
scp -q "$CERT_DIR/fullchain.pem" "$server:$REMOTE_CERT_DIR/fullchain.pem.new"
scp -q "$CERT_DIR/privkey.pem" "$server:$REMOTE_CERT_DIR/privkey.pem.new"
원자적 교체 (mv는 같은 파일시스템에서 atomic)
ssh "$server" "
mv $REMOTE_CERT_DIR/fullchain.pem.new $REMOTE_CERT_DIR/fullchain.pem
mv $REMOTE_CERT_DIR/privkey.pem.new $REMOTE_CERT_DIR/privkey.pem
nginx -t && systemctl reload nginx
"
if [ $? -eq 0 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $server: OK" >> "$DEPLOY_LOG"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $server: FAILED" >> "$DEPLOY_LOG"
return 1
fi
}
for server in "${SERVERS[@]}"; do
deploy_cert "$server"
done
1.3 모니터링 (Monitoring)
2장에서 자세히 다루지만, 핵심 원칙은 다음과 같다.
- 만료 **30일 전**부터 warning 알림
- 만료 **7일 전**부터 critical 알림
- 만료 **1일 전** 에스컬레이션 (PagerDuty/전화)
- 갱신 성공/실패 이벤트를 반드시 로깅
1.4 갱신 (Renewal)
3장에서 무중단 갱신 전략을 상세히 다룬다.
1.5 폐기 (Revocation)
인증서 폐기가 필요한 상황은 다음과 같다.
- 개인키 유출이 의심되는 경우
- 도메인 소유권을 상실한 경우
- 조직 정보가 변경된 경우
Let's Encrypt 인증서 폐기
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/cert.pem \
--reason keycompromise
폐기 후 인증서 파일 삭제
sudo certbot delete --cert-name example.com
즉시 새 인증서 발급
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "*.example.com" -d "example.com"
2. 무중단 갱신 전략
인증서 갱신 시 서비스 중단이 발생하는 주요 원인은 크게 세 가지다.
1. 갱신 과정에서 웹 서버를 재시작(restart)하는 경우
2. 새 인증서 배포와 로드밸런서 반영 사이의 시간차
3. 클라이언트의 TLS 세션 캐시가 이전 인증서를 참조하는 경우
2.1 Nginx reload 방식 (단일 서버)
가장 기본적인 무중단 갱신이다. Nginx는 `reload` 시 기존 워커 프로세스가 현재 처리 중인 요청을 마무리한 후 종료되고, 새 워커 프로세스가 새 설정(새 인증서)으로 시작된다.
restart vs reload 차이
restart: 프로세스를 중단 후 재시작 → 요청 유실 가능
reload: 새 워커 생성 → 기존 워커 graceful shutdown → 무중단
certbot deploy hook으로 reload 자동화
sudo certbot renew --deploy-hook "systemctl reload nginx"
**주의**: `systemctl restart nginx`는 절대 사용하지 말 것. 기존 연결이 즉시 끊어진다.
2.2 Rolling 갱신 (다중 서버)
로드밸런서 뒤에 여러 서버가 있을 때, 한 대씩 순차적으로 갱신한다.
#!/bin/bash
/usr/local/bin/rolling-cert-renewal.sh
SERVERS=("web01" "web02" "web03")
LB_API="http://lb-admin.internal:8080/api"
HEALTH_CHECK_URL="https://example.com/healthz"
WAIT_SECONDS=30
for server in "${SERVERS[@]}"; do
echo "=== Processing $server ==="
1. 로드밸런서에서 서버 제거
curl -s -X POST "$LB_API/drain" -d "server=$server"
echo "Draining $server from load balancer..."
sleep $WAIT_SECONDS # 기존 연결 완료 대기
2. 인증서 배포 및 적용
scp /etc/letsencrypt/live/example.com/fullchain.pem "$server:/etc/nginx/ssl/"
scp /etc/letsencrypt/live/example.com/privkey.pem "$server:/etc/nginx/ssl/"
ssh "$server" "nginx -t && systemctl reload nginx"
3. 서버 헬스체크
for i in $(seq 1 10); do
status=$(curl -s -o /dev/null -w "%{http_code}" "https://$server/healthz" --resolve "example.com:443:$(dig +short $server)")
if [ "$status" = "200" ]; then
echo "$server health check passed"
break
fi
sleep 2
done
4. 로드밸런서에 서버 복귀
curl -s -X POST "$LB_API/enable" -d "server=$server"
echo "$server re-enabled in load balancer"
sleep 5
done
echo "=== Rolling renewal complete ==="
2.3 Blue-Green 인증서 교체
두 세트의 인증서를 운영하여 전환 시점에 즉시 스위칭하는 방식이다. 주로 대규모 인프라에서 사용한다.
/etc/nginx/conf.d/ssl-blue-green.conf
심볼릭 링크를 활용한 Blue-Green 인증서 전환
현재 활성 인증서 (심볼릭 링크)
/etc/nginx/ssl/active/ -> /etc/nginx/ssl/blue/ 또는 /etc/nginx/ssl/green/
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/ssl/active/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/active/privkey.pem;
... 기타 설정
}
#!/bin/bash
/usr/local/bin/blue-green-cert-switch.sh
ACTIVE_LINK="/etc/nginx/ssl/active"
BLUE_DIR="/etc/nginx/ssl/blue"
GREEN_DIR="/etc/nginx/ssl/green"
현재 활성 슬롯 확인
current=$(readlink "$ACTIVE_LINK")
if [ "$current" = "$BLUE_DIR" ]; then
target="$GREEN_DIR"
target_name="green"
else
target="$BLUE_DIR"
target_name="blue"
fi
echo "Current: $current"
echo "Deploying new cert to: $target ($target_name)"
새 인증서를 비활성 슬롯에 배포
cp /etc/letsencrypt/live/example.com/fullchain.pem "$target/fullchain.pem"
cp /etc/letsencrypt/live/example.com/privkey.pem "$target/privkey.pem"
인증서 유효성 검증
openssl x509 -in "$target/fullchain.pem" -noout -checkend 86400
if [ $? -ne 0 ]; then
echo "ERROR: New certificate expires within 24 hours. Aborting."
exit 1
fi
키 매칭 검증
CERT_MD5=$(openssl x509 -noout -modulus -in "$target/fullchain.pem" | openssl md5)
KEY_MD5=$(openssl rsa -noout -modulus -in "$target/privkey.pem" 2>/dev/null | openssl md5)
if [ "$CERT_MD5" != "$KEY_MD5" ]; then
echo "ERROR: Certificate and key do not match. Aborting."
exit 1
fi
심볼릭 링크 원자적 전환
ln -sfn "$target" "${ACTIVE_LINK}.new"
mv -T "${ACTIVE_LINK}.new" "$ACTIVE_LINK"
Nginx reload
nginx -t && systemctl reload nginx
echo "Switched to $target_name slot. Reload complete."
2.4 Dual-Certificate (듀얼 인증서)
Nginx 1.11.0 이상에서는 RSA와 ECDSA 인증서를 동시에 로드할 수 있다. 이를 활용하면 하나의 인증서를 갱신하는 동안 다른 인증서가 서비스를 유지한다.
server {
listen 443 ssl http2;
server_name example.com;
RSA 인증서
ssl_certificate /etc/nginx/ssl/rsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;
ECDSA 인증서
ssl_certificate /etc/nginx/ssl/ecdsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;
Nginx가 클라이언트 지원에 따라 자동 선택
ECDSA 우선, 미지원 클라이언트는 RSA fallback
}
3. Let's Encrypt 자동 갱신 운영
3.1 systemd timer 기반 갱신 (권장)
Cron보다 systemd timer를 권장하는 이유는 다음과 같다.
- `RandomizedDelaySec`로 CA 서버 부하 분산
- `Persistent=true`로 부팅 시 놓친 실행분 보상
- `systemctl list-timers`로 다음 실행 시각 확인 가능
- journalctl로 로그 통합 관리
/etc/systemd/system/certbot-renewal.service
[Unit]
Description=Certbot SSL Certificate Renewal
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet \
--pre-hook "/usr/local/bin/cert-pre-hook.sh" \
--deploy-hook "/usr/local/bin/cert-deploy-hook.sh"
ExecStartPost=/usr/local/bin/cert-renewal-notify.sh
TimeoutStartSec=300
/etc/systemd/system/certbot-renewal.timer
[Unit]
Description=Run certbot renewal twice daily
[Timer]
OnCalendar=*-*-* 02,14:00:00
RandomizedDelaySec=3600
Persistent=true
AccuracySec=1s
[Install]
WantedBy=timers.target
timer 활성화 및 상태 확인
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer
sudo systemctl list-timers certbot-renewal.timer
수동 테스트 (dry-run)
sudo certbot renew --dry-run
수동 트리거
sudo systemctl start certbot-renewal.service
3.2 pre-hook / deploy-hook 활용
hook은 갱신 전후에 필요한 작업을 자동화한다. certbot은 인증서가 실제로 갱신될 때만 hook을 실행한다.
#!/bin/bash
/usr/local/bin/cert-pre-hook.sh
갱신 전 실행되는 hook
LOG="/var/log/cert-hooks.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] PRE-HOOK: Starting renewal process" >> "$LOG"
현재 인증서 정보 백업
for domain_dir in /etc/letsencrypt/live/*/; do
domain=$(basename "$domain_dir")
expiry=$(openssl x509 -in "${domain_dir}fullchain.pem" -noout -enddate 2>/dev/null | cut -d= -f2)
echo "[PRE] $domain expires: $expiry" >> "$LOG"
done
#!/bin/bash
/usr/local/bin/cert-deploy-hook.sh
갱신 성공 후 실행되는 hook
$RENEWED_DOMAINS, $RENEWED_LINEAGE 환경변수 사용 가능
LOG="/var/log/cert-hooks.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEPLOY-HOOK: Certificate renewed" >> "$LOG"
echo " Domains: $RENEWED_DOMAINS" >> "$LOG"
echo " Lineage: $RENEWED_LINEAGE" >> "$LOG"
1. Nginx 설정 검증 후 reload
if nginx -t 2>/dev/null; then
systemctl reload nginx
echo " Nginx reloaded successfully" >> "$LOG"
else
echo " ERROR: Nginx config test failed!" >> "$LOG"
설정 오류 시 긴급 알림
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"CRITICAL: Nginx config test failed after cert renewal!"}'
exit 1
fi
2. HAProxy가 있으면 인증서 합본 후 reload
if systemctl is-active haproxy > /dev/null 2>&1; then
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" \
> /etc/haproxy/certs/$(basename "$RENEWED_LINEAGE").pem
systemctl reload haproxy
echo " HAProxy reloaded" >> "$LOG"
fi
3. 다른 서버에 인증서 동기화 (필요 시)
/usr/local/bin/sync-certs-to-peers.sh "$RENEWED_LINEAGE"
#!/bin/bash
/usr/local/bin/cert-renewal-notify.sh
갱신 결과 알림
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
LOG="/var/log/cert-hooks.log"
마지막 갱신 결과 확인
last_renewal=$(journalctl -u certbot-renewal.service --since "5 minutes ago" --no-pager 2>/dev/null)
if echo "$last_renewal" | grep -q "Congratulations"; then
갱신 성공
renewed_domains=$(echo "$last_renewal" | grep "renewed" | head -5)
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"SSL 인증서 갱신 성공\\n${renewed_domains}\"}"
elif echo "$last_renewal" | grep -q "No renewals were attempted"; then
갱신 대상 없음 (정상)
echo "[$(date)] No certificates due for renewal" >> "$LOG"
else
갱신 실패
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d '{"text":"WARNING: certbot renewal 실행 결과를 확인하세요!"}'
fi
3.3 갱신 실패 시 자동 재시도
certbot 자체에는 재시도 로직이 없다. systemd의 기능을 활용하여 구현한다.
/etc/systemd/system/certbot-renewal.service 에 추가
[Service]
실패 시 5분 후 재시도, 최대 3회
Restart=on-failure
RestartSec=300
StartLimitBurst=3
StartLimitIntervalSec=3600
4. 인증서 만료 모니터링
4.1 Prometheus + ssl_exporter
ssl_exporter는 TLS 인증서의 만료 시간을 Prometheus 메트릭으로 노출한다.
ssl_exporter 설치
wget https://github.com/ribbybibby/ssl_exporter/releases/download/v2.4.3/ssl_exporter-2.4.3.linux-amd64.tar.gz
tar xzf ssl_exporter-2.4.3.linux-amd64.tar.gz
sudo mv ssl_exporter-2.4.3.linux-amd64/ssl_exporter /usr/local/bin/
systemd service
sudo tee /etc/systemd/system/ssl-exporter.service << 'EOF'
[Unit]
Description=SSL Certificate Exporter
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ssl_exporter
Restart=on-failure
User=ssl-exporter
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now ssl-exporter
Prometheus scrape config
/etc/prometheus/prometheus.yml
scrape_configs:
- job_name: 'ssl'
metrics_path: /probe
static_configs:
- targets:
- example.com:443
- api.example.com:443
- admin.example.com:443
- staging.example.com:443
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: ssl-exporter:9219 # ssl_exporter 주소
주요 메트릭은 다음과 같다.
- `ssl_cert_not_after`: 인증서 만료 시각 (Unix timestamp)
- `ssl_cert_not_before`: 인증서 발급 시각
- `ssl_tls_version_info`: TLS 버전 정보
- `ssl_ocsp_response_status`: OCSP 응답 상태
인증서 만료까지 남은 일수 계산
(ssl_cert_not_after - time()) / 86400
30일 이내 만료 인증서 조회
(ssl_cert_not_after - time()) / 86400 < 30
7일 이내 만료 인증서 조회
(ssl_cert_not_after - time()) / 86400 < 7
4.2 Alertmanager 알림 규칙
/etc/prometheus/rules/ssl-alerts.yml
groups:
- name: ssl_certificate_alerts
rules:
30일 이내 만료 - Warning
- alert: SSLCertExpiringSoon
expr: (ssl_cert_not_after - time()) / 86400 < 30
for: 1h
labels:
severity: warning
annotations:
summary: 'SSL 인증서 만료 임박 ({{ $labels.instance }})'
description: '{{ $labels.instance }} 인증서가 {{ $value | printf "%.0f" }}일 후 만료됩니다.'
runbook_url: 'https://wiki.internal/runbooks/ssl-renewal'
7일 이내 만료 - Critical
- alert: SSLCertExpiryCritical
expr: (ssl_cert_not_after - time()) / 86400 < 7
for: 10m
labels:
severity: critical
team: platform
annotations:
summary: 'SSL 인증서 만료 긴급 ({{ $labels.instance }})'
description: '{{ $labels.instance }} 인증서가 {{ $value | printf "%.0f" }}일 후 만료됩니다. 즉시 조치가 필요합니다.'
runbook_url: 'https://wiki.internal/runbooks/ssl-emergency-renewal'
이미 만료된 인증서
- alert: SSLCertExpired
expr: (ssl_cert_not_after - time()) < 0
for: 0m
labels:
severity: critical
escalation: pagerduty
annotations:
summary: 'SSL 인증서 만료됨 ({{ $labels.instance }})'
description: '{{ $labels.instance }} 인증서가 만료되었습니다! 서비스 장애가 발생할 수 있습니다.'
인증서 프로빙 실패 (연결 불가)
- alert: SSLProbeFailure
expr: ssl_probe_success == 0
for: 5m
labels:
severity: warning
annotations:
summary: 'SSL 프로브 실패 ({{ $labels.instance }})'
description: '{{ $labels.instance }}에 TLS 연결을 수립할 수 없습니다.'
Alertmanager 라우팅 설정
/etc/alertmanager/alertmanager.yml
route:
group_by: ['alertname', 'instance']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'slack-warning'
routes:
- match:
severity: critical
escalation: pagerduty
receiver: 'pagerduty-critical'
repeat_interval: 30m
- match:
severity: critical
receiver: 'slack-critical'
repeat_interval: 1h
receivers:
- name: 'slack-warning'
slack_configs:
- api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
channel: '#ssl-alerts'
title: '{{ .CommonAnnotations.summary }}'
text: '{{ .CommonAnnotations.description }}'
- name: 'slack-critical'
slack_configs:
- api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
channel: '#incident'
title: '{{ .CommonAnnotations.summary }}'
text: '{{ .CommonAnnotations.description }}'
- name: 'pagerduty-critical'
pagerduty_configs:
- service_key: 'YOUR_PAGERDUTY_SERVICE_KEY'
severity: critical
4.3 Grafana 대시보드
{
"title": "SSL Certificate Dashboard",
"panels": [
{
"title": "인증서 만료까지 남은 일수",
"type": "table",
"targets": [
{
"expr": "sort_desc((ssl_cert_not_after - time()) / 86400)",
"legendFormat": "{{ instance }}"
}
]
},
{
"title": "만료 임박 인증서 (30일 이내)",
"type": "stat",
"targets": [
{
"expr": "count((ssl_cert_not_after - time()) / 86400 < 30)"
}
],
"thresholds": [
{ "value": 0, "color": "green" },
{ "value": 1, "color": "orange" },
{ "value": 3, "color": "red" }
]
}
]
}
4.4 자체 모니터링 스크립트 (Prometheus 없이)
Prometheus 인프라가 없는 환경에서는 셸 스크립트로 대체할 수 있다.
#!/bin/bash
/usr/local/bin/ssl-expiry-check.sh
인증서 만료 모니터링 + Slack/Email 알림
set -euo pipefail
DOMAINS=(
"example.com"
"api.example.com"
"admin.example.com"
"staging.example.com"
)
WARNING_DAYS=30
CRITICAL_DAYS=7
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
ALERT_EMAIL="ops-team@example.com"
check_cert() {
local domain=$1
local port=${2:-443}
인증서 만료일 가져오기
local expiry_date
expiry_date=$(echo | timeout 10 openssl s_client \
-servername "$domain" \
-connect "${domain}:${port}" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
if [ -z "$expiry_date" ]; then
echo "UNKNOWN|${domain}|Connection failed"
return
fi
남은 일수 계산 (Linux/macOS 호환)
local expiry_epoch days_left
if date --version >/dev/null 2>&1; then
GNU date (Linux)
expiry_epoch=$(date -d "$expiry_date" +%s)
else
BSD date (macOS)
expiry_epoch=$(date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s)
fi
days_left=$(( (expiry_epoch - $(date +%s)) / 86400 ))
if [ "$days_left" -lt 0 ]; then
echo "EXPIRED|${domain}|${days_left}|${expiry_date}"
elif [ "$days_left" -lt "$CRITICAL_DAYS" ]; then
echo "CRITICAL|${domain}|${days_left}|${expiry_date}"
elif [ "$days_left" -lt "$WARNING_DAYS" ]; then
echo "WARNING|${domain}|${days_left}|${expiry_date}"
else
echo "OK|${domain}|${days_left}|${expiry_date}"
fi
}
전체 도메인 점검
alerts=""
for domain in "${DOMAINS[@]}"; do
result=$(check_cert "$domain")
status=$(echo "$result" | cut -d'|' -f1)
days=$(echo "$result" | cut -d'|' -f3)
case $status in
OK)
printf "%-30s %-10s %s days\n" "$domain" "[OK]" "$days"
;;
WARNING)
printf "%-30s %-10s %s days\n" "$domain" "[WARNING]" "$days"
alerts="${alerts}WARNING: ${domain} - ${days}일 남음\n"
;;
CRITICAL|EXPIRED)
printf "%-30s %-10s %s days\n" "$domain" "[$status]" "$days"
alerts="${alerts}${status}: ${domain} - ${days}일 남음\n"
;;
UNKNOWN)
printf "%-30s %-10s\n" "$domain" "[UNKNOWN]"
alerts="${alerts}UNKNOWN: ${domain} - 연결 실패\n"
;;
esac
done
알림 발송
if [ -n "$alerts" ]; then
if [ -n "$SLACK_WEBHOOK" ]; then
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"SSL 인증서 점검 결과:\\n${alerts}\"}"
fi
이메일 알림 (mailutils 필요)
echo -e "$alerts" | mail -s "[SSL Alert] 인증서 만료 경고" "$ALERT_EMAIL" 2>/dev/null || true
fi
cron 등록 (매일 오전 9시)
echo "0 9 * * * /usr/local/bin/ssl-expiry-check.sh >> /var/log/ssl-check.log 2>&1" | sudo crontab -
5. 인시던트 대응 플레이북
5.1 인증서 만료 사고 발생 시 대응 순서
┌─────────────────────────────────────────────────────────────┐
│ 인증서 만료 인시던트 대응 플로우 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 탐지 및 확인 (0~5분) │
│ └→ 장애 범위 파악, 영향 도메인 목록화 │
│ │
│ 2. 즉시 완화 (5~15분) │
│ └→ 임시 인증서 적용 또는 트래픽 우회 │
│ │
│ 3. 정식 인증서 갱신 (15~30분) │
│ └→ certbot 갱신 또는 긴급 발급 │
│ │
│ 4. 서비스 검증 (30~45분) │
│ └→ 모든 엔드포인트 TLS 연결 확인 │
│ │
│ 5. 포스트모템 (48시간 이내) │
│ └→ 원인 분석, 재발 방지 조치 │
│ │
└─────────────────────────────────────────────────────────────┘
5.2 단계별 대응 상세
단계 1: 탐지 및 확인 (0~5분)
1-1. 만료 여부 즉시 확인
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates
1-2. 여러 도메인 한번에 확인
for domain in example.com api.example.com admin.example.com; do
echo -n "$domain: "
echo | openssl s_client -servername "$domain" -connect "${domain}:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null || echo "CONNECTION FAILED"
done
1-3. 로컬 인증서 파일 확인
for cert in /etc/letsencrypt/live/*/fullchain.pem; do
domain=$(basename $(dirname "$cert"))
expiry=$(openssl x509 -in "$cert" -noout -enddate | cut -d= -f2)
echo "$domain: $expiry"
done
1-4. 인시던트 채널에 상황 공유
"SSL 인증서 만료 확인. 영향 범위: example.com, api.example.com. 대응 시작."
단계 2: 즉시 완화 (5~15분)
2-1. Let's Encrypt 인증서 강제 갱신
sudo certbot renew --force-renewal --cert-name example.com
sudo systemctl reload nginx
2-2. 갱신 실패 시 - standalone 방식으로 긴급 발급
sudo systemctl stop nginx
sudo certbot certonly --standalone -d example.com -d "*.example.com"
sudo systemctl start nginx
2-3. Let's Encrypt rate limit에 걸린 경우 - 자체 서명 인증서 임시 적용
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout /tmp/emergency.key \
-out /tmp/emergency.crt \
-subj "/CN=example.com"
주의: 자체 서명 인증서는 브라우저 경고가 표시되지만
API 서버 등 내부 통신에서는 임시 대안이 될 수 있다
2-4. AWS 환경에서 ACM 인증서 사용 중이라면
ACM은 자동 갱신이므로 대부분 ALB/CloudFront 측 문제
aws elbv2 describe-listeners --load-balancer-arn $ALB_ARN \
--query 'Listeners[].Certificates[].CertificateArn'
ACM 인증서 상태 확인
aws acm describe-certificate --certificate-arn $CERT_ARN \
--query 'Certificate.{Status:Status,NotAfter:NotAfter}'
단계 3: 정식 인증서 갱신 (15~30분)
3-1. 갱신된 인증서 체인 검증
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
/etc/letsencrypt/live/example.com/fullchain.pem
3-2. 인증서와 키 매칭 확인
diff <(openssl x509 -noout -modulus -in /etc/letsencrypt/live/example.com/fullchain.pem | openssl md5) \
<(openssl rsa -noout -modulus -in /etc/letsencrypt/live/example.com/privkey.pem | openssl md5)
3-3. 다중 서버에 배포
/usr/local/bin/deploy-cert.sh
단계 4: 서비스 검증 (30~45분)
4-1. TLS 연결 테스트
curl -vI https://example.com 2>&1 | grep -E "SSL|expire|subject"
4-2. 전체 엔드포인트 점검
for url in https://example.com https://api.example.com/healthz https://admin.example.com; do
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
echo "$url: HTTP $status"
done
4-3. 외부에서 확인 (SSL Labs)
echo "Check: https://www.ssllabs.com/ssltest/analyze.html?d=example.com"
단계 5: 포스트모템 (48시간 이내)
포스트모템에 포함해야 할 항목은 다음과 같다.
인시던트 포스트모템: SSL 인증서 만료
타임라인
- HH:MM - 최초 알림 수신 (출처: Prometheus/사용자 보고)
- HH:MM - 인시던트 확인, 대응 시작
- HH:MM - 인증서 갱신 완료
- HH:MM - 서비스 정상 확인
영향 범위
- 영향받은 도메인: example.com, api.example.com
- 영향 시간: XX분
- 영향받은 사용자 수: approximately N명
근본 원인
- (예) certbot timer가 비활성화되어 자동 갱신이 동작하지 않았음
- (예) DNS 검증 실패로 갱신이 반복 실패했으나 알림이 미설정
재발 방지 조치
- [ ] 자동 갱신 timer 상태 모니터링 추가
- [ ] 갱신 실패 시 즉시 알림 설정
- [ ] 인증서 만료 30일 전 warning 알림 설정 확인
6. 멀티 환경 인증서 관리
6.1 환경별 전략
| 환경 | 인증서 유형 | CA | 갱신 주기 | 비고 |
| ----------- | ----------------------- | --------------------- | ----------- | ------------------ |
| **dev** | 자체 서명 또는 mkcert | 자체 | N/A | 브라우저 경고 허용 |
| **staging** | Let's Encrypt (staging) | Let's Encrypt Staging | 90일 | rate limit 없음 |
| **prod** | Let's Encrypt 또는 ACM | 공인 CA | 90일 / 자동 | 무중단 필수 |
6.2 개발 환경: mkcert 활용
로컬 개발 환경에서는 mkcert로 로컬 신뢰 인증서를 생성한다.
mkcert 설치 (macOS)
brew install mkcert
mkcert -install # 로컬 CA를 시스템 인증서 저장소에 추가
개발용 인증서 생성
mkcert "*.dev.example.com" localhost 127.0.0.1 ::1
결과 파일
_wildcard.dev.example.com+3.pem (인증서)
_wildcard.dev.example.com+3-key.pem (키)
6.3 스테이징 환경: Let's Encrypt Staging 사용
스테이징에서는 Let's Encrypt의 staging 서버를 사용하여 rate limit 문제를 피한다.
staging 서버로 인증서 발급 (rate limit 없음, 브라우저 신뢰 안 됨)
sudo certbot certonly --staging \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "*.staging.example.com"
API 테스트 시 -k (insecure) 플래그 사용
curl -k https://staging.example.com/api/healthz
6.4 와일드카드 전략
example.com → 단일 도메인 + 와일드카드
├── www.example.com → *.example.com 으로 커버
├── api.example.com → *.example.com 으로 커버
├── admin.example.com → *.example.com 으로 커버
├── staging.example.com → 별도 인증서 (staging 환경)
│ ├── api.staging.example.com → *.staging.example.com 으로 커버
│ └── admin.staging.example.com → *.staging.example.com 으로 커버
└── internal.example.com → 내부 전용 (mTLS 고려)
프로덕션 와일드카드 인증서 (apex + wildcard)
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "example.com" \
-d "*.example.com" \
--cert-name prod-wildcard
스테이징 와일드카드 인증서 (별도)
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "staging.example.com" \
-d "*.staging.example.com" \
--cert-name staging-wildcard
6.5 Kubernetes 환경: cert-manager
Kubernetes 환경에서는 cert-manager로 인증서를 선언적으로 관리한다.
cert-manager ClusterIssuer (Let's Encrypt)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
email: admin@example.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
Certificate 리소스
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: istio-system
spec:
secretName: example-com-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- example.com
- '*.example.com'
자동 갱신: 만료 30일 전
renewBefore: 720h # 30일
Ingress에서 자동 인증서 발급
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-ingress
annotations:
cert-manager.io/cluster-issuer: 'letsencrypt-prod'
spec:
tls:
- hosts:
- example.com
- api.example.com
secretName: example-com-tls
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
cert-manager 상태 모니터링 명령도 함께 알아두면 좋다.
인증서 상태 확인
kubectl get certificates -A
kubectl describe certificate example-com-tls -n istio-system
인증서 이벤트 확인
kubectl get events --field-selector reason=IssueError -A
cert-manager 로그
kubectl logs -n cert-manager deploy/cert-manager -f
7. 실전 체크리스트
인증서 발급 시 체크리스트
- [ ] 키 알고리즘 선택 (ECDSA P-256 권장)
- [ ] 인증서 범위 결정 (단일 / 와일드카드 / SAN)
- [ ] DNS-01 검증을 위한 DNS API 자격 증명 준비
- [ ] 인증서 파일 권한 설정 (`chmod 600 privkey.pem`)
- [ ] fullchain.pem 사용 여부 확인 (cert.pem만 쓰면 체인 불완전)
자동 갱신 체크리스트
- [ ] systemd timer 활성 상태 확인 (`systemctl is-active certbot-renewal.timer`)
- [ ] `certbot renew --dry-run` 성공 확인
- [ ] deploy-hook에서 웹 서버 reload 설정
- [ ] 갱신 실패 시 알림 설정
- [ ] 갱신 실패 시 재시도 로직 구현
모니터링 체크리스트
- [ ] ssl_exporter 또는 자체 스크립트로 만료일 모니터링
- [ ] 30일 전 warning, 7일 전 critical 알림 설정
- [ ] Slack/PagerDuty 알림 채널 연결
- [ ] Grafana 대시보드에 인증서 현황 패널 추가
- [ ] 주간 인증서 점검 리포트 자동화
인시던트 대비 체크리스트
- [ ] 인증서 만료 시 대응 런북 작성 완료
- [ ] 긴급 연락 채널 (온콜) 지정
- [ ] 이전 인증서 백업 보관
- [ ] certbot 외 대체 발급 수단 확보 (acme.sh 등)
- [ ] rate limit 초과 시 대안 (staging CA, 다른 CA)
멀티 환경 체크리스트
- [ ] dev: mkcert 로컬 인증서 사용
- [ ] staging: Let's Encrypt staging CA 사용
- [ ] prod: 공인 CA + 자동 갱신 + 모니터링
- [ ] Kubernetes: cert-manager 설치 및 ClusterIssuer 설정
- [ ] 인증서별 갱신 일정 문서화 (인증서 인벤토리)
8. 마무리
인증서 운영의 핵심 원칙을 정리한다.
**1. 수동 갱신은 반드시 실패한다.** Let's Encrypt가 90일 유효기간을 채택한 이유는 자동화를 강제하기 위해서다. certbot + systemd timer, cert-manager 등으로 갱신을 완전 자동화해야 한다.
**2. 자동화만으로는 부족하다.** 자동 갱신이 실패할 수 있다. DNS API 토큰 만료, 디스크 부족, CA 서버 장애 등 실패 원인은 다양하다. 반드시 모니터링을 함께 구축해야 한다.
**3. 인시던트는 반드시 온다.** 만료 사고가 발생했을 때 당황하지 않으려면, 미리 런북을 작성하고 정기적으로 훈련해야 한다. 복구 시간은 준비 수준에 비례한다.
**4. 인증서는 인벤토리로 관리한다.** 도메인이 늘어날수록 "어디에 어떤 인증서가 있고, 언제 만료되는지" 파악이 어려워진다. 인증서 목록을 문서화하고, 모니터링 대상에 빠짐없이 등록해야 한다.
이 플레이북의 체크리스트와 스크립트를 기반으로 자신의 환경에 맞는 인증서 운영 체계를 구축하기를 권장한다. 한 번 잘 만들어 놓으면 인증서 만료 사고에서 해방될 수 있다.
현재 단락 (1/638)
SSL/TLS 인증서의 기본 개념, 발급 방법, Nginx 설정은 [SSL/TLS 인증서 완벽 가이드](/blog/devops/2026-03-08-ssl-certificate-co...