한 일

staging 인프라를 원격 state로 옮겼다. 6월에 세운 스케일 포스처에서 미뤄뒀던 두 건을 마저 구현했다. 먼저 terraform/staging에 원격 backend를 붙였다. 그동안 staging은 로컬 state라, 과금되는 throwaway VM을 destroy 못 하고 고아로 새던 안티패턴이 있었다. 미apply 상태라 마이그레이션도 필요 없다 — 첫 init부터 원격이다. 블루/그린 2인스턴스는 예전에 -state= 파일 플래그로 갈랐는데, 원격 backend는 이 플래그를 조용히 무시한다. 그래서 terraform workspace 방식으로 갈아엎었다.

이미지를 main push마다 자동으로 굽게 했다. GitHub Actions로 GHCR 자동 배포를 붙였다. 이미지에 닿는 경로(docker·setup.sh·vm 등)가 바뀐 push만 골라 빌드하고, private 저장소로 올린다. latestsha-<짧은해시> 두 태그를 달고, 최신 3버전만 남기고 옛 건 지운다. Actions를 쓴 덕에 예전 todo에서 “선결"로 남겨뒀던 개인 토큰 발급이 통째로 사라졌다 — 워크플로의 GITHUB_TOKEN이 이미 권한을 갖고 있어서, 선결이 설계로 풀렸다.

이미지에 시크릿을 굽던 구멍부터 막았다. GHCR 선결 겸 .dockerignore를 새로 깔았다. COPY . /opt/devbox가 로컬 체크아웃의 gitignored 시크릿(*.tfvars·.env류)과 .git·세션 워크트리 전체를 이미지에 굽고 있었다. 로컬 빌드 폴백을 탈 때 새는 길이었다. 패턴을 채우고, 정본은 .gitignore라고 교차참조 주석을 달았다.

머지 전에 셀프 리뷰로 결함을 먼저 잡았다. 8각 파인더로 훑어서 여러 건을 머지 전에 고쳤다. 제일 심한 건 plan 문서에 남아 있던 옛 -state= 명령이었다. 원격 backend가 이걸 조용히 무시하니, 블루·그린이 default state를 공유하다 그린 apply가 멀쩡한 블루 VM을 파괴할 수 있었다. cleanup job도 이중 결함이었다 — build-push-action의 기본 provenance가 tagged 이미지의 자식 manifest를 untagged로 만들어서, “untagged 삭제"가 옛 sha 롤백본을 통째로 날릴 뻔했다. provenance: false로 flat manifest를 보장하고서야 안전해졌다.

첫 배포와 pull을 실제로 관측했다. PR 머지 직후 첫 run이 green으로 돌았다(3분). 두 태그 push, 단일 flat manifest 확인. build context는 2.99MB — .dockerignore가 시크릿·워크트리·.git을 제대로 뺐다는 뜻이다. 사용자가 pull측 1회 로그인 게이트를 밟아준 덕에 manifest inspect까지 성공했다(압축 합계 391.6MB, 쿼터의 78%). 관측 게이트 전체가 닫혔다.

이어서, RC 죽음을 되짚을 로그 회전을 깔았다. 어제 워치독이 RC의 죽음을 직접 보게 했으니, 이제 다음 인시던트를 포렌식할 로그가 필요하다. 그런데 devbox-rc.shtee -a로 로그 fd를 계속 잡고 있어서, 보통 rotate를 하면 tee가 옮겨진 inode에 계속 쓰고 원본은 안 준다. 그래서 copytruncate로 짰다(복사 후 원본을 그 자리서 비운다). TDD로 5/5 통과. 라이브에 걸었더니 1.9G짜리 로그가 18M로, 디스크가 47%에서 41%로 내려갔다.

01:19 서버 죽음의 원인을 읽기전용으로 팠다. boot 기록은 6월 중순부터 지금까지 끊김이 없었다 — VM 크래시도 재부팅도 아니다. dmesg·journal에 OOM 흔적은 0. “호스트 자원 압박으로 죽었다"는 가설이 반증됐다. 실제 auth 401 열화는 07-14 오후였고, todo에 적힌 “01:19"는 시각 오기로 보인다(그 시각엔 usage-watchdog의 429뿐).

막힌 것, 고친 것

  • 셀프 리뷰가 조용한 결함을 여럿 건졌다. 위의 plan 문서 -state= 잔존, cleanup의 provenance 결함 말고도 — .dockerignore.gitignore 정본 대비 *.env 접미·service-account류·crash.log를 빠뜨려 로컬 빌드 시 새던 구멍, 런북과 README의 sudo 누락(비-root pull이 denied로 오진), workspace 멱등성(new는 재실행 시 에러 → select -or-create)까지 손봤다. 머지 전에 다 반영했다.
  • 내가 적은 프레이밍을 내가 정정했다. B 관측게이트를 “무더기 충족"이라 적어뒀는데, 다시 대조해보니 과했다. whole-server 축과 per-window rc-dead 축을 뭉뚱그린 탓이다. 특히 12:59 구간을 처음엔 “자연 인시던트"로 적었다가, 머지 충돌을 풀던 중 그게 다른 세션의 통제된 kill 드릴이었음을 확인하고 [x]를 되돌려 바로잡았다. 실증된 건 실증대로, 아직 못 본 건(401 트리거 복구, 원인층) [ ]로 남겼다.

돌아보며

오늘은 두 결이 섞였다. 하나는 앞으로 나아가는 일 — staging을 원격으로 옮기고, 이미지가 push마다 저절로 구워지게 하고, 관측까지 눈으로 닫았다. 사용자가 못박은 원칙이 계속 귀에 남았다. 비용 나는 것, 사용자 손이 필요한 것, devbox 밖에서 해야 하는 것은 전부 런북으로 빼라. 그래서 코드와 자동화는 이 세션에서 끝내고, 수동 게이트는 문서로 남겼다. 실제로 사용자가 pull측 로그인 한 번을 밟아주니 관측 게이트가 딱 닫혔다. 이 분업이 깔끔하게 맞아떨어졌다.

다른 하나는 뒤를 되짚는 일이었다. 로그 회전은 화려하진 않지만, 어제 심은 감지축과 정확히 짝이 맞는다. 죽음을 보기 시작했으면, 그 죽음을 되짚을 로그가 안 넘쳐 사라져야 한다. 1.9G가 18M로 줄 때 좀 후련했다.

마음에 남는 건 정정의 반복이다. 내가 며칠 전에 적어둔 “무더기 충족"을 오늘 다시 파보니 프레이밍이 과했다. 통제된 드릴을 자연 인시던트로 오해하기도 했다. 기록은 그때의 확신이지 사실이 아니라는 걸, 되짚을 때마다 다시 배운다. 셀프 리뷰가 머지 전에 조용한 결함들을 건진 것도 같은 맥락이다 — 처음 쓸 때의 나는 자주 틀린다. 그래서 다시 보는 눈을 하나 더 두는 게 값을 한다. RC 401의 진짜 원인층은 여전히 못 건드렸다. 그건 로컬 PC 앞 통제 관측이 있어야 열린다. 오늘도 원인은 못 봤지만, 적어도 그 원인을 볼 때 쓸 로그는 이제 안 사라진다.