첫 반영에서 멈췄다
콘텐츠 자동화는 모의 테스트에서 정상적으로 동작했다. GitHub Webhook을 받은 Spring Boot가 요청을
검증하고 작업 파일을 만들면, 서버의 콘텐츠 Worker가 houkago.posts를 갱신한 뒤 일회성 동기화를
실행하는 구조였다.
첫 실제 수정에서도 중간까지는 예상대로였다. Webhook은 HTTP 202를 반환했고, 작업은 incoming에서
processing으로 이동했다. Git fetch와 fast-forward merge도 성공해 운영 checkout은 새 commit에
도달했다.
문제는 그다음에 발생했다. 동기화 scanner가 새 Markdown 파일을 읽는 순간
AccessDeniedException을 내며 중단됐다. Git은 파일을 정상적으로 갱신했지만 동기화 컨테이너는 그
파일을 읽지 못했다.
실패 지점이 분명했기 때문에 Webhook, Nginx나 Git 이력을 다시 건드릴 필요는 없었다. 데이터베이스를
변경하기 전이었으므로 애플리케이션과 MySQL도 그대로 유지했다. 실패한 작업은 삭제하지 않고
failed에 남겼다.
원인은 파일 권한이었다
운영 서버에서는 파일을 만드는 쪽과 읽는 쪽이 달랐다.
- systemd의 콘텐츠 Worker가 Git 명령을 실행한다.
- Git은 운영 checkout에 새 파일을 만든다.
- 동기화는 Docker Compose에 정의된 별도 UID와 GID로 실행된다.
문제가 된 파일의 권한은 0660이었다. 소유자와 그룹에는 읽기 권한이 있었지만, 그 그룹에 속하지 않은
동기화 컨테이너에는 읽기 권한이 없었다.
이 권한을 만든 것은 콘텐츠 Worker의 systemd 설정에 있던 UMask=0007이었다. 작업 파일과 잠금 파일을
외부 사용자에게 노출하지 않기 위한 보안 설정이었지만, Worker가 실행한 Git에도 그대로 전달됐다.
일반 파일의 기본 권한 0666에 0007이 적용되면 결과는 0660이 된다. Git이 checkout에서 파일을
새로 만들거나 교체할 때도 같은 영향을 받았다.
UMask=0007
-> Git이 새 Markdown을 0660으로 생성
-> 별도 UID/GID의 동기화 컨테이너가 읽지 못함
-> AccessDeniedExceptionUMask=0007 자체가 잘못된 것은 아니었다. 보호해야 하는 운영 파일과 다른 프로세스가 읽어야 하는 글
원본에 같은 권한 정책을 적용한 것이 문제였다.
테스트가 놓친 조건
기존 모의 테스트는 잘못된 작업 거부, 잠금 충돌, fast-forward 갱신과 동기화 실패 보존을 확인했다. 하지만 테스트용 Markdown은 미리 읽을 수 있는 권한으로 만들어져 있었다.
실제 systemd의 UMask=0007 아래에서 Git이 파일을 만드는 과정은 재현하지 않았다. 테스트를 실행한
사용자가 파일을 읽을 수 있다는 사실도 별도 계정으로 실행되는 동기화 컨테이너의 권한을 보장하지
않았다.
빠져 있던 검증은 두 가지였다.
UMask=0007아래에서 Git이 어떤 권한으로 파일과 디렉터리를 만드는가- 실제 동기화 컨테이너가 갱신된 Markdown을 읽을 수 있는가
운영과 같은 명령을 호출하는 것만으로는 부족했다. 그 명령이 만든 파일의 권한과 실제로 파일을 읽는 계정까지 같아야 했다.
Git에만 다른 권한을 적용했다
가장 쉬운 해결은 systemd의 UMask를 0022로 바꾸는 것이었다. 그러나 그렇게 하면 Worker가 만드는 작업
파일, 재시도 기록과 잠금 파일까지 더 넓은 권한을 갖게 된다.
Checkout 전체에 재귀적으로 chmod를 실행하는 방법도 피했다. 동기화할 때마다 필요 이상으로 넓은
범위를 바꾸고, 어떤 파일의 권한이 왜 달라졌는지 알기 어려워지기 때문이다.
대신 Git 명령만 별도의 subshell에서 실행했다.
run_checkout_git() {
(
umask 0022
git -C "$POSTS_ROOT" "$@"
)
}Git이 원본 파일을 만드는 동안에는 0022를 사용하고, 명령이 끝나면 Worker는 다시 기존 0007 정책을
따른다. 이후 Git이 만든 일반 파일은 0644, 디렉터리는 0755가 되어 동기화 컨테이너도 읽고 접근할 수
있었다.
회귀 테스트에서는 바깥쪽 UMask를 0007로 둔 채 Git 갱신을 실행했다. Git이 만든 파일과 디렉터리는
각각 0644, 0755인지, wrapper 밖에서 만든 파일과 디렉터리는 기존대로 0660, 0770인지 확인했다.
권한 완화가 Git 바깥으로 새지 않는 것도 함께 고정했다.
실제 동기화 계정으로 확인했다
숫자로 표시된 권한만 확인하면 Docker mount나 디렉터리 접근 권한 문제를 놓칠 수 있다. 그래서 Git 갱신 뒤, 일회성 동기화를 시작하기 전에 운영과 같은 Compose 서비스로 짧은 사전 점검을 실행했다.
점검 대상은 scanner가 실제로 읽는 index.md다. 숨김 경로와 assets, node_modules, dist, build
등 scanner가 제외하는 디렉터리는 건너뛴다.
읽을 수 없는 파일이 하나라도 발견되면 동기화를 시작하지 않는다. 안전한 상대 경로만 기록하고 작업을
checkout_unreadable 상태로 실패 처리한다. 중요한 점은 운영 서버 사용자가 아니라 실제 동기화 이미지,
mount와 UID/GID로 검사한다는 것이다.
실패한 작업을 복구했다
처음 실패한 작업은 File Spool에 그대로 남아 있었다. 문제를 일으킨 변경은
algorithm/boj/2461/index.md에서 최소값을 최솟값으로 고친 한 단어 수정이었다.
Worker를 고친 뒤 운영자 재시도 명령으로 기존 작업을 다시 incoming에 넣었다. 재시도 전의 작업은
시각이 포함된 기록으로 failed에 보존했고 파일 권한도 0640으로 유지했다.
두 번째 실행에서는 Git 갱신, 파일 읽기 점검, 일회성 동기화와 공개 API 확인을 모두 통과했다. 당시 결과는 다음과 같았다.
CREATED=0
UPDATED=1
TOUCHED=264
DELETED=0수정한 본문은 공개 API에서도 확인할 수 있었다. 애플리케이션과 MySQL 컨테이너의 ID와 재시작 횟수는 바뀌지 않았다. 재시도부터 동기화 완료까지는 약 19초, API에서 변경을 확인하기까지는 약 17초가 걸렸다. 두 값은 당시 복구 과정에서 관찰한 수치다.
같은 delivery ID를 다시 보내는 GitHub 재전송은 계속 중복 실행하지 않는다. 반면 운영자 재시도는 실패 원인을 해결한 뒤 보존된 작업을 명시적으로 다시 실행한다. 자동 중복 방지와 장애 복구를 서로 다른 동작으로 유지했다.
코드 밖의 조건도 테스트해야 한다
이 장애 이후에는 세 가지를 회귀 테스트에 추가했다.
UMask=0007아래에서 Git이 파일0644, 디렉터리0755를 만드는지 확인한다.- 운영 서버 사용자가 아니라 실제 동기화 서비스로 파일 읽기 권한을 검사한다.
- 읽을 수 없는 파일이 있으면 동기화를 시작하지 않고 작업을
failed에 보존한다.
운영자 재시도 과정에서 이전 실패 기록과 파일 권한 0640이 유지되는지도 확인했다.
이번 문제는 동기화 코드나 데이터베이스에서 발생하지 않았다. 실제 운영 환경에서 파일을 만드는 계정과 읽는 계정이 달랐기 때문에 발생했다. 보안 설정을 없애는 대신 공유 원본을 만드는 Git 구간만 분리했고, 검증은 실제 동기화 계정으로 수행했다.
파일을 읽을 수 있는지 확인하려면, 실제로 그 파일을 읽을 프로세스로 검사해야 한다.
이 수정은 Git에서 동기화 서비스로 파일을 전달하는 권한 문제를 해결했다. 데이터베이스 반영 이후 프런트엔드 캐시를 갱신하는 문제는 이 작업의 범위에 포함되지 않았다.