firecrawl self-host를 맥미니 한 대용으로 다이어트한 기록
목차
firecrawl SaaS는 페이지당 과금이다. 내 사용량(Hermes 뉴스 배치, 웹 추출)을 넣으면 비용이 금방 올라가서, self-host를 올리기로 했다. 공식 절차를 따라 올리면 Supabase, Stripe, FoundationDB까지 같이 뜬다. firecrawl 본체가 자기 SaaS 백엔드와 코드를 공유하기 때문이다. 로컬 한 대에선 필요 없는 것들이다. 이 글은 그걸 빼고, 동시성은 실제 사용량에서 역산한 기록이다.
왜 firecrawl인가, 왜 직접인가
firecrawl을 고른 이유는 기능이 많아서다. 보통 웹 데이터 파이프라인을 짜면 도구가 쪼개진다. 검색은 검색대로, 단일 페이지 추출은 또 따로, 사이트 전체 수집은 또 다른 걸로. firecrawl은 이 네 가지가 한 스택, 한 API에 있다.
search: 메타 검색 엔진 통합 쿼리. 내가 직접 구글/빙을 찌르는 게 아니라 firecrawl이 메타 검색 백엔드(SearXNG)로 결과를 가져온다.map: 사이트 전체의 URL만 빠르게 뽑는다. 본문까지 내리지 않고 링크 구조만.scrape: 단일 페이지를 deep extract 한다. 본문, 메타, 구조화 데이터까지.crawl: 한 URL에서 시작해 재귀적으로 수집한다.
Hermes 파이프라인은 이 네 가지를 다 쓴다. 후보 URL을 search로 모으고, map으로 펼치고, scrape로 본문을 뽑고, 가끔 crawl로 한 사이트를 통째로 긁는다. 네 기능이 한 엔드포인트에 있으니 파이프라인이 한 곳을 본다. 이게 도구 선택의 이유다.
그럼 왜 SaaS가 아니라 직접 올렸나. 비용 때문이다. firecrawl SaaS는 페이지당 과금이다. 수백 건짜리 배치가 매일, 매주 돌면 비용이 선형으로 올라간다. 그러면 직접 올리는 게 싸다. 그래서 self-host를 올렸다.
공식 self-host가 가져오는 것
SELF_HOST.md를 따라 docker compose up을 하면 뜨는 서비스를 나열해 봤다.
| 서비스 | 역할 | 로컬 싱글 에이전트에 필요? |
|---|---|---|
| api | 본체. scrape/crawl/map/search 엔드포인트 | 필요 |
| playwright-service | 헤드리스 크롬을 분리한 프로세스 풀 | 필요 |
| redis | 캐시, 속도 제한 | 필요 |
| rabbitmq | 작업 큐 | 필요 |
| nuq-postgres | 큐 백엔드(postgres) | 필요 |
| foundationdb | 큐 백엔드(fdb, 실험적) | 불필요 |
Supabase (USE_DB_AUTHENTICATION) | DB 인증, 멀티테넌시 | 불필요 |
| Stripe | 결제 | 불필요 |
여덟 개 중 여섯만 남는다. 두 개는 내 용도에 닿지 않는다.
왜 이런 의존성이 기본으로 붙어 있을까. firecrawl은 자기 SaaS 백엔드와 self-host 빌드가 같은 코드베이스를 공유한다. 공식 배포본은 그들의 프로덕션 인프라 축소판이다. 멀티테넌시, 결제, 분산 큐 백엔드가 다 올라가 있는 건, 이 코드가 상용 SaaS를 굴리는 같은 코드이기 때문이다. 로컬에서 쓸 땐 이게 다 부담이다.
다이어트: 싱글 호스트 로컬 에이전트용으로 끊어내기
끊어낸 건 세 가지다. 환경변수 몇 줄로 배포용 무게의 상당 부분이 빠진다.
| 결정 | 효과 | 이유 |
|---|---|---|
USE_DB_AUTHENTICATION=false | Supabase 의존 제거 | 로컬 단일 사용자. 멀티테넌시, DB 인증이 필요 없다 |
NUQ_BACKEND=pg | FoundationDB 비활성화, postgres 큐로 단순화 | postgres 큐로 충분. 실험적 fdb는 프로필로 밀어넣어 평소엔 안 띄운다 |
| Stripe, Supabase 토큰 비움 | 결제, 외부 인증 차단 | 싱글 에이전트 로컬 구동엔 서드파티가 필요 없다 |
USE_DB_AUTHENTICATION=false 하나로 Supabase가 빠진다. 인증 계층이 환경변수 하나에 매달려 있다는 건, 이 시스템이 멀티테넌시를 설정 하나로 켜고 끄도록 설계됐다는 뜻이다. SaaS 운영자에겐 합리적이지만, 내 머신에선 필요 없는 설정이다.
NUQ_BACKEND=pg도 같은 맥락이다. firecrawl의 큐 백엔드는 FoundationDB와 postgres 중 하나를 고를 수 있다. fdb는 분산 저장소라 고처리량 배포용이지만, 로컬 싱글 호스트엔 과하다. postgres로 강제하면 fdb 서비스를 아예 프로필 뒤로 숨길 수 있다. docker compose --profile fdb up을 해야만 올라온다. 평소엔 컨테이너 자체가 없다.
세 결정의 공통점은 명확하다. 용도를 싱글 호스트 로컬 에이전트로 정의하고, 그 정의에 안 쓰는 의존성은 끊어냈다.
리소스 상한: 보수적이 아니라 역산이다
여기가 핵심이다. compose의 리소스 상한과 동시성 값이 그냥 ‘보수적으로 잡았다’가 아니라는 얘기다.
두 갈래 역산이 교차한다.
첫째, 수요 쪽 역산. Hermes 뉴스 배치는 한 번에 수백 건을 긁는다. 동시성 1, 즉 한 페이지씩 순차 처리하면 수백 건에 페이지당 수 초씩 곱해져서 수십 분이 훌쩍 넘는다. 배치가 느려지면 다음 단계 파이프라인이 굶는다. 그래서 최소한의 동시성이 필요하다. 동시성을 4에서 5로 올리면 처리 시간이 4분의 1, 5분의 1로 준다. 수백 건이면 수 분 안에 끝난다. 처리 시간 압박이 동시성의 하한선을 정한다.
둘째, 자원 쪽 역산. 동시성을 올릴수록 동시에 뜨는 크롬 프로세스가 많아진다. 크롬 탭 하나가 잡아먹는 RAM은 공개 벤치마크 기준 약 50~150MB다. 동시성 5면 최악의 경우 약 750MB. playwright-service 상한을 3GB로 잡았으니 여유가 있다. 그런데 동시성을 10, 20으로 올리면 크롬 10개, 20개의 RAM이 쌓인다. 맥미니 48GB라 해도 api, postgres, rabbitmq와 같이 돌면 타이트해진다. 크롬 풋프린트가 동시성의 상한선을 정한다.
두 선이 만나는 지점이 4에서 5다. 그래서 compose엔 이렇게 들어갔다.
| 항목 | base 기본값 | 다이어트 세팅 | 7일 운영 후 실측 |
|---|---|---|---|
| api cpus | 4.0 | 2 | CPU 1.11% |
| api mem | 8G | 4G | 2.42G / 4G (60.5%) |
| playwright cpus | 2.0 | 2 | CPU 0.05% (idle) |
| playwright mem | 4G | 3G | 184M / 3G (5.99%, idle) |
NUM_WORKERS_PER_QUEUE | — | 4 | — |
CRAWL_CONCURRENT_REQUESTS | — | 5 | — |
MAX_CONCURRENT_JOBS | — | 4 | — |
BROWSER_POOL_SIZE | — | 4 | — |
BLOCK_MEDIA | — | true | 미디어 차단, 속도와 자원 절약 |
실측을 보면 이 세팅이 맞다. api는 상한 4G의 60%인 2.42G를 쓴다. 상한을 8G로 두었으면 5.58G가 그냥 쳐다보고 앉아 있을 뻔했다. 반대로 2G로 줄였으면 피크 때 위험했다. 4G가 적당하다.
한 가지는 솔직하게 적어야 한다. playwright의 184M은 어디까지나 idle 측정값이다. 실제 크롤링 중에는 동시 페이지 수만큼 크롬이 뜨므로, idle 단발 측정만으로 OOM 한계선을 못 잡는다. 그래서 공개 벤치마크(페이지당 50~150MB)를 끌어와 역산했다. idle에 재본 숫자를 그대로 믿고 동시성을 20으로 올렸다면, 크롤이 몰리는 순간 RAM이 터졌을 것이다.
여기서 이 글의 진짜 진단이 나온다. api는 동시성을 더 올릴 수 있다. api 실측 CPU가 1.11%다. CPU는 여유가 많다. 그런데 동시성을 올리면 api가 아니라 크롬이 먼저 RAM 한계에 부딪힌다. 크롬 탭 하나의 RAM이 api 전체보다 무겁기 때문이다. 그래서 전체 상한을 가르는 건 api가 아니라 브라우저 풀이다. api의 cpus를 4에서 2로 줄인 것도 이 때문이다. CPU를 거의 안 쓰는 api에 4 cpu는 넉넉했다.
병목은 CPU가 아니라 RAM이다.
메타 검색과 운영 디테일
다이어트와 리소스 외에 몇 가지를 더 붙였다.
SearXNG를 메타 검색 백엔드로. firecrawl의 search는 구글, 빙을 직접 찌르는 게 아니라 메타 검색 엔진을 경유한다. 공식 compose엔 이 백엔드가 없어서, SearXNG 컨테이너를 직접 붙였다. 호스트 포트는 127.0.0.1:3004로만 묶었다. 0.0.0.0이 아니라 127.0.0.1이다. 이 머신이 외부에 직접 노출된 검색 엔드포인트를 갖지 않게 하려는 의도다. firecrawl api만 이 백엔드를 보고, 밖에서는 닿지 않는다.
로그 회전. 모든 서비스에 json-file 로그 드라이버에 max-size 10m, max-file 2를 묶었다. 크롬이 뱉는 로그가 의외로 크다. 며칠 두면 디스크가 조용히 잠식당한다. 로그 회전 없이 한 달을 굴렸다면 /var/lib/docker가 먼저 부하를 호소했을 것이다.
restart: unless-stopped. 맥미니를 재부팅하거나 도커 데몬이 흔들렸을 때 서비스가 알아서 돌아온다. 로컬 에이전트는 7일째, 내가 만지지 않아도 서 있어야 한다.
.env 버전 관리. .env 옆에 .env.20260705를 둔다. 설정을 고치면 이전 값을 복사해 둔다. 언제 어떤 값을 바꿨는지 git이 안 잡는 부분을 파일 하나로 남긴다. docker-compose 시스템에서 .env 디버깅이 가장 까다로운 일 중 하나라, 백업 한 장이면 원인 후보를 절반으로 줄일 수 있다.
이 디테일들은 화려하지 않다. 하지만 7일 연속으로 떠 있는 머신을 유지하는 건 이런 쓰레기 처리가 결정한다.
마무리
공식 firecrawl self-host는 배포용이다. Supabase, Stripe, FoundationDB가 기본으로 붙어 있는 건, 이 코드가 상용 SaaS를 굴리는 같은 코드이기 때문이다. 그래서 그대로 올리면 한 대 머신엔 과하다.
내가 배운 건 두 개다. 첫째, 용도를 먼저 정의하라. 싱글 호스트 로컬 에이전트라고. 그 정의에 안 쓰는 의존성은 끊어내라. 둘째, 리소스 상한은 보수적으로 잡는 게 아니라 역산하라. 수요가 하한선을 주고, 크롬이 상한선을 준다. 그 교차점이 정답이다.
firecrawl이 공식적으로 주는 배포 설정을 그대로 믿지 말 것. 내 용도에 맞춰 빼야 한 대 머신에서 돌아간다.