Lobsters

Bot-free self-hosted analytics with GoatCounter on NixOS

GoatCounter와 NixOS로 봇 없는 셀프 호스팅 분석 구축하기

Google Analytics를 제거한 뒤 AI 스크래퍼로 부풀려진 로그 분석을 버리고, GoatCounter를 NixOS 환경에 직접 배포한 사례입니다. 상호작용 기반 JavaScript와 CSS 대체 수단으로 봇을 걸러내고, 5대 웹 서버의 로컬 프록시와 Litestream 백업으로 수집 안정성까지 확보합니다.

AI 요약

Vincent Bernat는 2016년 개인정보 수집에 관여하지 않기 위해 블로그에서 Google Analytics를 제거하고 서버 로그 분석 도구인 GoAccess를 사용했습니다. 그러나 최근 몇 년 동안 AI 스크래퍼가 방문자 수를 부풀리면서 통계가 실제 독자 규모를 반영하지 못했습니다. 봇 필터링을 시도했지만 GoAccess에는 하루 약 2,000명의 방문자가 기록됐고, 결국 오픈 소스 웹 분석 도구 GoatCounter를 직접 운영하기로 했습니다. JavaScript 클라이언트를 더 엄격한 방식으로 교체하고, JavaScript를 끈 브라우저를 위한 CSS 대체 수단도 추가했습니다. GoatCounter 서버가 내려가도 방문 기록을 잃지 않도록 블로그를 제공하는 웹 서버 5곳에 로컬 프록시를 배치했으며, 전체 배포와 백업은 NixOS 설정으로 관리합니다.

■ GoatCounter를 선택한 이유

GoatCounter는 독자의 IP 주소를 저장하거나 쿠키를 사용하지 않습니다. 사용자 에이전트(User-Agent)와 IP 주소를 바탕으로 8시간 동안 유효한 세션 식별자를 만들어 방문을 집계합니다. 블로그에 필요한 기능은 충분하지만 기능 범위는 비교적 작습니다. 단일 바이너리와 SQLite 데이터베이스만으로 실행할 수 있어 셀프 호스팅 구성이 가볍습니다. 글에서는 복잡도가 높아지는 순서로 Umami, Plausible, Rybbit도 개인정보 친화적인 대안으로 언급합니다.

■ 상호작용이 발생한 방문만 기록하는 클라이언트

기본 GoatCounter JavaScript 클라이언트는 압축 후 2,189바이트이며 방문자 수 표시, 클릭 추적, 설정 기능 등을 포함합니다. 작성자는 사용하지 않는 기능을 제거하고 페이지 방문을 등록하는 함수를 직접 작성했습니다. 요청에는 경로 또는 이벤트 이름, 문서 제목, 리퍼러, 쿼리 문자열, 문서 너비, 이벤트 여부, 짧은 난수 값이 포함됩니다. 요청은 같은 사이트의 `/count` 경로로 `fetch()`를 호출하며, 페이지가 닫혀도 전송되도록 `keepalive`를 사용합니다.

봇을 걸러내기 위해 페이지를 읽는 것만으로는 방문을 기록하지 않습니다. `touchmove`, `mousemove`, `keydown`, `pointerdown` 가운데 하나가 발생해야 `count()`를 호출합니다. 이벤트 리스너에는 `once: true`를 지정해 첫 상호작용 이후 한 번만 기록합니다. 작성자는 이 방식을 Bear Blog에서 가져왔다고 설명합니다. JavaScript를 끈 방문자는 CSS 이미지로 집계합니다. `:hover` 의사 클래스가 상호작용 뒤에 `/count` 이미지를 불러오도록 구성해, 단순히 HTML을 요청하는 크롤러는 기록하지 않게 했습니다. JavaScript를 사용하지 않는 방문자는 전체의 약 2%입니다.

이 변경 뒤 GoatCounter가 세는 사람 방문자는 하루 200명 미만으로 내려갔습니다. GoAccess의 약 2,000명과 큰 차이가 납니다. 작성자는 AI 스크래퍼가 콘텐츠에 별도 장벽이 없으면 복잡한 자동화 브라우저를 실행하지 않는 낮은 비용의 방식으로 접근한다고 추정합니다. JavaScript를 실행하는 Googlebot의 headless Chromium도 페이지에서 마우스 이동이나 키보드 입력 같은 상호작용을 하지 않으므로 이벤트를 발생시키지 않습니다. 따라서 상호작용을 요구하는 방식이 계속 작동할 것으로 보고 있습니다.

■ 5대 서버를 위한 로컬 프록시

블로그는 유럽과 북미에 있는 5대 서버에서 제공하지만 GoatCounter 본체는 한 서버에서만 실행합니다. 본체가 중단되는 동안 방문 기록을 잃지 않기 위해 각 서버에서 `/count`와 같은 엔드포인트를 제공하는 로컬 프록시를 실행합니다. 프록시는 방문 요청을 메모리에 저장하며, 며칠 동안의 중단을 버틸 수 있을 만큼 버퍼를 확보합니다. 이후 인증된 `/api/v0/count` 엔드포인트로 요청을 묶어 전송합니다.

