블로그 문제 해결

GitHub 토큰 만료로 멈춘 자동 발행 찾기: 결과물 헬스체크와 컨테이너 재생성

GitHub fine-grained PAT 기한이 지나 블로그 자동 발행이 세 번 실패했고, 알아챈 건 닷새 뒤였습니다. 401이 났을 때 확인하는 순서, 컨테이너에 새 토큰을 넣는 방법, 결과물 기준 헬스체크를 적었습니다.

블로그 글을 자동으로 올리는 파이프라인이 8월 10일, 12일, 14일 세 번 연달아 실패했습니다. 원인은 코드가 아니라 저장소에 글을 커밋할 때 쓰던 GitHub fine-grained PAT(개인 접근 토큰)의 기한이 지난 것이었고, 저는 그걸 닷새 뒤 주간 회고를 하다가 블로그에 새 글이 없는 걸 보고 알았습니다.

고친 순서는 이렇습니다. 새 토큰을 발급하면서 만료일을 달력에 넣었고, 서버의 .env 를 바꾼 뒤 컨테이너를 재시작이 아니라 재생성했고, 컨테이너 안에서 GitHub API를 불러 200 이 나오는 걸 확인했습니다. 세 편은 원래 계획한 날짜 그대로 다시 발행했습니다. 그리고 다음에 또 이런 일이 생기면 바로 알 수 있게, 확인 기준을 “실행됐는가”에서 “결과물이 나왔는가”로 바꿨습니다.

스케줄러와 본문 생성은 성공하고 마지막 GitHub 커밋 단계에서만 401이 나서, 실행 기준 확인은 초록이고 결과물 기준 확인은 실패로 갈리는 흐름

자동 발행이 마지막 커밋 단계에서만 401로 실패한 이유

파이프라인은 세 단계였습니다. 스케줄러가 정해진 시각에 돌고, LLM이 본문을 끝까지 쓰고, 마지막에 GitHub Contents API로 저장소에 커밋합니다. 이번에는 앞의 두 단계는 매번 정상이었고 마지막 커밋에서만 401 이 났습니다.

토큰이 필요한 곳이 마지막 한 칸뿐이라 이렇게 됩니다. 스케줄러도 돌았고 글도 써졌으니 “돌고 있다”는 신호는 계속 초록이었고, 실제로 저장소에 올라간 글은 없었습니다. 손실은 블로그 세 편이 제때 안 나간 것까지였는데, 그동안 아무도 몰랐습니다.

401 은 권한이 없다는 답만 줍니다. 토큰이 만료됐는지, 권한 범위가 모자란지, 값이 잘못 들어갔는지는 알려 주지 않습니다. 그래서 아래 순서로 하나씩 지워 나갔습니다.

GitHub PAT가 만료되면 어떻게 되나

GitHub 공식 문서에 따르면 토큰은 만료일이 되면 자동으로 폐기되고, 그 뒤로는 Git과 API 요청 인증에 쓸 수 없습니다. 만료되거나 폐기된 토큰은 되살릴 수 없어서 새 토큰을 만들어야 합니다(2026년 10월 확인).

개인 접근 토큰 관리 문서에는 이런 내용도 있습니다(2026년 10월 확인).

  • 만료 없는 토큰도 만들 수 있지만, 조직이나 엔터프라이즈가 최대 기간 정책을 걸어 두면 막힙니다.
  • 1년 동안 한 번도 쓰지 않은 토큰은 GitHub가 자동으로 지웁니다.
  • GitHub는 토큰에 만료일을 넣으라고 권합니다.

저는 새 토큰을 만료 1년으로 발급했습니다. 기한을 없애지 않고 길게 잡은 뒤 달력으로 챙기기로 했습니다.

401이 났을 때 확인하는 순서

1. 토큰 값 자체가 살아 있는지 본다

컨테이너 밖에서, 지금 쓰려는 토큰으로 GitHub API를 직접 부릅니다. 200 이 나오면 토큰은 살아 있고, 401 이면 만료·폐기됐거나 값이 잘못 들어간 토큰입니다.

curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/user

GitHub 웹의 Settings → Developer settings → Personal access tokens 화면에서도 토큰별 만료일을 볼 수 있습니다.

2. 컨테이너 안에 들어간 값이 같은지 본다

밖에서 200 이 나와도 컨테이너 안은 다를 수 있습니다. 저는 서버 .env 의 토큰을 새 값으로 바꾼 뒤에도 컨테이너 안에서는 /user 가 계속 401 이었습니다. 컨테이너는 아직 옛 토큰을 들고 있었고, 파일만 보고 됐다고 생각한 바람에 반나절을 옛 토큰으로 돌렸습니다. 이 부분은 아래 절에 따로 적었습니다.

