Lobsters

what if my git host were a static site generator?

내 git 호스트가 정적 사이트 생성기라면? — 읽기 전용 리포 뷰어 sorcery

Forgejo 개인 서버가 스크레이더 부하와 OOM에 시달리던 개발자가, git 호스트 전체를 정적 파일로 서빙하는 읽기 전용 리포 뷰어 sorcery를 만들었습니다. 최신 커밋 뷰는 미리 렌더링된 HTML로, 이력 탐색은 .git 디렉터리와 인브라우저 git 클라이언트 조합으로 처리해 봇 트래픽을 사실상 무시할 수 있는 구조입니다.

본문

개발 인프라를 직접 운영하는 것을 좋아하는 저자는 2015년부터 Gogs→Gitea→Forgejo 순으로 개인 git 포지를 운영해 왔습니다. 그런데 Forgejo 서버는 업데이트되지 않은 리포를 repack하다 크래시하며 디스크가 계속 부족해지고, 상시적인 스크레이더 부하에 OOM으로 쓰러지는 일이 반복됐습니다. 개인용 초소형 머신에서 인터넷의 '배경 복사' 수준 트래픽을 견디기에는 Forgejo가 지나치게 큰 소프트웨어였고, 이슈·PR·릴리즈·위키 같은 GitHub 호환 기능은 쓰지도 않으면서 비용을 지불하고 있다는 판단에 이릅니다. 그래서 모든 것을 정적 파일로 서빙하는 git 리포 뷰어 sorcery를 만들었습니다.

Anubis식 차단 대신 정적 파일을 택한 이유

Forgejo 리소스 고갈에 대해 통상 처방되는 해법은 Anubis 같은 웹 애플리케이션 방화벽으로, JavaScript proof-of-work 챌린지 뒤에 앱을 숨기는 방식입니다. 저자는 이것이 오픈 웹의 철학에 반한다고 봅니다. 'user agent'여야 할 브라우저가 사용자의 기기를 소모하는 거의 무의미한 코드 실행을 강요당하고, JITted JavaScript를 지원하지 않는 대체 브라우저는 아예 차단되거나 긴 대기 시간에 갇힙니다. 게다가 이런 배포는 '앞단에 놓인 애플리케이션이 실제 인터넷 트래픽을 정상적으로 처리하지 못한다'는 항복 선언이라고 지적합니다. 읽기가 쓰기를 압도하는 워크로드에서 정적 서빙은 실제로 아주 쌉니다. GitHub Pages가 수년간 단 한 대의 머신에서 동작했다는 사실을 근거로 들며, git 호스트 전체를 정적 파일로 만들지 못할 이유가 없다고 말합니다.

sorcery의 구조 — 빌드 타임 렌더링 + 인브라우저 git 클라이언트

sorcery는 형태상 정적 사이트 생성기입니다. 리포에 업데이트가 들어오면 해당 리포의 온디스크 HTML을 다시 빌드합니다. 개요 페이지, 각 브랜치 tip 커밋의 디렉터리 트리, tip에 포함된 각 파일의 문법 하이라이팅된 소스 렌더링이 그 대상입니다. 미리 비용을 지불해 이후 수많은 요청을 sendfile 수준 비용으로 처리하므로 스크레이더 부하에 강합니다. 다만 모든 파일의 모든 리비전을 미리 렌더링하는 것은 비싸므로 과거 이력 뷰는 정적으로 제공하지 않습니다. 대신 .git 디렉터리를 직접 서빙하고, JavaScript로 작성한 기본적인 읽기 전용 git 클라이언트가 '리치 뷰'를 클라이언트 사이드에서 렌더링합니다. git 리포 안에서는 디렉터리 리스팅을 안정적으로 할 수 없으므로 생성기가 보조 JSON 데이터도 함께보내는데, 이 역시 정적 파일입니다.

packfile 문제와 스마트 fetch 최적화

