Show HN: 경량 분산 Python 런타임인 Wool을 개발했습니다
요약
Wool은 중앙 집중식 스케줄러 없이 피어 투 피어(P2P) 네트워크를 통해 작업을 실행하는 경량 분산 Python 런타임입니다. 단 하나의 데코레이터만으로 비동기 함수와 제너레이터를 원격 실행 가능하게 하며, 직렬화와 라우팅을 자동으로 처리합니다.
핵심 포인트
- 중앙 제어 평면 없이 탈중앙화된 P2P 네트워크를 통한 분산 실행 지원
- @wool.routine 데코레이터를 통한 간편한 원격 함수 호출 및 비동기 의미론 유지
- 직렬화, 라우팅, 전송 과정을 자동화하여 개발자 편의성 증대
- 최선 노력(Best-effort) 및 최대 한 번(At-most-once) 실행 모델 제공
- Kubernetes와 같은 기존 오케스트레이션 도구와 결합하여 사용 가능
Wool은 중앙 집중식 스케줄러(Scheduler)나 제어 평면(Control plane)을 도입하지 않고, 불가지론적(Agnostic) 워커 프로세스(Worker processes)의 수평 확장 가능한 풀(Pool)에서 작업을 실행하는 분산 Python 런타임(Runtime)입니다. 대신, Wool 루틴(Routines)은 워커들의 탈중앙화된 피어 투 피어(Peer-to-peer) 네트워크로 직접 전달됩니다. 클러스터 생명주기(Lifecycle)와 노드 오케스트레이션(Orchestration)은 Kubernetes와 같은 목적에 맞게 설계된 도구에 맡길 수 있으며, Wool은 오로지 분산 실행(Distributed execution)에만 집중합니다.
단 하나의 데코레이터(Decorator)만으로 모든 비동기 함수(Async function)나 제너레이터(Generator)를 원격 실행 가능하게 만들 수 있습니다. 직렬화(Serialization), 라우팅(Routing), 전송(Transport)은 자동으로 처리됩니다. 호출자(Caller)의 관점에서 함수는 원래의 비동기 의미론(Async semantics)을 유지합니다. 즉, 반환 타입(Return types), 스트리밍(Streaming), 취소(Cancellation), 예외(Exceptions)가 모두 예상대로 동작합니다.
Wool은 최선 노력(Best-effort), 최대 한 번(At-most-once) 실행을 제공합니다. 내장된 조정 상태(Coordination state), 재시도 로직(Retry logic), 또는 영구적인 작업 추적(Durable task tracking) 기능은 없습니다. 이러한 사항들은 애플리케이션 정의(Application-defined)로 남습니다.
설치 (Installation)
pip 사용
pip install wool
Wool은 메이저 및 마이너 릴리스에 대해 출시 후보(Release candidates)를 게시합니다. 이를 설치하려면 --pre 플래그를 사용하세요:
pip install --pre wool
GitHub에서 클로닝 (Cloning from GitHub)
git clone https://github.com/wool-labs/wool.git
cd wool
pip install ./wool
빠른 시작 (Quick start)
import asyncio
import wool
...
루틴 (Routines)
Wool 루틴(Routine)은 @wool.routine으로 데코레이팅된 비동기 함수입니다. 호출 시 함수는 직렬화되어 풀 내의 워커로 전달되며, 결과는 호출자에게 스트리밍되어 돌아옵니다. 호출 방식은 투명합니다. 특별한 메서드 없이 일반적인 비동기 함수처럼 루틴을 호출하면 됩니다. 코루틴(Coroutines)의 경우, routine(args)는 코루틴을 반환하며 await 시점에 전달(Dispatch)이 발생합니다. 비동기 제너레이터(Async generators)의 경우, routine(args)는 비동기 제너레이터를 반환하며 첫 번째 반복(Iteration) 시점에 전달이 발생합니다.
@wool.routine
async def fib(n: int) -> int:
if n <= 1:
...
결과 스트리밍을 위해 비동기 제너레이터도 지원됩니다:
@wool.routine
async def fib(n: int):
a, b = 0, 1
...
데코레이터가 적용된 함수, 그 인자(arguments), 반환되거나 yield된 값, 그리고 예외(exceptions)는 모두 cloudpickle을 통해 직렬화(serializable) 가능해야 합니다. 인스턴스 메서드, 클래스 메서드, 정적 메서드(static methods)가 모두 지원됩니다.
디스패치 게이트 (Dispatch gate)
내부적으로 @wool.routine 데코레이터는 함수를 do_dispatch 컨텍스트 변수를 확인하는 래퍼(wrapper)로 교체합니다. 이는 기본값이 True인 ContextVar[bool]이며 디스패치 게이트(dispatch gate) 역할을 합니다. 이 값이 True일 때 루틴(routine)을 호출하면, 호출을 태스크(task)로 패키징하여 원격 워커(remote worker)로 전송합니다. 워커는 함수 본문을 실행하기 전에 do_dispatch를 False로 설정하여 무한 재디스패치(re-dispatch)를 방지합니다. 함수 내의 중첩된 @wool.routine 호출에 대해서는 변수가 다시 True로 복구되므로, 해당 호출들은 다른 워커로 정상적으로 디스패치됩니다.
코루틴 (Coroutines) vs 비동기 제너레이터 (async generators)
코루틴(Coroutines)과 비동기 제너레이터(async generators)는 서로 다른 디스패치 경로를 따릅니다. 코루틴은 단일 요청-응답(request-response) 방식으로 디스패치됩니다. 즉, 워커가 함수를 실행하고 하나의 결과값을 반환합니다. 비동기 제너레이터는 풀 기반(pull-based) 양방향 스트리밍을 사용합니다. 클라이언트가 next/send/throw 명령을 보내면, 워커는 명령당 제너레이터를 한 단계씩 진행시키고 yield된 각 값을 다시 스트리밍합니다. 워커는 클라이언트가 다음 값을 요청할 때까지 yield 사이에서 일시 중지(pause) 상태가 됩니다.
태스크 (Tasks)
태스크(task)는 원격 실행에 필요한 모든 것을 캡슐화하는 데이터 클래스(dataclass)입니다. 여기에는 고유 ID, 비동기 호출 가능 객체(async callable), 인자(args/kwargs), 직렬화된 WorkerProxy(수신 측 워커가 자신의 태스크를 피어(peer)에게 디스패치할 수 있게 함), 선택적 타임아웃(timeout), 그리고 중첩된 태스크를 위한 호출자 추적(caller-tracking) 정보가 포함됩니다. 태스크는 @wool.routine이 적용된 함수가 호출될 때 자동으로 생성되며, 사용자가 수동으로 생성할 필요는 없습니다.
중첩 태스크 추적 (Nested task tracking)
이미 실행 중인 태스크 내부에서 새로운 태스크가 생성될 때, 새 태스크는 부모 태스크의 UUID를 자신의 caller 필드에 자동으로 캡처합니다. 이를 통해 부모-자식 체인이 구축되어 시스템이 어떤 태스크가 어떤 태스크를 생성했는지 추적할 수 있습니다.
프록시 직렬화 (Proxy serialization)
각 태스크는 직렬화된 WorkerProxy를 포함합니다. 태스크가 원격 워커(remote worker)에서 실행될 때, 다른 @wool.routine 함수를 호출할 수 있습니다 (중첩 디스패치 (nested dispatch)). 역직렬화된 프록시는 워커의 활성 프록시 컨텍스트 변수(active proxy context variable)로 설정되어, 원격 태스크가 풀(pool) 내의 다른 워커들에게 서브 태스크(sub-tasks)를 디스패치할 수 있는 능력을 부여합니다.
직렬화 (Serialization)
태스크 직렬화에는 두 가지 계층이 있습니다. cloudpickle은 Python 객체(호출 가능한 객체, 인자(args), 키워드 인자(kwargs), 그리고 프록시)를 바이트(bytes)로 직렬화합니다. 표준 pickle 모듈 대신 cloudpickle을 사용하는 이유는 대화형으로 정의된 함수, 클로저(closures), 람다(lambdas)를 포함하여 pickle이 직렬화할 수 없는 객체들을 직렬화할 수 있기 때문입니다. 이는 @wool.routine 데코레이터가 붙은 함수와 그 인자들이 임의의 워커 프로세스로 전송되기 위해 완전히 직렬화 가능해야 하므로 필수적입니다.
Protocol Buffers는 와이어 포맷(wire format)을 제공합니다. 스칼라(Scalar) 태스크 메타데이터(id, caller, tag, timeout)는 protobuf 필드에 직접 매핑되는 반면, Python 전용 객체들은 cloudpickle 바이트 블롭(byte blobs)으로 중첩됩니다. proto/ 디렉토리에 있는 protobuf 정의는 워커 간의 태스크 디스패치, 승인(acknowledgment), 그리고 결과 스트리밍(result streaming)을 위한 gRPC 와이어 프로토콜을 정의합니다.
컨텍스트 전파 (Context propagation)
Python의 contextvars.ContextVar는 피클링(pickled)될 수 없습니다. 이는 직렬화를 명시적으로 차단하는 C 확장(C extension) 타입이기 때문이며, 따라서 주변 상태(ambient state)가 프로세스 경계를 넘을 수 있는 내장된 방법이 없습니다. wool.ContextVar는 표준 라이브러리 API(get, set, reset)를 미러링하고 디스패치 체인을 통한 자동 전파 기능을 추가함으로써 이 문제를 해결합니다.
import asyncio
import wool
...
두 가지 생성 모드가 지원됩니다:
wool.ContextVar("name")— 기본값 없음; 값이 설정될 때까지get()은LookupError를 발생시킵니다.wool.ContextVar("name", default=...)— 현재 컨텍스트에 변수 값이 없을 때get()은 기본값을 반환합니다.
set()은 reset()을 호출할 때 이전 값(또는 설정된 값이 없다면 기본값)을 복구하는 Token을 반환하며, 이는 표준 라이브러리(stdlib)의 contextvars API를 그대로 반영합니다. 토큰은 논리적 체인(logical chain) 전체에서 단 한 번만 사용됩니다. 태스크 간(cross-task) 및 프로세스 간(cross-process) 스코핑(scoping) 규칙에 대해서는 아래의 제한 사항(Limitations)을 참조하세요.
각 변수의 네임스페이스(namespace)는 호출 프레임의 최상위 패키지로부터 추론되며, 클러스터 내의 모든 프로세스에서 안정적으로 유지되는 "<namespace>:<name>" 키를 생성합니다. 공유 팩토리 코드를 사용하여 변수를 생성하는 라이브러리 작성자는 동일한 패키지 아래의 애플리케이션 범위(application-scope) 변수와 충돌하는 것을 방지하기 위해 namespace=를 명시적으로 전달해야 합니다.
전파(propagation) 작동 방식
디스패치(dispatch) 시점에 Wool은 현재 wool.Context 내에서 명시적으로 set()된 변수들만 스냅샷(snapshot)을 찍습니다. 기본값만 있는 값은 전송되지 않습니다. 스냅샷은 프로세스 전체 레지스트리(registry)가 아닌, 각 컨텍스트별 데이터 딕셔너리(명시적으로 설정된 변수만 포함됨)를 반복(iterate)함으로써 $O(k)$ 시간 내에 조립됩니다. 이 스냅샷은 논리적 체인을 식별하는 활성 wool.Context ID와 함께, 각 변수의 "<namespace>:<name>"를 키로 갖는 map<string, bytes>를 담은 Context 프로토버프(protobuf) 메시지로서 모든 디스패치 프레임에 실려 전달됩니다.
wool.ContextVar.__reduce__는 변수의 현재 값을 reduce 튜플에 직접 포함시키므로, 피클링(pickled)된 객체 그래프의 어디에서든 wool.ContextVar가 나타나면 그 값도 함께 이동합니다. 태스크의 인자(args), 키워드 인자(kwargs), 그리고 ContextVar 스냅샷을 가로지르는 참조들은 수신 측의 동일한 로컬 인스턴스에 도달하게 됩니다. 언피클링(Unpickling)은 엄격한 생성 경로를 거칩니다. 해당 키 아래에 아직 등록된 변수가 없다면, 중복 키 체크를 우회하는 내부 백도어를 통해 "스텁(stub)" 인스턴스가 등록되고, 네트워크를 통해 전달된 변수 값이 적용됩니다. 이후 워커(worker)의 모듈 범위 생성자(module-scope constructor)가 실행될 때, 이 스텁을 제자리에서 승격(promote)시켜 네트워크 상태와 참조 정체성(reference identity)을 보존합니다.
워커(worker)에서 각 태스크는 호출자의 체인 ID(chain id)와 호출자의 전파된 값(propagated values)을 담은 자체적인 wool.Context 내에서 활성화되며, 이는 동일한 워커에서 실행되는 다른 동시 태스크의 wool.Context와는 구별됩니다. 워커가 반환(return)하거나 양보(yield)할 때, 최종 변수(var) 상태가 gRPC 응답에 첨부되어 호출자 측에 적용되므로, 워커 측의 변이(mutation)가 자동으로 다시 흘러 들어갑니다. 비동기 제너레이터(async generators)의 경우, 호출자는 각 반복 요청(iteration request)에 현재 컨텍스트를 첨부하여, 모든 yield/next 경계에서 호출자와 워커 간의 양방향 상태 교환을 가능하게 합니다.
격리 (Isolation)
각각 디스패치(dispatch)된 태스크는 호출자의 체인 ID와 호출자의 전파된 값을 담은 자체적인 wool.Context 내에서 실행됩니다. 동일한 변수에 대해 서로 다른 값을 가진 동일 워커 상의 동시 태스크들은 결코 간섭하지 않으며, 각 태스크는 오직 자신의 전파된 상태만을 봅니다. 워커 측의 변이(set()을 통해 수행)는 태스크가 반환하거나 양보할 때 호출자에게 역전파(back-propagated)되지만, 다른 동시 태스크로 유출되지는 않습니다. 각 디스패치는 워커에서 자체적인 wool.Context를 활성화하며, asyncio.create_task의 자식들은 생성 시 부모의 wool.Context 복사본을 포크(fork)합니다(contextvars.copy_context()의 의미론을 반영). 따라서 동시 실행 경로들은 가변적인(mutable) wool.Context를 공유하지 않으며, 투명한 디스패치(transparent-dispatch) 모델 하에서 양방향 값 전파가 일관되게 유지됩니다.
디코딩 실패 의미론 (Decode failure semantics)
컨텍스트 전파(Context propagation)는 wool의 와이어 프로토콜(wire protocol)에서 **부수적 상태(ancillary state)**입니다. 이는 루틴의 기본 신호(반환 값 또는 발생한 예외)와는 별개의 채널입니다. 와이어 컨텍스트의 디코딩에 실패할 경우(버전 간 pickle 불일치, 수신 측에 사용자 정의 클래스 누락, 단일 변수 값의 와이어상 손상 등), wool은 부수적 실패를 드러내기 위해 기본 신호를 가로채지(preempt) 않습니다. 루틴의 결과는 그대로 전달되며, 실패는 Python의 표준 warnings 메커니즘을 통해 wool.ContextDecodeWarning으로 보고되어 호출자가 어떻게 대응할지 결정할 수 있도록 합니다.
세 가지 모드를 사용할 수 있으며, 이들은 Wool 전용 API가 아닌 표준 Python 경고 (warnings) 시스템과 결합됩니다:
| 모드 | 활성화 방법 | 동작 |
|---|---|---|
| Lenient (기본값) | 별도 설정 없음 | 디코딩 실패 시 wool.ContextDecodeWarning을 발생시키며, 기본 신호(primary signal)를 반환합니다. 호출자 측의 예외 프레임(exception frames) 또한 __notes__를 통해 해당 실패 내용을 전달받습니다. |
| ... |
Lenient 기본 모드는 트레이싱 스타일의 상태를 권고 사항으로 취급하는 호출자들에게 Wool을 유용하게 유지해 줍니다. Strict 모드는 컨텍스트 상태에 따라 정확성이 결정되며 빠른 실패(fail fast)를 선호하는 호출자를 위한 것입니다. Inspect 모드는 기본 신호와 부수적인 실패에 대한 가시성을 모두 원할 때 적합한 선택입니다:
import warnings
import wool
...
동일한 의미론(semantics)이 통신(wire)의 양측 모두에 적용됩니다. 워커(worker)는 요청 컨텍스트(request context) 디코딩에 실패했을 때 ContextDecodeWarning을 발생시키며(그리고 폴백(fallback)으로서 새로운 빈 컨텍스트로 루틴을 실행합니다), 호출자(caller)는 응답 컨텍스트(response context) 디코딩에 실패했을 때 ContextDecodeWarning을 발생시킵니다(그리고 결과는 그대로 전달합니다). 호출자 측에서는 예외 프레임 디코딩 실패가 __notes__를 통해 루틴의 예외에 추가로 실려 전달되므로, 실패 내용이 트레이스백(traceback)에 나타납니다. 워커 측에서는 루틴 예외와 동시에 발생하는 스냅샷 인코딩(snapshot encode) 실패 역시 __notes__를 통해 루틴 예외에 유사하게 실려 전달됩니다. ExceptionGroup 체이닝이나 새로 배워야 할 래퍼 예외(wrapper-exception) API는 없습니다. 그저 표준 경고 클래스와 기본 신호에 대한 표준 try/except 문만 있으면 됩니다.
워커 측 Strict 모드
Strict 모드는 Python의 표준 PYTHONWARNINGS 환경 변수를 통해 워커 측에도 대칭적으로 적용됩니다. multiprocessing은 기본적으로 생성된 워커 서브프로세스(subprocess)로 이 변수를 전파합니다:
export PYTHONWARNINGS="error::wool.ContextDecodeWarning"
python my_app.py
또는 풀(pool)을 생성하기 전에 프로그래밍 방식으로 설정할 수 있습니다:
import os
os.environ["PYTHONWARNINGS"] = "error::wool.ContextDecodeWarning"
...
워커(worker)가 경고(warning)를 예외(exception)로 격상시키면, wool은 이를 루틴-예외(routine-exception) 채널을 통해 다시 전달합니다. 따라서 호출자(caller)는 호출자 측의 엄격 모드(strict mode)와 대칭적으로 정확히 동일한 wool.ContextDecodeWarning 클래스를 포착(catch)하게 됩니다. 별도로 처리해야 할 RpcError나 대역 외(out-of-band) 와이어 메타데이터(wire metadata)도 필요하지 않습니다.
태스크에 wool.Context 바인딩하기
새로 생성된 asyncio.Task에 wool.Context를 바인딩하는 표준적인 방법은 wool.create_task(타입이 지정된 심(shim))를 사용하거나, asyncio.create_task(또는 loop.create_task)를 사용하면서 context=wool_ctx를 직접 전달하는 것입니다:
ctx = wool.copy_context()
task = wool.create_task(some_coro(), context=ctx)
# 런타임 시 동일한 동작:
...
AI 자동 생성 콘텐츠
본 콘텐츠는 HN OpenAI Codex의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기