3. 권한 범위를 본다

토큰이 살아 있는데도 커밋에서 막히면 fine-grained 토큰에 그 저장소가 포함돼 있는지, 콘텐츠 쓰기 권한이 있는지를 봅니다. 이번 일은 만료가 원인이라 여기까지 오지 않았습니다.

컨테이너 환경변수를 바꿨는데 반영이 안 될 때

환경변수는 컨테이너를 만들 때 들어갑니다. 재시작은 안에 있는 프로그램만 다시 켜는 거라 .env 파일을 다시 읽지 않습니다. 그래서 값을 바꿨으면 컨테이너를 지우고 새로 만들어야 합니다.

명령새 env 반영
docker restart · docker compose restart안 됨
docker compose up -d --force-recreate됨
docker rm -f 후 docker run됨

compose를 쓰면 이렇게 재생성합니다.

# 재시작(restart)으로는 env가 안 바뀐다 — 재생성해야 한다
docker compose up -d --force-recreate blog-worker

재생성한 뒤에는 컨테이너 안에서 실제 값을 확인합니다. 토큰 전체를 화면에 찍지 않도록 앞 4자리만 보거나, 인증이 통과하는지를 봅니다.

# 컨테이너 안의 실제 값 검증 (앞 4자리만 노출)
docker compose exec blog-worker \
  sh -c 'echo "${GITHUB_TOKEN%${GITHUB_TOKEN#????}}..."'

# 또는 실제로 인증이 통과하는지 최종 확인
docker compose exec blog-worker \
  sh -c 'curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer $GITHUB_TOKEN" \
    https://api.github.com/user'

200 이 나오는 걸 보고 나서 조치를 끝냈습니다.

따로 적어 둔 재생성 명령이 원본 설정과 어긋날 때

고치는 중에 한 번 더 막혔습니다. 저는 compose 파일과 별도로, 컨테이너를 다시 띄우는 명령을 셸 스크립트에 적어 두고 쓰고 있었습니다. 이 스크립트가 compose와 어긋나 있어서 마운트 두 줄(Google Sheets 서비스 계정 키, 문서 폴더)이 빠진 채로 컨테이너가 다시 만들어졌습니다.

컨테이너는 멀쩡히 떴습니다. 대신 시트를 읽는 단계에서 토큰 발급이 실패했고, 발행할 목록이 빈 채로 돌아와서 SNS와 블로그 발행이 둘 다 멎었습니다. 마운트를 되돌리고 나서야 정상으로 돌아왔습니다. compose 파일 주석에 같은 마운트가 한 번 빠졌다가 복구됐다는 기록이 이미 있었으니, 같은 일이 두 번째였습니다.

포트나 이미지가 빠지면 바로 티가 나는데, 마운트나 환경변수가 빠지면 컨테이너 상태는 정상으로 나오고 그걸 쓰는 기능만 안 돕니다. 원본 설정을 고칠 때 복사본도 같이 고치거나, 복사본을 없애고 원본 하나로만 띄우는 편이 안전합니다. 저는 스크립트에 compose와 맞춰 두라는 주석을 넣었습니다.

헬스체크는 실행이 아니라 결과물로 본다

이번에 빠져 있던 건 “오늘 커밋된 글이 실제로 저장소에 있는가”를 보는 확인이었습니다. 스케줄러가 트리거됐는지, 프로세스가 시작됐는지는 앞쪽 신호라서 마지막 한 칸의 실패를 못 잡습니다. 확인은 실행이 끝난 뒤 결과물을 되짚게 짭니다.

# "돌았는가"가 아니라 "결과물이 나왔는가"를 본다
today=$(date +%Y-%m-%d)
latest=$(git -C /srv/blog log -1 --format=%cd --date=short)

if [ "$latest" != "$today" ]; then
  echo "WARN: 오늘($today) 커밋된 글이 없음 (마지막: $latest)"
  # 여기서 알림 — 프로세스가 아니라 산출물 기준
fi

블로그가 아니어도 같습니다. 백업이라면 “백업이 돌았나”가 아니라 “어제 날짜 파일이 있나”를 보고, 배포라면 “워크플로가 돌았나”가 아니라 “서버에 새 버전이 떠 있나”를 봅니다. 백업을 이 기준으로 확인하게 된 이야기는 한 달 동안 조용히 안 돌던 백업에 적었습니다.

재시도로 안 풀리는 실패는 실패 상태로 끊는다

같은 주에 SNS 예약 발행도 세 건이 안 나갔습니다. 원인은 토큰이 아니라 본문이 플랫폼의 글자수 상한(500자)을 넘긴 것이었는데, 처리 방식이 블로그와 달라서 문제가 커졌습니다.

