SSL 인증서 자동 갱신 모니터링 구축하기 (Discord 알림)

o_z·2026년 2월 27일
post-thumbnail

오늘 서비스의 cerbot SSL 인증서가 만료됐다. 만료된 사실을 우리 서비스에 직접 들어가고서야 알게 됐다. 만약 우리가 직접 서비스에 안들어가봤다면 들어갈 때까지 몰랐을 것이다. ssl 인증서 만료로 인해 서비스 전체가 마비됐고, 즉시 certbot renew로 살렸다.

이런 일이 재발하는 것을 예방하기 위해 자동 갱신 스케줄러를 추가했지만, 사후대책이 필요했다.
실제로 언제 갱신됐는지, 갱신이 실패하진 않았는지 매번 시스템 로그로 확인해야한다.

ssl 인증서가 갱신되는 타이밍을 더 쉽게 확인하고, 만약 모종의 이유로 실패한다면 이를 빠르게 개발자들에게 알리는 장치가 필요했다. 인증서 갱신 시도 시 실패가 발생하면 적어도 약 30일간의 복구 여유 기간이 있기 때문이다. (certbot 인증서 재발급은 만료기간으로부터 30일 전부터 할 수 있다.)

우리는 핵심 소통 창구로 디스코드를 사용하고 있으므로 Discord Webhook을 통해 인증서 발급 상태를 가시화 하고, 만료로 인한 서비스 중단 리스크를 낮추고자 한다.


📝 알림 전송 파이프라인

큰 설계는 다음과 같다. (자동갱신은 systemd timer로 구성돼 있다.)

  1. systemd timer가 정해진 시각에 certbot-renew.service를 실행한다.
  2. certbot renew는 "갱신 시도(실행)”만 하고, 실제로 갱신이 필요할 때만 인증서를 갱신한다.
  3. 실제로 갱신된 인증서가 있을 경우에만, certbot이
    /etc/letsencrypt/renewal-hooks/deploy/* 안의 실행 파일들을 순서대로 실행한다.
  4. deploy-hook에서
    • nginx -tnginx reload를 수행하고,
    • 성공했을 때만 “성공 알림”을 디스코드로 전송한다.
  5. certbot renew 자체가 실패하거나, deploy-hook에서 nginx reload가 실패하면 종료 코드는 0이 아닌 값으로 끝난다.
  6. systemd는 이를 실패로 판정하고, OnFailure로 연결된 알림 서비스가 실패 알림을 디스코드로 전송한다.

📍핵심 설계

  • 성공 알림: "갱신됨 + nginx reload까지 성공”일 때만
  • 실패 알림: certbot-renew.service 및 nginx reload가 실패(exit code ≠ 0)일 때

1️⃣ Discord Webhook url 파일 추가하기

먼저 공통으로 사용할 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

2️⃣ 갱신 성공 시 webhook 알림 전송하기

성공 기준은 단순히 “certbot 갱신 성공”이 아니라:

certbot renew가 실제 갱신을 수행했고 + nginx reload까지 성공

그래서 deploy-hook을 2단으로 나눴다.

  • 10-reload-nginx.sh : reload 성공 여부를 마커로 남김
  • 20-notify-discord-success.sh : 마커가 있을 때만 성공 알림 전송

2-1) 10-reload-nginx.sh: nginx reload 성공 마커 남기기

/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은 런타임 디렉토리라 재부팅 시 자동으로 비워진다.
“이번 갱신 사이클에서만 쓰는 성공 신호”로 적합하다.

2-2) 20-notify-discord-success.sh: 성공 알림 전송

/etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh

  • 마커가 없으면 reload 성공 보장 불가로 조용히 종료한다.
  • 갱신 시각 / 다음 만료 / 갱신 시작 가능 날짜만 embed로 전송한다.
  • 성공 후 중복 알림 방지를 위해 마커는 삭제한다.
#!/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

2-3) 성공 웹훅 테스트(실제 갱신 없이)

성공 알림은 “마커가 있어야” 전송되므로, 테스트는 다음처럼 한다.

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

테스트 알림이 잘 도착했다! 지금은 갱신 시각, 다음 만료 날짜, 갱신 시작 가능 날짜가 언제인지 넣지 않아서 안떠있다.


3️⃣ 갱신 실패 시 알림 전송하기 (systemd OnFailure)

성공은 certbot 훅으로 처리했지만, 실패는 systemd가 제일 잘 감지한다.

  • certbot-renew.service가 실패하면(exit code ≠ 0)
  • OnFailure=certbot-renew-failed.service가 실행되고
  • 디스코드로 실패 알림을 보낸다.

3-1) 실패 알림 스크립트 만들기

/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

로그가 길면 디스코드 제한 때문에 일부 잘린다. 그래서 가장 핵심 원인에 밀접할 마지막 부분을 남기도록 했다.

3-2) 실패 알림 systemd service 만들기

/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

3-3) certbot-renew.service에 OnFailure 연결하기

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 // 반영하기

3-4) 실패 알림 테스트

실제 실패를 만들기 전에, 실패 알림 서비스만 단독 실행해도 웹훅이 잘 가는지 검증 가능하다.

sudo systemctl start certbot-renew-failed.service

테스트 시 아래처럼 알림이 잘 왔다.


이제 인증서 만료를 LB 서버에 직접 들어가서 발견하는 일은 줄어든다.

  • 인증서가 실제로 갱신되면 디스코드에 ✅ 성공 알림(갱신 시각/만료일/갱신 가능 시작일)이 온다.
  • 갱신이 실패하면 systemd가 🚨 실패 알림을 보내서, 로그를 서버에 접속하지 않고도 바로 확인할 수 있다.

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

profile
트러블슈팅과 구현기를 위주로 기록합니다-

0개의 댓글