성공이라고 적힌 배포가 셋이었고, 셋 다 다른 데로 가고 있었다

자동으로 배포되는 사이트가 몇 개 있다. 코스피 반등 신호를 매일 계산해서 올리는 곳, 그리고 이 블로그. 사람 손이 안 가도 알아서 최신이 된다. 정확히는, 그렇게 믿고 있었다.
시작은 질문 하나였다. "그 사이트 요즘 갱신은 되고 있나?"
하나 — 토큰은 진작 죽어 있었다
신호 사이트부터 봤다. 화면은 최신이었다. 로그도 매일 이렇게 끝나 있었다.
[KOSPI] {"asof": "...", "phase": "FTD", ...}
REST 배포 실패(HTTP Error 403: Forbidden) — vercel CLI로 폴백
배포: CLI 배포
마지막 줄만 보면 배포는 됐다. 그런데 그 위의 403은 어제오늘 일이 아니었다. 로그를 거슬러 올라가니 몇 주 내내 같은 줄이 찍혀 있었다.
토큰을 직접 확인해봤다.
{"error":{"code":"forbidden","message":"Not authorized","invalidToken":true}}
invalidToken: true. 원래 경로는 오래전에 죽어 있었고, 예비 경로가 매일 조용히 대신 뛰고 있었다.
예비가 잘 작동해서 문제가 안 보였던 게 아니라, 예비가 잘 작동해서 문제가 안 보였다.
예비 경로는 같은 일을 하지 않았다
여기서 더 나쁜 걸 찾았다.
원래 경로는 올릴 파일을 목록으로 지정해서 보낸다. 예비 경로인 CLI는 작업 폴더를 통째로 올린다. 둘의 업로드 범위가 다르다는 걸 나는 몰랐다.
200 /update_site.py
200 /fetch_data.py
내 파이썬 소스가 몇 주째 사이트에 공개돼 있었다. 토큰은 다행히 안 새어 나갔다. 플랫폼이 .env를 알아서 빼줬기 때문이지 내가 막아서가 아니다. 운이 좋았다.
.vercelignore를 만들어 소스를 뺐다.
예비는 원래와 같은 일을 하지 않는다. 조용히 성공하고 있어도 부작용까지 같은지는 따로 봐야 한다.
둘 — 고치다가 파일을 0바이트로 만들었다
예비 경로가 왜 이제 와서 실패하기 시작했는지도 찾았다. 스크립트가 npx vercel로 CLI를 부르는데, 캐시에 없으면 npx가 최신판을 새로 받아온다. 그 최신판은 기존 로그인을 못 읽는다. 로그인은 멀쩡한데 엉뚱한 실행 파일을 부르고 있었던 것이다.
고쳤다. 그리고 코드에 섞여 있던 유니코드 이스케이프를 한글로 되돌리는 스크립트를 돌렸다.
p.write_text(decoded)
UnicodeEncodeError. 이모지가 서로게이트 쌍이라 인코딩에 실패했다.
문제는 그다음이다. write_text는 파일을 먼저 비우고 나서 쓴다. 쓰기에서 죽었으니 비운 상태로 끝났다.
17KB짜리 소스가 0바이트가 됐다.
그리고 그 폴더는 git 저장소가 아니었다.
되찾은 경로
Time Machine 스냅샷이 36분 전에 찍혀 있었는데 마운트에 관리자 권한이 필요했다. 백업 스크립트도 없었다.
결국 어제 배포된 사이트에서 되찾았다. 배포 목록에서 전날 것을 잠깐 대표로 돌려놓고, 공개돼 있던 그 소스 파일을 내려받고, 바로 원복했다. 데이터가 같은 날 마감이라 화면은 그대로였다.
앞에서 "유출"이라고 부르며 막았던 그 결함이, 한 시간 뒤에 나를 살렸다.
폴더는 그날 git 저장소가 됐다.
덮어쓰기는 임시 파일에 쓰고 원자적으로 바꿔치기해야 한다. 그리고 버전 관리 안 되는 폴더에서 스크립트로 파일을 고치지 말 것.
셋 — 이 블로그가 사흘째 안 올라가고 있었다
마지막으로 블로그를 봤다. 매매일지 하나가 로컬에만 있고 사이트엔 없었다. 마지막 배포는 나흘 전.
이번엔 토큰도 멀쩡했고 로그에 실패도 없었다. 대신 이 줄이 반복돼 있었다.
반영할 체결 없음
정상적인 안내처럼 보인다. 실제로 정상이다. 그날 체결이 없었던 게 맞다.
문제는 코드였다.
if not targets:
print("반영할 체결 없음")
return
배포가 "오늘 새 체결이 있을 때"에만 걸려 있었다. 매매일지를 자동으로 채워 넣는 스크립트라 그렇게 짰는데, 그 사이 다른 경로로 생긴 글은 배포될 방법이 없었다. 봇이 아침에 만든 일지도, 내가 손으로 쓴 글도.
배포를 안 한 것과 배포에 실패한 것이 로그에서 똑같이 조용했다.
트리거를 바꿨다. 체결이 아니라 내용이 바뀌었는지를 본다.
now = content_hash(BLOG_DIR)
last = HASH_FILE.read_text() if HASH_FILE.exists() else None
if now == last:
print("배포 생략: content 변경 없음")
return
성공한 배포만 기준점이 된다. 실패하면 다음 실행이 알아서 다시 시도한다. 그리고 실패하면 텔레그램으로 온다. 로그는 아무도 안 읽으니까.
관통하는 것
세 개가 전부 다른 고장이었다. 만료된 토큰, 잘못 불린 실행 파일, 너무 좁은 조건.
그런데 셋 다 같은 자리에서 미끄러졌다.
- 신호 사이트: 로그에
배포: CLI 배포 - 블로그: 로그에
반영할 체결 없음 - 그 사이: 화면은 멀쩡했다
로그가 거짓말을 한 게 아니다. 각자 자기가 아는 사실만 정확히 말했다.
배포: CLI 배포는 CLI가 뭔가를 올렸다는 뜻이지, 원래 올려야 할 것을 올렸다는 뜻이 아니다.
반영할 체결 없음은 체결이 없었다는 뜻이지, 올릴 게 없었다는 뜻이 아니다.
그 이상을 읽은 건 나였다.
고친 목록
- 예비 경로가
npx대신 설치된 실행 파일을 직접 부른다. 다운로드가 사라지니 버전이 바뀔 일도 없다 - 업로드 범위에서 소스 제외
- 배포 트리거를 "체결 있음"에서 "내용 바뀜"으로
- 배포 실패 시 알림
- 버전 관리 안 되던 폴더 두 개를 저장소로
토큰 재발급은 아직 안 했다. 예비가 제대로 서 있으니 급하지 않은데, 그 문장이 이 글의 첫 문단과 똑같이 생겼다는 것도 알고 있다.
이 글은 세 번째 수정 덕분에 올라간다. 그 전이었으면, 오늘 체결이 없어서 안 올라갔을 것이다.