DEVELOPMENT NOTE3

Docker 권한 없이 콘텐츠 동기화를 자동화하기

Webhook 검증과 호스트 작업을 분리해 Git 변경을 안전하게 동기화한 과정

#spring-boot#github-webhook#systemd#file-spool#content-sync#security
아카이브로 돌아가기

Webhook 서버에 운영 권한을 줄 수는 없었다

콘텐츠를 일회성 명령으로 동기화할 수 있게 됐지만 Git push와는 연결되지 않았다. 운영자가 매번 SSH로 접속할 수는 없었고, 인터넷 요청을 받는 Spring Boot에 checkout 쓰기 권한과 Docker socket을 제공하는 것도 위험했다. Docker socket은 사실상 호스트의 강한 권한이며, 오래 걸리는 작업이 HTTP 요청 생명주기에 묶이는 문제도 생긴다.

GitHub Actions가 서버에 SSH로 접속하는 방식은 CI에 운영 자격 증명을 둬야 했다. 주기적인 polling은 즉시성이 낮고, 별도 message broker는 단일 VM과 낮은 변경 빈도에 비해 운영 요소가 많았다.

그래서 외부 요청 검증과 호스트 작업 사이에 파일 기반 작업 대기열을 뒀다.

text
GitHub push
  -> Nginx HTTPS
  -> Spring Boot: 요청 검증
  -> File Spool: 작업 저장
  -> systemd.path
  -> Host Worker: Git 갱신
  -> 일회성 동기화
  -> MySQL 조회 모델

Spring Boot는 요청만 검증하고, Host Worker만 제한된 Git과 Docker 권한을 가진다. 동기화 컨테이너는 기존 콘텐츠 처리 로직만 실행한다.

요청은 원문 그대로 검증했다

Webhook verifier는 JSON을 해석하기 전에 raw body로 HMAC-SHA256을 계산한다. 결과는 X-Hub-Signature-256MessageDigest.isEqual로 비교한다.

서명이 맞더라도 다음 조건을 모두 통과해야 한다.

  • Event header가 push인가
  • 저장소 전체 이름과 ref가 허용 목록과 일치하는가
  • forced=false인가
  • Delivery ID가 정상 UUID인가
  • after가 40자 또는 64자 commit SHA이며 zero SHA가 아닌가

실패한 요청은 작업을 만들지 않는다. 성공하면 작업 파일을 완전히 발행한 뒤 HTTP 202 Accepted를 반환한다. 같은 delivery가 이미 처리 중이거나 끝났다면 새 작업 없이 HTTP 200으로 응답한다.

운영 애플리케이션은 posts checkout을 읽기 전용으로만 보고, Docker socket과 deploy key, host shell을 받지 않는다.

작업 파일은 완성된 상태로만 공개했다

작업 JSON에는 Worker가 필요한 세 값만 저장한다.

  • deliveryId
  • commitSha
  • receivedAt

전체 GitHub payload와 HMAC secret은 남기지 않는다. 파일은 같은 spool filesystem의 .tmp에 먼저 쓴 뒤 incoming/<delivery-id>.json으로 ATOMIC_MOVE한다. Atomic move를 지원하지 않으면 일반 이동으로 대체하지 않고 실패한다. Worker가 절반만 작성된 JSON을 읽지 않게 하기 위해서다.

작업 파일은 0640으로 만든다. 애플리케이션은 .tmpincoming만 쓸 수 있고, processing, succeeded, failed는 읽기 전용으로 본다. 요청을 발행할 수는 있지만 성공 상태를 임의로 만들 수는 없다.

디렉터리 자체가 작업 상태를 나타낸다.

상태의미
.tmp아직 공개되지 않은 파일
incoming검증을 마치고 실행을 기다리는 작업
processingWorker가 가져간 작업
succeededGit 갱신과 동기화, 확인까지 성공한 작업
failed원인을 확인하고 재시도해야 하는 작업

중복 여부는 모든 상태 디렉터리에서 확인한다. 이미 실패해 보존된 요청도 같은 ID로 덮어쓰지 않는다.

systemd는 실행 신호만 전달했다

