kreuzcrawl v0.3.0 소개
요약
kreuzcrawl v0.3.0 릴리스를 통해 지원 언어가 14개로 확대되었으며, 메모리 사용량을 2.5GB에서 20MB로 대폭 절감했습니다. 또한 WAF 인지 디스패치 엔진 추가 및 SSRF 방어 활성화 등 보안과 성능이 강화되었습니다.
핵심 포인트
- 지원 언어 확대: Dart, Kotlin, Swift, Zig 포함 총 14개 언어 지원
- 메모리 최적화: 피크 스트리밍 메모리를 약 2.5GB에서 20MB로 절감
- 보안 강화: 모든 아웃바운드 호출 경로에서 SSRF 방어 기본 활성화
- 엔지니어링 개선: 계층형 WAF 인지 디스패치 엔진 추가 및 바인딩 생성 파이프라인 강화
kreuzcrawl는 10개 언어용 바인딩(bindings)을 갖춘 Rust 코어로 시작되었습니다. v0.3.0은 14개 언어를 지원하며, 계층형 WAF 인지 디스패치 엔진(WAF-aware dispatch engine)을 추가하고, 피크 스트리밍 메모리(peak streaming memory)를 약 2.5 GB에서 약 20 MB로 절감했으며, 기본적으로 모든 아웃바운드 호출 경로(outbound call path)에서 SSRF 방어를 활성화합니다. 이는 우리가 API 안정화(API-stable) 단계로 간주하는 첫 번째 릴리스입니다.
이 포스트에서는 무엇이 변경되었는지, 왜 각 결정이 내려졌는지, 그리고 내부적으로 더 어려운 엔지니어링 문제들이 어떠했는지를 다룹니다.
한눈에 보기
| 영역 | v0.2.0 | v0.3.0 |
|---|---|---|
| 언어 바인딩 (Language bindings) | 10 | 14 (+Dart, Kotlin/Android, Swift, Zig) |
| ... |
4개의 새로운 언어 바인딩
v0.2.0은 Rust, Python, Node.js, Ruby, Go, Java, C#, PHP, Elixir, 그리고 WebAssembly를 지원했습니다.
v0.3.0은 Dart, Kotlin/Android, Swift, 그리고 Zig를 추가하여 총 14개로 늘어났습니다.
언어별 글루 코드(glue code)는 수동으로 작성되지 않았습니다. 모든 바인딩은 우리의 다국어 바인딩 생성기(polyglot binding generator)인 alef를 통해 Rust 코어로부터 생성됩니다.
Dart와 Kotlin/Android 패키지는 각각 dart:ffi와 JNI를 통해 C FFI 레이어(kreuzcrawl-ffi)를 거쳐 바인딩됩니다. Swift는 clang을 통해 바인딩됩니다. Zig는 동일한 C 헤더에 대해 @cImport를 사용합니다.
이번 릴리스에서는 생성 파이프라인(generation pipeline)도 강화되었습니다: Docker 게시 매트릭스(publish matrix)는 이제 QEMU 에뮬레이션을 통하지 않고 각 아키텍처를 네이티브로 빌드하며, Dart 빌드는 더 이상 pub.dev 게시를 위해 Flutter SDK를 요구하지 않습니다. Swift artifactbundle 체크섬은 자동으로 주입되며, Elixir/PHP/Ruby 릴리스는 소스 게시(source-publish) 단계를 통해 락 파일(lock files)을 보존합니다.
=== "Python"
```sh
pip install kreuzcrawl
```
=== "Node.js"
```sh
npm install @xberg/kreuzcrawl
```
=== "Rust"
```sh
cargo add kreuzcrawl
```
=== "Go"
```sh
go get github.com/xberg-io/kreuzcrawl/packages/go
```
=== "Java"
```xml
<dependency>
<groupId>io.xberg.kreuzcrawl</groupId>
<artifactId>kreuzcrawl</artifactId>
<version>0.3.0</version>
</dependency>
```
=== "Kotlin (Android)"
implementation("io.xberg.kreuzcrawl.android:kreuzcrawl-android:0.3.0")
=== "C#"
dotnet add package Kreuzcrawl
=== "Ruby"
gem install kreuzcrawl
=== "PHP"
composer require xberg-io/kreuzcrawl
=== "Elixir"
{:kreuzcrawl, "~> 0.3"}
=== "Dart"
dart pub add kreuzcrawl
=== "Swift"
// Package.swift
.package(url: "https://github.com/xberg-io/kreuzcrawl", from: "0.3.0")
=== "Zig"
zig fetch --save https://github.com/xberg-io/kreuzcrawl/archive/v0.3.0.tar.gz
=== "WebAssembly"
npm install @xberg/kreuzcrawl-wasm
메모리 제한 스트리밍 (Memory-bounded streaming)
기존에는 crawl_stream()과 batch_crawl_stream()이 호출자가 결과를 받기 전에 모든 페이지 결과를 메모리에 누적했습니다. 텍스트, 메타데이터, 링크 및 이미지를 포함하는 수만 페이지에 달하는 대규모 크롤링의 경우, 최대 작업 집합(peak working set)은 약 2.5 GB에 도달했습니다.
이 수정 사항은 소유권 변경을 통해 이루어졌습니다: 각 페이지 결과는 CrawlEvent::Page로 이동하여 즉시 방출됩니다. 호출자는 이를 받고 처리한 후 버립니다. 엔진은 동시성 설정(concurrency setting)으로 제한되는 현재 진행 중인 페이지를 초과하여 보유하지 않습니다.
// 이벤트 타입 (외부적으로는 변경되지 않음; 내부 동작만 변경)
pub enum CrawlEvent {
Page { result: Box<CrawlPageResult> }, // (1)
...
CrawlPageResult는 박싱(boxed)되어 변형(variant)으로 이동하고, 호출자의 루프가 이를 지나갈 때 버려집니다. 엔진은 전송 후 어떠한 참조도 보유하지 않습니다.
# Python — 페이지를 하나씩 처리하고 해제함
from kreuzcrawl import crawl_stream
...
기본 동시성(16)을 사용하는 10,000페이지 크롤링의 최대 작업 집합: ~20 MB.
비스트리밍 (non-streaming) 방식인 crawl()은 변경되지 않았습니다. 호출자가 전체 CrawlResult를 필요로 하기 때문에 계약상 데이터를 축적합니다. 두 코드 경로는 분리된 상태로 유지됩니다. 이를 병합하면 축적 패턴이 호출자에게 전가되는데, 이는 문제를 한 단계 위로 옮기는 것과 같습니다.
!!! tip "crawl()과 crawl_stream() 사이의 선택"
전체 결과 집합을 메모리에 담아야 할 때는 crawl()을 사용하세요. 대규모 크롤링, 진행 상황 추적, 또는 결과를 하나씩 처리해야 할 때는 crawl_stream()을 사용하세요. 규모가 커질수록 메모리 차이는 상당합니다.
기본적으로 활성화된 SSRF 방어
웹 크롤러는 URL을 입력으로 받아 HTTP 요청을 보냅니다. 이는 공격자가 내부 서비스에 접근하기 위해 필요로 하는 정확한 기본 동작 (primitive)입니다. 이제 URL을 허용하는 모든 경로는 요청을 보내기 전에 SsrfPolicy를 통해 해당 URL을 검증합니다: scrape(), crawl(), batch_crawl(), 사이트맵 가져오기, robots.txt 가져오기, 에셋 다운로드, 그리고 링크 인큐 (link enqueue).
거부되는 항목
| 카테고리 | 범위 |
|---|---|
| 루프백 (Loopback) | 127.0.0.0/8, ::1/128 |
| ... |
DNS 리바인딩 (DNS rebinding) 완화
검증 시점에 호스트 이름을 확인하는 것만으로는 불충분합니다. 공격자는 evil.example.com을 등록하여 검증 시에는 공인 IP를 제공한 뒤, 검증이 통과되면 DNS를 192.168.1.1을 가리키도록 업데이트할 수 있습니다.
정책은 DNS를 통해 모든 호스트 이름을 확인(resolve)하고 반환된 모든 IP 주소를 검증합니다. 만약 확인된 IP 중 하나라도 거부 목록 (deny list)에 포함되어 있다면, 다른 IP들이 무엇을 가리키든 상관없이 요청은 거부됩니다.
// From kreuzcrawl/src/net/ssrf.rs
let addresses: Vec<IpAddr> = tokio::net::lookup_host(&lookup_addr).await?
.map(|addr| addr.ip())
...
리다이렉트 체인 (Redirect-chain) 재검증
각 30x Location 헤더는 다음 홉 (hop)으로 넘어가기 전에 다시 확인(resolve)되고 재검증됩니다. 이를 통해 리다이렉트 체인 공격을 차단합니다. 예를 들어 http://169.254.169.254/latest/meta-data/로 리다이렉트되는 공인 URL은 두 번째 홉에서 거부됩니다. 리다이렉트 추적은 SsrfPolicy::max_redirects (기본값: 5)에 의해 제한됩니다.
옵트아웃 (Opting out)
# 환경 변수 — 프로세스 내의 모든 크롤러에 적용됩니다
export KREUZCRAWL_ALLOW_PRIVATE_NETWORK=1
// 설정별 빌더 — 단일 CrawlConfig에 적용됩니다
CrawlConfig::builder()
.allow_private_networks(true)
...
!!! warning "Wasm 타겟"
wasm32 환경에서는 SSRF 체크가 비활성화됩니다. 브라우저의 fetch API와 동일 출처 정책 (Same-origin policy)이 강제 경계 역할을 하며, 해당 컨텍스트에서는 tokio::net::lookup_host를 사용할 수 없습니다.
WAF 인식 계층형 디스패치 (WAF-aware tiered dispatch)
v0.3.0 이전에는 디스패치 (dispatch) 결정이 정적(static)이었습니다. 즉, HTTP, 우회 제공업체 (bypass-vendor), 또는 브라우저 중 하나를 설정 시점에 선택하면 크롤링이 진행되는 동안 고정되었습니다. 이는 명백한 비용 문제를 야기했습니다. 페이지의 5%가 차단된다는 이유로 모든 요청을 우회 제공업체를 통해 라우팅하는 것은 비용이 많이 들기 때문입니다.
새로운 엔진은 계층 (tiers)을 체인으로 연결하고, 시도(attempt)별 신호에 따라 단계를 격상(escalate)합니다.
계층 및 격상 전략 (Tiers and escalation strategies)
pub enum Tier {
Http, // 일반 HTTP fetch
Bypass, // 제공업체 관리형 우회 (Zyte, ScrapingBee, Bright Data, …)
...
모든 디스패치 열거형(enum)은 #[non_exhaustive]로 지정되어 있어, 하위 match 구문을 깨뜨리지 않고도 새로운 변체(variants)를 추가할 수 있습니다.
WAF 탐지: TOML 코퍼스에 대한 Aho-Corasick 적용
WAF 챌린지 페이지를 탐지하려면 응답 헤더와 본문(body)을 모두 검사해야 합니다.
단순한 접근 방식 — 응답당 지문(fingerprint) 하나당 하나의 정규 표현식(regex)을 사용하는 방식 — 은 $O( ext{지문 수} imes ext{본문 길이})$로 확장됩니다. 지문이 35개라면 페이지당 비용이 매우 높습니다.
모든 지문에 걸친 모든 본문 패턴 신호는 시작 시점에 **단일 Aho-Corasick 오토마톤 (automaton)**으로 컴파일됩니다. 응답 본문을 한 번 스캔하면 일치하는 패턴 인덱스 집합이 반환되며, 각 인덱스는 평탄한 Vec<usize>를 통해 지문에 매핑됩니다.
pub struct Rules {
fingerprints: Vec<Fingerprint>,
automaton: AhoCorasick, // 모든 패턴에 대한 단일 오토마톤
...
본문 스캔은 100 KB (CHALLENGE_BODY_LIMIT)로 제한됩니다. WAF 챌린지 페이지는 크기가 작지만, 실제 콘텐츠 페이지는 압도적으로 이 임계값을 초과합니다. 이를 통해 신호를 놓치지 않으면서도 스캔 비용을 제한할 수 있습니다.
헤더 신호(Header signals)가 먼저 확인됩니다 (지문(fingerprint)당 상수 시간 소요). 만약 헤더만으로 지문이 탐지되면, 본문 스캔(body scan)은 완전히 건너뜁니다.
현재 코퍼스(Corpus): Cloudflare (10), DataDome (6), PerimeterX (5), Imperva (5), AWS WAF (4), F5 (2), Akamai (1), 그리고 일반적인 상호 확인 패턴 (2)을 포함한 총 35개의 지문.
라이브 환경을 위한 핫 리로드 (Hot-reload)
지문 코퍼스는 TOML 파일(rules/waf_fingerprints.toml)로 관리됩니다. Kubernetes 배포 환경에서는 ConfigMap으로 관리되어, 프로세스를 재시작하지 않고도 운영자(operators)가 시그니처를 업데이트할 수 있습니다.
컴파일된 Rules는 arc_swap::ArcSwap으로 래핑되어 있습니다. TomlClassifier::watch()는 파일이 변경될 때 규칙 세트를 원자적으로 교체(atomically swap)하는 파일 시스템 와처(filesystem watcher)를 시작합니다:
pub struct TomlClassifier {
rules: ArcSwap<Rules>,
}
...
이벤트는 500ms 동안 디바운스(debounced)됩니다. 이는 tmpfile+rename 방식을 통해 파일을 쓰는 에디터와, 동일한 파일 시스템 이벤트 시퀀스를 생성하는 Kubernetes ConfigMap의 원자적 투영(atomic projection) 메커니즘을 모두 처리하기 위함입니다.
도메인별 EWMA 상태
엔진은 지수 가중 이동 평균(Exponentially Weighted Moving Average, EWMA)을 사용하여 도메인당 차단율(block rate)을 추적합니다. 높은 차단율은 시작 티어(starting tier)를 격상시킵니다. 즉, 지속적으로 차단이 발생한 도메인은 항상 Http를 먼저 시도하는 대신 Bypass 또는 Browser 단계에서 시작합니다.
DomainStatePort 트레이트(trait)는 주입(injectable)이 가능합니다:
#[async_trait]
pub trait DomainStatePort: Send + Sync + fmt::Debug {
async fn recommend(&self, domain: &str) -> DomainRecommendation;
...
기본 구현체(EwmaDomainState)는 자동으로 연결됩니다.
kreuzberg-cloud는 이를 인스턴스 간 도메인 인텔리전스를 위한 분산 저장소(distributed store)로 대체합니다.
디스패치(dispatch) 설정
use std::sync::Arc;
use kreuzcrawl::{
CrawlConfig, DispatchProfile, EscalationStrategy,
...
CLI와 동일한 수준의 MCP 서버
이제 MCP 서버는 CLI와 1:1로 대응하는 도구들을 제공합니다 — scrape, batch_scrape, batch_crawl, download, 그리고 generate_citations입니다. 이전 릴리스들은 부분적인 기능만을 지원했으나, v0.3.0에서는 그 격차를 해소했습니다.
안전 주석 (Safety annotations)
각 도구는 MCP 스펙에 따라 세 가지 안전 속성 (safety properties)을 선언합니다:
| 속성 (Property) | 값 (Value) | 의미 (Meaning) |
|---|---|---|
read_only | true | 외부 상태를 수정하지 않음 |
| ... |
open_world: true가 핵심적인 속성입니다. MCP 호스트는 이를 사용하여 에이전트가 외부 요청을 보내기 전에 추가적인 샌드박싱 (sandboxing)을 적용하거나 확인을 요청할 수 있습니다. SSRF 정책은 이를 강제하는 계층 역할을 합니다: http://169.254.169.254/로의 요청은 어떠한 네트워크 활동이 일어나기 전에 SsrfPolicyViolation 에러를 반환합니다.
전송 (Transport)
서버는 호출 방식에 따라 두 가지 모드로 실행됩니다:
- stdio — stdin에서 JSON-RPC를 읽고 stdout으로 씁니다. Claude Desktop, Cursor, 그리고 바이너리를 서브프로세스 (subprocess)로 생성하는 도구들에 의해 사용됩니다.
- /mcp 경로의 Streamable HTTP — 서비스 배포에 사용됩니다. 바이너리가
--features api,mcp옵션과 함께 빌드되었을 때 활성화됩니다.
# stdio 모드 (서브프로세스)
kreuzcrawl mcp
...
확장된 CLI
네 가지 서브커맨드 (subcommands)를 통해 CLI와 코어 및 MCP 인터페이스 간의 1:1 매핑이 완성되었습니다:
| 명령 (Command) | 설명 (Description) |
|---|---|
batch-scrape <urls…> | 여러 URL을 동시에 스크래핑 (scrape)하고 구조화된 JSON을 출력합니다 |
| ... |
# 두 개의 시드(seed)를 크롤링하여 Markdown으로 출력
kreuzcrawl batch-crawl \
https://docs.example.com \
...
독립형 robots.txt 및 사이트맵 파서 (parsers)
kreuzcrawl::robots와 kreuzcrawl::sitemap은 이제 공개 모듈(public modules)이 되어, 크롤링 엔진을 구축하지 않고도 사용할 수 있습니다:
use kreuzcrawl::robots::{parse_robots_txt, is_path_allowed};
use kreuzcrawl::sitemap::{parse_sitemap_xml, parse_sitemap_index};
...
이는 전체 크롤링을 실행하지 않고도 robots.txt 접근 규칙을 평가하거나 사이트맵에서 URL을 열거해야 하는 컴플라이언스 도구 (compliance tooling), 링크 그래프 빌더 (link-graph builders), 그리고 크롤링 플래너 (crawl planners)에 유용합니다.
브라우저 풀 (Browser pool) 및 실행기 주입 (executor injection)
BrowserPool, BrowserPoolConfig, NativeBrowserExecutor, 그리고 NativeBrowserExecutorConfig가 이제 공개(public)되었습니다. 동일한 대상(targets)에 대해 많은 크롤링(crawls)을 수행하는 호출자는 풀(pool)을 한 번 생성 및 예열(warm)한 뒤 재사용할 수 있습니다:
use kreuzcrawl::{BrowserPool, BrowserPoolConfig, CrawlEngineBuilder};
let pool = BrowserPool::new(BrowserPoolConfig::default()); // 동기(sync) 방식이며, Arc<BrowserPool>을 반환합니다.
...
풀 주입(pool injection)이 없으면 각 엔진은 자체적인 Chrome 인스턴스를 생성하고 해제합니다. 풀 주입을 사용하면 브라우저 프로세스가 크롤링 작업(crawl jobs) 전반에 걸쳐 유지됩니다. 이는 짧은 크롤링 작업을 긴밀한 루프(tight loop) 내에서 많이 실행할 때 유용합니다.
관측 가능성 (Observability)
v0.3.0에는 두 개의 OpenTelemetry 카운터(counters)가 추가되었습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기