블로그 경로는 실패하면 발행 시트의 상태를 실패 로 바꾸고, 텔레그램으로 알리고, 만든 초안을 파일로 남겼습니다. SNS 경로에는 실패 상태 표시가 없어서 실패해도 상태가 그대로 남았고, 10분마다 도는 폴링이 같은 행을 다시 집었습니다. 그렇게 같은 실패를 60번 넘게 반복했고 알림도 그만큼 왔습니다. 처음 몇 번은 신호였지만 금방 배경 소음이 됐습니다.

만료된 토큰이나 글자수 초과처럼 다시 해도 똑같이 실패하는 일은 재시도로 풀리지 않습니다. 이런 실패는 상태를 실패 로 바꿔 흐름을 끊고, 사람이 고친 뒤 다시 돌리게 합니다. 폴링이 집어야 할 건 “승인됐고 아직 발행 안 된 행”이지 “이미 실패한 행”이 아닙니다. 이후 SNS 경로에도 실패 상태 표시와 발행 전 글자수 검사를 넣었습니다. 에러를 어디서 받아 어떻게 알릴지는 조용한 실패가 제일 비싸다에서 더 자세히 다뤘습니다.

만료일 있는 토큰·인증서 관리하기

토큰, 인증서, 결제 카드처럼 기한이 있는 것은 발급 화면을 닫는 순간 잊어버립니다. 그래서 두 가지를 정했습니다.

  • 새로 발급하는 자리에서 만료일을 바로 달력에 넣습니다.
  • 기한 있는 자격증명을 한곳에 목록으로 모아 둡니다. 어디에 쓰는지, 어느 서버의 어느 파일에 들어 있는지까지 같이 적습니다.

이것만으로 다 막히지는 않았습니다. 열흘 남짓 뒤 배포가 멈춘 걸 알아채는 감지기를 붙였더니, 컨테이너 이미지 저장소(ghcr)에 쓰던 GitHub PAT가 만료돼서 자동 배포가 2주째 멈춰 있던 게 바로 나왔습니다. 목록에서 빠져 있던 GitHub 토큰이 하나 더 있었고, 이번에도 에러 없이 멈춰 있었습니다. 그래서 목록과 함께 결과물을 보는 확인도 같이 둡니다.

이 방법으로 안 되는 것

  • 달력 알림은 사람이 봐야 의미가 있습니다. 바쁜 주에 놓치면 결과물 헬스체크가 마지막 안전망입니다.
  • 결과물 헬스체크는 “오늘 결과물이 있어야 한다”는 기준을 정할 수 있는 일에만 맞습니다. 불규칙하게 도는 작업은 기준부터 따로 정해야 합니다.
  • 조직 정책으로 토큰 최대 기간이 정해져 있으면 1년짜리 토큰을 못 만들 수 있습니다. 그만큼 갱신 주기가 짧아집니다.
  • 같은 토큰을 여러 서버나 컨테이너에 넣어 두었다면 갱신할 때 전부 재생성해야 합니다. 한 곳만 바꾸면 나머지는 옛 토큰으로 계속 실패합니다.

자주 묻는 질문

자동화가 돌고 있는지만 확인하면 왜 부족한가요?

스케줄러가 제시간에 돌고 앞 단계가 다 성공해도 마지막 한 칸에서만 실패하면 산출물은 나오지 않습니다. "실행됐는가"는 초록으로 보이지만 정작 결과물이 없을 수 있으므로, 헬스체크는 "산출물이 실제로 나왔는가"를 기준으로 잡아야 합니다.

컨테이너 환경변수를 바꿨는데 왜 재시작으로는 안 되나요?

환경변수는 컨테이너 생성 시점에 박혀 들어가기 때문에 재시작만으로는 이전 값이 유지됩니다. 토큰 같은 값을 바꿨다면 컨테이너를 재생성해야 하고, 재생성 후에는 컨테이너 안에서 실제 값이 반영됐는지 직접 확인하는 것이 안전합니다.

자동화 실패를 자동 재시도로 두면 안 되나요?

만료된 토큰처럼 재시도로 풀리지 않는 실패는 폴링이 같은 작업을 반복해서 집어 하루에 수십 번 같은 실패와 알림을 반복하게 됩니다. 이런 실패는 상태를 실패로 전환해 흐름을 끊고 수동으로 재개하는 편이 소음을 막습니다.

만료되는 자격증명은 어떻게 관리하나요?

토큰, 인증서, 결제수단처럼 만료일이 있는 자격증명을 목록으로 만들고 각 만료일을 달력에 등록해 두는 것이 기본입니다. 새 토큰을 발급할 때 만료일을 함께 캘린더에 넣어 두면, 조용히 멈추기 전에 미리 갱신할 수 있습니다.

#자동화#토큰 만료#헬스체크#1인 개발#컨테이너