작성자는 이 프록시 코드를 GoatCounter 저장소의 pull request #909로 제안했지만, 유지 관리자는 특정 사용 사례에 필요한 코드가 너무 많다는 이유로 직접 관리하지 않기로 했습니다. 글에서는 오픈 소스 유지 관리자가 일회성 기여를 장기간의 유지 보수 부담으로 받아들일 수 있다는 점도 함께 설명합니다. 프록시 엔드포인트는 광고 차단기에 덜 걸리도록 블로그와 같은 도메인에 노출합니다. 개인정보를 수집하지 않는 분석 도구라는 점을 고려해 이 구성을 받아들였다고 밝힙니다.

nginx는 `/count` 요청을 로컬 프록시인 `127.0.0.3:8087`로 전달합니다. 프록시에는 요청 본문이나 일반 헤더를 모두 넘기지 않고 `Accept-Language`, `User-Agent`, `X-Real-Ip`만 설정합니다. GoatCounter 본체는 별도 도메인에서 실행하며, 본체의 `/count` 경로는 프록시가 담당하므로 404를 반환하도록 막습니다. 프록시는 초당 IP별 10건으로 제한하고, API 키를 사용해 중앙 GoatCounter 인스턴스에 배치 데이터를 인증합니다.

■ NixOS 컨테이너와 선언형 배포

웹 서버 운영체제는 선언형 Linux 배포판인 NixOS이며, 여러 서버의 배포에는 상태를 보존하지 않는 NixOS 배포 도구 Colmena를 사용합니다. 각 애플리케이션은 systemd-nspawn 기반의 가벼운 임시 컨테이너에서 실행합니다. 컨테이너마다 축소한 NixOS 인스턴스를 띄우고, 반복되는 설정은 별도 NixOS 모듈로 감쌉니다. 컨테이너는 호스트와 네트워크 네임스페이스를 공유합니다. 글에서는 추가 네트워크 격리가 복잡성에 비해 얻는 이점이 작다고 판단했습니다. console-getty, systemd-logind, systemd-oomd 같은 필수가 아닌 서비스도 끕니다.

임시 컨테이너는 재생성될 때 내부 데이터가 사라지므로 SQLite 데이터베이스는 호스트의 `/var/db/goatcounter`에 둡니다. NixOS 컨테이너의 `bindMounts`로 호스트 디렉터리를 컨테이너에 읽기·쓰기 방식으로 연결하고, GoatCounter 실행 인자에서 `sqlite+/var/db/goatcounter/db.sqlite`를 데이터베이스 경로로 지정합니다. GoatCounter는 `127.0.0.4:8088`에서 수신하며, systemd 서비스에는 `DynamicUser`, 자동 재시작, 자동 마이그레이션을 설정합니다. 동적으로 할당된 `goatcounter` 사용자가 데이터베이스 디렉터리를 쓸 수 있도록 시작 전에 소유권도 변경합니다.

비밀 값은 Nix store에 저장하지 않습니다. Colmena의 `deployment.keys`로 서버에 업로드하고, 비밀번호 관리자에서 명령을 실행해 GeoIP 데이터베이스용 환경 변수와 프록시 API 키를 채웁니다. 컨테이너는 필요한 비밀 파일이 준비된 뒤에만 시작하며, `/etc` 아래에 읽기 전용으로 마운트합니다. GoatCounter Nix 패키지는 기존 정의의 `src`와 `vendorHash`를 덮어써 프록시 기능을 추가한 개인 브랜치를 빌드합니다. 작성자는 미래의 NixOS 모듈 변경에 영향을 덜 받도록 기존 GoatCounter 모듈을 재사용하지 않고 컨테이너, 서비스, 비밀 설정을 감싼 별도 모듈을 만들었습니다.

■ Litestream으로 SQLite 백업과 복구

SQLite 데이터베이스 백업에는 Litestream을 사용합니다. Litestream은 WAL에 커밋된 변경을 압축해 원격 저장소로 보내는 도구입니다. 구성은 별도 컨테이너에서 실행하며, 데이터베이스 디렉터리와 Hetzner Storage Box에 SFTP로 접속하는 비밀 값을 마운트합니다. 전체 스냅샷은 하루에 한 번 만들고 15일 동안 보관합니다. 트랜잭션 파일은 5분, 30분, 3시간 간격으로 세 단계 압축을 적용하며 자동 복구도 켭니다. 백업 경로는 호스트 이름과 데이터베이스 이름을 조합해 분리합니다.

데이터베이스가 손상되면 GoatCounter와 Litestream 컨테이너를 멈추고 기존 `db.sqlite`를 별도 이름으로 옮깁니다. 이후 Litestream 환경 변수와 설정 파일을 불러온 뒤 `litestream restore`로 원격 사본을 `/var/db/goatcounter/db.sqlite`에 복원합니다. 복원이 끝나면 두 컨테이너를 다시 시작합니다. 글에 제시된 복원 결과 데이터베이스 크기는 20MB입니다.

작성자는 Google Analytics를 제거한 지 10년이 지난 뒤 JavaScript 기반 분석을 다시 도입했습니다. 다만 쿠키와 IP 주소를 저장하지 않고, 외부 분석 사업자도 사용하지 않습니다. 봇을 제외한 실제 독자 규모를 파악하면서도 JavaScript를 끈 방문자를 CSS 방식으로 포함하고, 프록시·컨테이너·비밀 관리·SQLite 백업까지 하나의 NixOS 구성으로 묶은 운영 사례입니다.

원문: Vincent Bernat 블로그 / 번역·요약: Trawling