website: CD 캐시 fix + DB 접근 문서화·셀프서비스 스킬 + 백업 자동화
스토리지 전환(#71)과는 별개로 밀려 있던 인프라 잡일들을 처리하면서, DB 접근 체계를 문서만 있는 상태에서 실제로 동작하는 상태로 끝까지 완성한 PR이다.
시작은 작은 것부터였다. 배포 후 서버에 컨테이너를 태그 없이 수동으로 재기동하면 옛날 이미지로 떨어지는 사고가 있었다. 원인을 보니 레지스트리의 -latest 태그는 CD가 매 배포마다 갱신하는데, 서버 로컬 캐시는 SHA 태그만 pull하고 -latest는 건드리지 않아서 최초 배포 시점 이미지에 그대로 얼어붙어 있었다. 시나리오 테스트로 수정 전/후를 확인한 뒤, 매 배포 후 -latest도 명시적으로 pull하도록 CD 워크플로에 한 줄 추가했다. 별거 아닌 수정이지만 이 함정 자체를 learnings에 남겨뒀다 — 나중에 똑같이 수동 재기동하다 당할 사람이 나올 수 있어서다.
여기서부터 DB 쪽으로 넘어갔다. 서비스가 SQLite 파일 기반이라 TablePlus 같은 GUI 클라이언트로 붙을 방법이 없고, SSH로 서버에 들어가서 파일을 여는 경로뿐이었다. 문제는 이 경로와 "여기까진 되고 여기부턴 안 됨"이라는 경계를 팀원이 매번 나한테 물어봐야 했다는 점이다. infra/db-access.md에 접속 방법, Flyway 경계(스키마 변경은 마이그레이션 파일 + PR로만, sqlite3 직접 실행 금지), 백업 전략을 정리하고, infra/.claude/skills/db-access/로 셀프서비스 스킬까지 만들어서 팀원이 Claude Code한테 물어보면 이 문서 기반으로 바로 답이 나오게 했다.
이 과정에서 제한 계정 이름을 dbviewer에서 dbclient로 바꿨다. 처음엔 "보기 전용" 느낌으로 이름을 지었는데, 실제로는 stage에서 DML(INSERT/UPDATE/DELETE)까지 허용되는 계정이라 이름이 실권한 범위와 안 맞았다. 이름 하나지만 팀원이 계정명만 보고 권한을 오해할 수 있어서 서버 계정까지 리네임했다.
백업은 문서에 설계만 있던 상태였다. 실제로 동작하게 만들려고 likelion-backups라는 프라이빗 버킷을 새로 팠다(기존 stage/prod 버킷은 Public이라 백업용으로 못 씀). 접근은 인프라 오너 계정 키를 그대로 쓰지 않고, 전용 IAM 그룹과 전용 서비스 계정을 새로 만들어 그쪽 Customer Secret Key로만 열었다. 블라스트 반경을 좁혀두려는 판단이었다.
업로드 도구를 고르다가 예상 밖의 문제를 만났다. aws-cli v2로 업로드를 붙였는데 같은 자격증명, 같은 명령인데도 방금 성공한 호출이 바로 다음번엔 SignatureDoesNotMatch로 실패하는 걸 실측으로 확인했다. aws-cli v2.23+의 awscrt 서명기가 OCI S3 호환 엔드포인트와 궁합이 안 좋다는 걸 이때 알게 됐다. boto3의 classic SigV4 서명으로 바꾸니 반복 테스트(5회 연속 put/delete)에서 실패가 없어서, backup_upload.py를 boto3 기반으로 새로 짜서 넘어갔다. 이런 건 문서로만 봐서는 안 걸리고 직접 붙여봐야 나오는 문제라, learnings에 정리해뒀다.
백업 스크립트는 cp 대신 SQLite 내장 .backup으로 스냅샷을 뜨고(쓰기 중에도 일관된 상태를 보장), 업로드 전에 PRAGMA integrity_check를 통과한 것만 올리게 했다. 로컬 3일 + 원격 30일 rotation을 cron으로 매일 03:00 KST(UTC 18:00)에 돌게 등록했고, 여기서 끝내지 않고 실제로 업로드된 백업을 다시 다운로드해 별도 경로에서 integrity_check와 테이블 목록 확인까지 해봤다. 설계만 있고 "이론상 되겠지"로 남겨두고 싶지 않아서 복원까지 실증한 셈이다.
문서화가 끝나갈 때쯤 보안 점검을 겸해서 코드와 인프라 설정(IAM 정책, 버킷 공개범위, 보안리스트, SSH 설정)을 전체적으로 훑었는데, 여기서 실제 취약점 하나를 발견했다. dbclient의 SSH forced command가 그냥 sqlite3 <db경로>였는데, sqlite3 CLI는 stdin으로 .shell/.system 같은 dot-command를 받으면 그 자리에서 임의 OS 명령을 실행한다. no-pty 옵션은 pty 할당만 막을 뿐 이 입력 자체는 막지 못해서, 다행히 아직 등록된 키가 없어 실제 악용 전이었지만 그대로 뒀으면 dbclient 키를 가진 사람이 셸을 얻을 수 있는 상태였다. infra/dbclient-sqlite-guard.sh라는 래퍼를 만들어서 stdin을 한 줄씩 검사해 dot-command, ATTACH DATABASE, 스키마 변경(ALTER/CREATE/DROP, 세미콜론으로 이어 붙이는 우회 포함)을 걸러낸 뒤에만 sqlite3로 넘기게 했다. SELECT 1; DROP TABLE x; 같은 세미콜론 우회까지 막아야 해서 문장을 세미콜론 단위로 쪼개 각각 검사하는 방식으로 짰다. 이걸 만들고 첫 테스트를 /tmp에서 했는데 정상적인 INSERT까지 막히는 이상한 결과가 나왔다. 원인을 보니 커널의 fs.protected_regular=2 하드닝 때문에 나온 가짜 실패였고, 실제 운영 환경과 같은 소유권 패턴(root:dbaccess, 2770/660)의 스크래치 디렉터리에서 재검증하니 정상 동작했다. 테스트 환경이 실제 조건을 재현하지 못하면 엉뚱한 결론에 다다를 수 있다는 걸 다시 확인한 셈이다.
이 래퍼를 적용하려고 권한을 만지다가 또 다른 문제를 발견했다. dbclient가 /home/ubuntu 자체를 통과할 권한이 없어서(그룹이 dbaccess뿐이라 ubuntu 그룹이 아님) 기존 설계 자체가 애초에 동작 불가능한 상태였다. chmod o+x로 열어버릴까 하다가, 그렇게 하면 그 아래 있는 ~/backups(cron이 만드는 백업 스냅샷, 당시 644로 world-readable)까지 같이 열린다는 걸 먼저 알아챘다. 실제 구독자 이메일이 들어있는 DB 스냅샷이 새고 있었던 셈이라, chmod 대신 setfacl로 dbclient 계정에만 통과 권한을 좁혀서 부여했다. 백업 스크립트 쪽도 기본 umask로 파일을 만들어 644로 떨어지고 있던 걸 디렉터리 700 + 파일 600으로 강제해서 같이 막았다.
stage/prod 접근 계정을 여러 명한테 나눠주는 과정에서도 한 번 잘못 짚었다. 같은 공개키를 authorized_keys에 stage용 한 줄, prod용 한 줄로 나눠 등록하면 한 사람이 stage와 prod 둘 다 접근할 수 있을 거라 생각했는데, 실제로 OpenSSH는 같은 공개키가 여러 줄이면 처음 매치되는 한 줄만 적용하고 나머지는 무시한다는 걸 테스트 키로 실측하고서야 알았다. 그래서 command=에 DB 경로를 고정하는 대신, 클라이언트가 ssh dbclient@host stage처럼 요청한 값을 SSH_ORIGINAL_COMMAND로 받아서 스크립트 안에서 stage/prod를 선택하도록 재설계했다. 한 줄 등록으로 stage+prod 둘 다 접근 가능해졌고, 특정 사람만 한쪽으로 제한하고 싶으면 인자를 고정하면 되는 구조다. 이 재설계 후 실 서버에서 임시 테스트 키로 전체 시나리오(stage/prod 선택, 인자 없음/잘못된 인자 거부, dot-command/DROP 차단)를 다시 검증하고, 안시현과 김우진(PM) 계정을 실제로 등록했다.
접근 대상 범위도 이 과정에서 확정했다. 백엔드(신선우, 안시현)와 PM(김우진, 콘텐츠 수급 확인에 실제로 필요)은 기본 등록 대상으로 하고, 프론트는 서버사이드 rewrite 프록시로 API만 거쳐 백엔드에 접근하는 구조라 DB를 직접 볼 구조적 이유가 없어서 기본 대상에서 뺐다. 필요해지면 그때 개별 예외로 열기로 했다 — 처음부터 넓게 열고 나중에 좁히는 것보다 안전한 방향이라 판단했다. 신선우는 GitHub에 등록된 SSH 키가 없어서 이번 PR 범위에서는 등록을 못 했고, 본인이 키를 생성한 뒤 전달받는 걸로 남겨뒀다.
Copilot 리뷰도 반영했다. SKILL.md의 상대경로 링크가 실제 파일 위치 기준으로 한 단계 부족해서 엉뚱한 경로로 해석되던 걸 고쳤고, learnings.md에 적어둔 "stage-latest 태그를 CD가 갱신한 적 없다"는 서술이 사실과 달라서(실제로는 CD가 갱신하고, 문제는 로컬 캐시가 pull 안 되는 쪽) 정정했다. 이런 사후 서술 오류는 나중에 같은 문서를 보고 잘못 판단할 수 있어서 발견 즉시 고쳐두는 편이다.
전체적으로 이번 작업은 "문서화해두면 끝"이 아니라 "문서에 적은 걸 실제로 서버에 적용하고 검증까지 마쳐야 끝"이라는 걸 반복해서 확인한 과정이었다. 백업도, 권한도, SSH forced command도 설계 단계에서는 맞아 보였는데 실제로 붙여보니 안 되거나 새는 지점이 있었다. 다음에 비슷한 인프라 작업을 할 땐 "설계상 맞다"에서 멈추지 않고 실제 조건(운영 환경과 같은 권한 패턴, 실제 자격증명, 실제 등록된 키)으로 한 번 더 돌려보는 걸 기본값으로 삼아야겠다.