이력 탐색은 commit·tree(디렉터리 리스팅)·blob(파일 내용) 객체에 대한 다수의 fetch를 필요로 하므로 고지연 연결에서는 객체 순회가 매우 느려집니다. git이 이 객체들을 델타 인코딩된 packfile에 압축 저장하기 때문에 문제가 더 큽니다. linux.git에서 한 커밋의 diff를 보려고 packfile 인덱스를 순진하게 fetch하면 400MiB 이상의 대역폭이 소모되고, 레인지를 똑똑하게 스캔하려 해도 캐시 적중률이 떨어지고 이전 요청 결과에 의존하는 요청 워터폴이 생깁니다. 이를 완화하기 위해 sorcery는 유일한 비정적 최적화로 서버 측 라우트 QUERY /<user>/<repo>/obj를 제공합니다. 객체 ID 목록을내면 단순한 바이너리 'git object bundle' 형식으로 반환하고 클라이언트에서 그대로 파싱합니다. 느슨한 객체 리포에서도 'smart fetch' 모드로 왕복을 줄입니다. commit.parent→parent→… 식 이력 순회나, 두 커밋을 diff하기 위해 양쪽 트리의 blob을 전부 가져오는 접근 패턴을 서버가 참조 OID까지 따라가며 처리합니다.

비기능(non-features)과 보안 모델

sorcery는 'forge'가 아니라 'repo viewer'를 표방합니다. 사용자 계정, SSH/GPG 키 관리, 이슈와 패치가 전부 없고, sorcery 자체는 완전히 읽기 전용입니다. 리포 쓰기는 오직 별도의 git over ssh로만 이뤄지며, sorcery-ssh를 git 사용자의 ssh ForceCommand로 설정하면 첫 push 시 리포 자동 생성과 리포 설명 편집 기능을 제공합니다. 이 구성에서 공개 배포의 보안 수준은 곧 sshd의 보안 수준이라는 점이 장점입니다.

프런트엔드 무게와 향후 계획

이력 뷰에는 JavaScript가 필요하지만 저자는 전면적인 JS 알레르기에는 동의하지 않습니다. 저지연 환경이 아니라면 디바이스에서 UI 코드를 실행하는 편이 거의 항상 낫다는 입장입니다. 자체 프론트엔드 마이크로프레임워크와 웹 플랫폼 기능, 의도적 코드 스플리팅으로 가볍게 만들어, 프로젝트 개요 페이지는 gzip 기준 약 9kb의 JS만 전송해 최근 커밋 페이지네이션·언어 필터링·커밋 diff 링크를 지원합니다. 사이트에서 가장 큰 부분은 문법 하이라이팅 grammar인데, tree-sitter grammar와 쿼리를 실행하는 순수 JavaScript 실행기를 만들어 각 하이라이트 언어의 WASM 번들 무게를 덜어내고 싶다고 합니다. 가까운 시일 내 CI 어노테이션 지원을 추가할 예정이며, 장기적으로는 친구들과 쓸 수 있는 좀 더 배터리 포함된 forge 프로젝트로 확장하는 것도 검토하고 있습니다.

Lobsters 반응

  • @fanf — 이 아이디어의 고전적 구현으로 stagit을 꼽았고, 어쩌면 browsegit 계열에 더 가깝다고 평가했습니다. '정적 웹사이트'라는 말이 예전에는 '동적 HTML 템플릿 없음'을 뜻했지만 지금은 '서버 측 템플릿은 없되 클라이언트 측 템플릿은 잔뜩'이라는 뜻으로도 쓰인다는 점도 짚었습니다.
  • @fanf — 인브라우저 git 클라이언트는 고지연 링크 위에서 왕복이 많아 상당히 느릴 것이라 예상했고, dumb protocol 클라이언트는 smart protocol의 최적화가 없어 오히려 서버 부하를 악화시킬 수 있다고 지적했습니다. 봇 대응 측면에서 실제 이득이 되는지에 대해서도 의문을 제기했습니다.

원문: char.lt / 번역·요약: Trawling