
오늘 서비스의 cerbot SSL 인증서가 만료됐다. 만료된 사실을 우리 서비스에 직접 들어가고서야 알게 됐다. 만약 우리가 직접 서비스에 안들어가봤다면 들어갈 때까지 몰랐을 것이다. ssl 인증서 만료로 인해 서비스 전체가 마비됐고, 즉시 certbot renew로 살렸다.
이런 일이 재발하는 것을 예방하기 위해 자동 갱신 스케줄러를 추가했지만, 사후대책이 필요했다.
실제로 언제 갱신됐는지, 갱신이 실패하진 않았는지 매번 시스템 로그로 확인해야한다.
ssl 인증서가 갱신되는 타이밍을 더 쉽게 확인하고, 만약 모종의 이유로 실패한다면 이를 빠르게 개발자들에게 알리는 장치가 필요했다. 인증서 갱신 시도 시 실패가 발생하면 적어도 약 30일간의 복구 여유 기간이 있기 때문이다. (certbot 인증서 재발급은 만료기간으로부터 30일 전부터 할 수 있다.)
우리는 핵심 소통 창구로 디스코드를 사용하고 있으므로 Discord Webhook을 통해 인증서 발급 상태를 가시화 하고, 만료로 인한 서비스 중단 리스크를 낮추고자 한다.
큰 설계는 다음과 같다. (자동갱신은 systemd timer로 구성돼 있다.)
- systemd timer가 정해진 시각에
certbot-renew.service를 실행한다.- certbot renew는 "갱신 시도(실행)”만 하고, 실제로 갱신이 필요할 때만 인증서를 갱신한다.
- 실제로 갱신된 인증서가 있을 경우에만, certbot이
/etc/letsencrypt/renewal-hooks/deploy/*안의 실행 파일들을 순서대로 실행한다.deploy-hook에서
nginx -t→nginx reload를 수행하고,- 성공했을 때만 “성공 알림”을 디스코드로 전송한다.
- certbot renew 자체가 실패하거나,
deploy-hook에서 nginx reload가 실패하면 종료 코드는 0이 아닌 값으로 끝난다.- systemd는 이를 실패로 판정하고, OnFailure로 연결된 알림 서비스가 실패 알림을 디스코드로 전송한다.
certbot-renew.service 및 nginx reload가 실패(exit code ≠ 0)일 때먼저 공통으로 사용할 Discord webhook url을 파일로 관리한다.
sudo install -d -m 700 /etc/discord
sudo tee /etc/discord/webhook.url >/dev/null <<'EOF'
https://discord.com/api/webhooks/XXXX/YYYY
EOF
sudo chmod 600 /etc/discord/webhook.url
성공 기준은 단순히 “certbot 갱신 성공”이 아니라:
certbot renew가 실제 갱신을 수행했고 + nginx reload까지 성공
그래서 deploy-hook을 2단으로 나눴다.
10-reload-nginx.sh : reload 성공 여부를 마커로 남김20-notify-discord-success.sh : 마커가 있을 때만 성공 알림 전송/etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh
#!/bin/sh
set -e
# nginx 설정 문법 체크 (실패하면 reload 안 함)
nginx -t
# 무중단 reload
systemctl reload nginx
MARKER_DIR="/run/letsencrypt"
mkdir -p "$MARKER_DIR"
# lineage 기반으로 고유 키 생성(여러 인증서/여러 서버에서도 충돌 방지)
KEY="$(printf '%s' "${RENEWED_LINEAGE:-unknown}" | sha256sum | awk '{print $1}')"
MARKER="$MARKER_DIR/nginx_reload_ok_${KEY}"
date -Is > "$MARKER"
// 변경사항 저장 후 실행 권한 추가
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh
❓ 왜
/run디렉토리인가?
/run은 런타임 디렉토리라 재부팅 시 자동으로 비워진다.
“이번 갱신 사이클에서만 쓰는 성공 신호”로 적합하다.
/etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh
#!/bin/sh
set -eu
# ===== 설정 =====
ENV_NAME="${ENV_NAME:-PROD}"
TZ="Asia/Seoul"
export TZ
WEBHOOK_URL="$(cat /etc/discord/webhook.url)"
NOW_ISO="$(date -Is)"
NOW_KST="$(date '+%Y-%m-%d %H:%M:%S %Z')"
LINEAGE="${RENEWED_LINEAGE:-}"
CERT_NAME="$(basename "$LINEAGE")"
CERT_PEM="$LINEAGE/fullchain.pem"
# ---- (중요) nginx reload 성공 마커 확인 ----
MARKER_DIR="/run/letsencrypt"
KEY="$(printf '%s' "${RENEWED_LINEAGE:-unknown}" | sha256sum | awk '{print $1}')"
MARKER="$MARKER_DIR/nginx_reload_ok_${KEY}"
# 마커가 없으면 성공 알림을 보내지 않음(=reload 성공 보장 불가)
[ -f "$MARKER" ] || exit 0
# ---- renew_before_expiry (certbot 2.6.0 기본 30일, 설정 있으면 override) ----
RENEW_BEFORE_DAYS="30"
CONF="/etc/letsencrypt/renewal/${CERT_NAME}.conf"
if [ -f "$CONF" ]; then
v="$(grep -E '^[[:space:]]*renew_before_expiry[[:space:]]*=' "$CONF" \
| tail -n 1 | sed -E 's/.*=[[:space:]]*([0-9]+).*/\1/')"
if echo "$v" | grep -Eq '^[0-9]+$'; then
RENEW_BEFORE_DAYS="$v"
fi
fi
# ---- 만료일 / 갱신가능 시작일 계산 ----
ENDDATE_RAW="$(openssl x509 -in "$CERT_PEM" -noout -enddate | cut -d= -f2)"
EXPIRES_KST="$(date -d "$ENDDATE_RAW" '+%Y-%m-%d %H:%M:%S KST')"
RENEW_FROM_KST="$(date -d "$ENDDATE_RAW - ${RENEW_BEFORE_DAYS} days" '+%Y-%m-%d %H:%M:%S KST')"
# ---- python이 읽을 수 있도록 환경변수 export ----
export ENV_NAME NOW_ISO NOW_KST EXPIRES_KST RENEW_FROM_KST RENEW_BEFORE_DAYS
# ---- 디스코드 embed 전송 ----
python3 - <<PY | curl -sS -H "Content-Type: application/json" -X POST -d @- "$WEBHOOK_URL" >/dev/null
import json, os
env_name = os.environ["ENV_NAME"]
now_iso = os.environ["NOW_ISO"]
now_kst = os.environ["NOW_KST"]
expires_kst = os.environ["EXPIRES_KST"]
renew_from_kst = os.environ["RENEW_FROM_KST"]
renew_before_days = os.environ["RENEW_BEFORE_DAYS"]
embed = {
"title": f"✅ [{env_name}] SSL 인증서 갱신 성공",
"description": "Nginx reload 완료",
"color": 0x2ECC71,
"fields": [
{"name": "🕐 갱신 시각", "value": f"`{now_kst}`", "inline": True},
{"name": "⏰ 다음 만료 예정", "value": f"`{expires_kst}`", "inline": True},
{"name": "📍 갱신 시작 가능", "value": f"`{renew_from_kst}` *(만료 {renew_before_days}일 전)*", "inline": False},
],
"timestamp": now_iso
}
print(json.dumps({"embeds": [embed]}, ensure_ascii=False))
PY
# ---- 마커는 사용 후 제거(중복 알림 방지) ----
rm -f "$MARKER"
// 권한 추가
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh
성공 알림은 “마커가 있어야” 전송되므로, 테스트는 다음처럼 한다.
sudo ls -1 /etc/letsencrypt/live
LINEAGE="/etc/letsencrypt/live/example.com"
KEY="$(printf '%s' "$LINEAGE" | sha256sum | awk '{print $1}')"
sudo mkdir -p /run/letsencrypt
date -Is | sudo tee "/run/letsencrypt/nginx_reload_ok_${KEY}" >/dev/null
sudo env ENV_NAME=PROD RENEWED_LINEAGE="$LINEAGE" \
/etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh
테스트 알림이 잘 도착했다! 지금은 갱신 시각, 다음 만료 날짜, 갱신 시작 가능 날짜가 언제인지 넣지 않아서 안떠있다.