houkago-content-sync.pathincoming/*.json을 감지해 일회성 systemd service를 시작한다. Spring Boot가 systemd 명령을 직접 호출하지 않으므로 HTTP 응답과 Worker 시작 순서도 결합되지 않는다.

Worker script는 root 소유의 고정 파일이지만 프로세스는 ubuntu 사용자로 실행한다. Spool 공유 그룹과 Docker 그룹만 추가하고, systemd sandbox는 server와 환경 파일을 읽기 전용으로 제한한다. Posts checkout과 spool, lock, Docker socket처럼 필요한 경로만 쓸 수 있다.

Worker는 상시 daemon이 아니다. 경로 이벤트가 오면 대기열을 순서대로 비우고 종료한다. 실행 중 새 작업이 들어오면 다음 scan에서 처리한다. 이벤트를 놓친 경우에는 같은 service를 수동 실행해 남은 작업을 처리할 수 있다.

Worker는 예상한 Git 상태만 허용했다

Worker는 다음 순서로 작업한다.

  1. 파일 이름과 JSON의 delivery ID, commit SHA, timestamp를 다시 검증한다.
  2. 작업을 incoming에서 processing으로 옮긴다.
  3. 콘텐츠 lock과 공용 maintenance lock을 획득한다.
  4. Checkout이 변경 없는 main인지 확인하고 origin을 fetch한다.
  5. 작업 commit과 origin/main, 현재 HEAD의 ancestor 관계를 확인한다.
  6. merge --ff-only로 허용된 최신 revision까지 갱신한다.
  7. 동기화 컨테이너가 원본을 읽을 수 있는지 확인한다.
  8. docker compose run --rm sync와 공개 API smoke를 실행한다.
  9. 결과에 따라 작업을 succeeded 또는 failed로 옮긴다.

JSON은 Python 표준 라이브러리로 해석하고 shell sourceeval은 사용하지 않는다. Spring Boot에서 한 검증도 호스트 권한 경계에서 필요한 형태로 다시 확인한다.

Working tree가 dirty하거나 branch가 main이 아니면 실패한다. 알 수 없는 commit, fast-forward할 수 없는 상태도 자동으로 고치지 않는다. git reset --hardgit clean으로 운영자의 변경을 지우는 대신 작업을 failed에 남겨 운영자 판단을 요구한다.

작업 SHA가 최신 origin/main일 필요는 없다. 정상적인 후속 push가 있다면 작업 SHA가 main의 ancestor인지 확인한 뒤 최신 main으로 fast-forward한다. 여러 push가 가까이 와도 과거 상태로 돌아가지 않는다.

Lock과 delivery ID로 중복을 막았다

Lock 파일의 존재가 아니라 file descriptor의 실제 flock을 확인한다. 먼저 Worker끼리 겹치지 않도록 콘텐츠 lock을 non-blocking으로 얻고, 그다음 배포와 동기화가 겹치지 않도록 maintenance lock을 제한된 시간 동안 기다린다. 순서는 항상 콘텐츠 lock, maintenance lock이다. 이 lock은 단일 호스트 안에서만 유효하다.

같은 GitHub delivery가 다시 오면 새 동기화를 실행하지 않는다. 실제 성공한 delivery를 GitHub에서 다시 전송했을 때 endpoint는 HTTP 200을 반환했고 기존 succeeded 작업을 유지했다. 새 작업과 Git fetch, Worker, 동기화는 발생하지 않았다.

서로 다른 delivery가 같은 commit을 가리키는 경우까지 합치지는 않는다. 이때는 fast-forward 갱신과 checksum 기반 전체 동기화가 반복 실행을 안전하게 만든다.

실제 push로 전체 경로를 확인했다

먼저 잘못된 JSON·UUID·SHA, 여러 작업, lock 경합, 알 수 없는 commit과 대기열 처리를 fixture로 검증했다. 이후 실제 published 글의 description을 바꿔 운영 환경에서 전체 경로를 확인했다.

2026년 8월 12일의 한 실행에서는 push 약 4초 뒤 Webhook이 도착했고, 약 22초에 동기화가 UPDATED=1, TOUCHED=264로 끝났다. 공개 API에서 변경을 확인했으며 app과 MySQL container는 재시작되지 않았다. 당시 네트워크와 265개 후보를 대상으로 한 관찰값으로, 고정 처리 시간이나 SLA는 아니다.

HTTP 202는 작업 파일이 안전하게 발행됐다는 뜻일 뿐이다. 실제 성공은 succeeded 상태와 checkout 리비전, 동기화 종료 결과와 요약 로그, API 점검을 함께 확인해야 한다.

분리한 만큼 운영할 대상도 늘었다

File Spool은 별도 broker 없이 권한과 생명주기를 나눴지만 단일 filesystem과 VM에 묶인다. 장기 보존과 정리 정책, 별도 실행 기록, systemd.path 이벤트를 보완할 timer도 아직 없었다. 이벤트를 놓치면 수동으로 대기열을 처리해야 했다.

첫 실제 Git 갱신에서는 UMask=0007이 새 Markdown을 0660으로 만들어 별도 계정의 동기화 컨테이너가 읽지 못하는 문제도 발생했다. 구조는 의도대로 작업을 failed에 보존했지만 콘텐츠 반영은 멈췄다.

자동화의 핵심은 Webhook에서 바로 Docker를 실행하는 것이 아니었다. 외부 요청은 검증과 작업 발행까지만 맡고, 강한 운영 권한은 입력을 다시 검증하는 호스트 작업에 제한하는 것이었다.