성공은 certbot 훅으로 처리했지만, 실패는 systemd가 제일 잘 감지한다.
/usr/local/bin/notify-certbot-renew-failure.sh
sudo nano /usr/local/bin/notify-certbot-renew-failure.sh
#!/bin/sh
set -eu
TZ="Asia/Seoul"
export TZ
WEBHOOK_URL="$(cat /etc/discord/webhook.url)"
NOW_ISO="$(date -Is)"
NOW_KST="$(date '+%Y-%m-%d %H:%M:%S %Z')"
# 실패 상태 요약
RESULT="$(systemctl show certbot-renew.service -p Result --value 2>/dev/null || echo "unknown")"
CODE="$(systemctl show certbot-renew.service -p ExecMainCode --value 2>/dev/null || echo "unknown")"
STATUS="$(systemctl show certbot-renew.service -p ExecMainStatus --value 2>/dev/null || echo "unknown")"
# 최근 로그(너무 길면 디스코드 제한 걸려서 적당히 잘라 보냄)
JOURNAL="$(
journalctl -u certbot-renew.service -n 60 --no-pager 2>/dev/null || true
)"
LELOG="$(
tail -n 60 /var/log/letsencrypt/letsencrypt.log 2>/dev/null || true
)"
# JSON(Embed) 생성 + 전송
python3 - <<PY | curl -sS -H "Content-Type: application/json" -X POST -d @- "$WEBHOOK_URL" >/dev/null
import json, os
def clip(s: str, max_len: int = 900) -> str:
s = (s or "").strip()
if len(s) <= max_len:
return s
return "…(truncated)…\n" + s[-max_len:]
env_name = os.environ.get("ENV_NAME", "PROD")
now_iso = os.environ.get("NOW_ISO", "")
now_kst = os.environ.get("NOW_KST", "")
result = os.environ.get("RESULT", "unknown")
code = os.environ.get("CODE", "unknown")
status = os.environ.get("STATUS", "unknown")
journal = clip(os.environ.get("JOURNAL", ""), 900)
lelog = clip(os.environ.get("LELOG", ""), 900)
embed = {
"title": f"🚨 [{env_name}] SSL 인증서 갱신 실패",
"description": "certbot-renew.service 실행이 실패했습니다.",
"color": 0xE74C3C, # red
"fields": [
{"name": "발생 시각", "value": f"`{now_kst}`", "inline": True},
{"name": "서비스 상태", "value": f"`Result={result}, Code={code}, Status={status}`", "inline": False},
{"name": "최근 로그 (certbot-renew.service)", "value": f"```{journal}```", "inline": False},
{"name": "최근 로그 (/var/log/letsencrypt/letsencrypt.log)", "value": f"```{lelog}```", "inline": False},
],
"footer": {"text": "systemd OnFailure • certbot renew"},
"timestamp": now_iso
}
print(json.dumps({"embeds": [embed]}, ensure_ascii=False))
PY
sudo chmod +x /usr/local/bin/notify-certbot-renew-failure.sh
로그가 길면 디스코드 제한 때문에 일부 잘린다. 그래서 가장 핵심 원인에 밀접할 마지막 부분을 남기도록 했다.
/etc/systemd/system/certbot-renew-failed.service
sudo nano /etc/systemd/system/certbot-renew-failed.service
[Unit]
Description=Notify Discord when certbot-renew.service fails
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
Environment=ENV_NAME=PROD
ExecStart=/usr/local/bin/notify-certbot-renew-failure.sh
sudo systemctl edit certbot-renew.service
### Editing /etc/systemd/system/certbot-renew.service.d/override.conf
### Anything between here and the comment below will become the new contents of the file
# 여기에 아래처럼 추가
[Unit]
OnFailure=certbot-renew-failed.service
### Lines below this comment will be discarded
...
sudo systemctl daemon-reload // 반영하기
실제 실패를 만들기 전에, 실패 알림 서비스만 단독 실행해도 웹훅이 잘 가는지 검증 가능하다.
sudo systemctl start certbot-renew-failed.service
테스트 시 아래처럼 알림이 잘 왔다.

이제 인증서 만료를 LB 서버에 직접 들어가서 발견하는 일은 줄어든다.
운영 관점에서 중요한 건 “갱신 자체”보다 갱신 실패를 빠르게 감지하고 대응하는 체계였다. 비즈니스 로직 뿐 만 아니라, 이런 개발 인프라도 꾸준하게 개선해야 더 좋은 서비스를 제공할 수 있다는 것을 깨달은 경험이다.