Pyro Caml 발표: OCaml을 위한 최초의 연속 프로파일러
요약
본 기사는 OCaml 기반 SAST 엔진인 Semgrep이 직면한 관측 가능성(observability) 문제를 해결하기 위해 개발된 연속 프로파일러 Pyro Caml의 출시를 발표합니다. 기존 프로파일러로는 운영 환경에서의 지속적인 성능 측정이 어려웠으며, 특히 gVisor와 같은 샌드박스 환경 제약으로 인해 새로운 솔루션이 필요했습니다.
핵심 포인트
- Pyro Caml은 OCaml을 위한 최초의 연속 프로파일러입니다.
- 연속 프로파일링은 운영 환경에서 지속적으로 성능 데이터를 수집하는 것이 핵심입니다.
- Semgrep과 같은 SAST 도구는 사용자 코드에 대한 지속적인 관측 가능성이 필수적입니다.
- gVisor와 같은 샌드박스 환경 제약으로 인해 기존 프로파일러 사용에 어려움이 있었습니다.
Semgrep의 핵심 SAST 엔진은 OCaml로 작성되어 있습니다. 이에 대한 기술적, 역사적 배경이 많이 있지만, 이는 나중에 다루도록 하겠습니다. OCaml처럼 (상대적으로) 생태계가 작은 언어를 사용한다는 중요한 결과는, Semgrep과 같은 산업용 소프트웨어를 수십만 개의 코드 저장소에서 신뢰성 있고 성능 좋게 실행하는 데 필수적인 관측 가능성(observability)을 위한 라이브러리가 많지 않다는 것입니다.
저희는 __OCaml OpenTelemetry library__와 같은 기존 라이브러리를 많이 사용했으며, 자체적으로 기여하고 작성한 것도 있습니다. 작년에 저는 FunOCaml에서 워크숍을 열며, 저희가 관측 가능성을 어떻게 활용하고 이점을 얻는지, 그리고 여러분도 OCaml 프로그램에 이를 어떻게 구현할 수 있는지 설명했습니다. 하지만 그 워크숍 이후로 여러 사람이 찾아와 “연속 프로파일링(continuous profiling)은 어떻습니까?”라고 물었고, 제 대답은 “아직 존재하지 않습니다”였습니다. 자, 7개월 후, 저는 OCaml을 위한 연속 프로파일러인 Pyro Caml의 1.0.0 버전을 출시하게 되었음을 기쁘게 발표합니다.
사례 연구: Pyro Caml이 오염 분석(Taint Analysis) 최적화를 식별하는 데 도움을 준 방법
연속 프로파일링이란 무엇인가?
기술적인 세부 사항에 들어가기 전에, 일반 프로파일러와 연속 프로파일러의 차이점을 먼저 이해해야 합니다. 프로파일링은 시간 복잡도, 명령어 사용량 또는 코드 어느 부분에서 시간이 소요되는지 등 프로그램의 측면을 측정하는 동적 분석(dynamic analysis) 한 형태입니다. OCaml에는 내장된 ocamlprof, magic-trace, 또는 __olly__와 같은 몇 가지 프로파일러가 있습니다. 연속 프로파일러를 차별화하는 것은 개발자가 직접 실행하는 것이 아니라, 대신 운영 환경에서 프로그램을 지속적으로 프로파일링하고 그 데이터를 중앙 위치로 보고한다는 점입니다.
저희에게 이 구분은 엄청나게 중요합니다. 저희는 언급된 다른 프로파일러들, 그리고 prof 같은 더 일반적인 프로파일러들을 사용해서 버텨왔지만, 그것만으로는 부족했습니다. Semgrep은 코드에 정적 분석(static analysis)을 실행하며, 일반적으로 저희는 엔지니어들이 분석하는 사용자 코드에 쉽게 접근하는 것을 피하려고 합니다. 따라서 자체 머신에서 소스 코드를 로컬로 복사하여 프로파일링할 수 없다면, 지속적인 프로파일링(continuous profiling)이 유일한 선택지가 됩니다. 게다가, 메트릭과 트레이싱은 어디를 봐야 할지 알 때만 성능 문제를 찾아내는 데 도움이 되는데, 코드베이스가 성숙해질수록 그 경우가 점점 드물어집니다. 저희는 고객이 코드를 스캔하는 동안 반드시 프로파일링을 해야 합니다. 그렇지 않으면 영원히 어둠 속에 살 운명입니다.*
지속적 프로파일러의 요구사항
그래서 저희는 지속적 프로파일러가 엄청나게 유용한 도구라는 것을 알지만, Semgrep에는 몇 가지 추가적인 제약 사항이 있습니다.
gVisor 환경에서 실행
저희는 보안 회사이기 때문에 안전한 상태를 유지하는 것을 좋아합니다. 이는 누군가의 코드를 스캔할 때, 저희가 __gVisor__를 사용하여 스캔을 샌드박스(sandbox) 처리한다는 의미인데, gVisor는 사용자 공간에서 Linux API를 구현합니다. Linux API 중 하나인 __perf_event_open__은 이를 구현하지 않는데, 이 함수는 prof 같은 프로파일러들이 작동하는 방식이며 일부 지속적 프로파일러들도 여기에 기반을 두고 있습니다(예: ddprof). 저희의 첫 시도는 이러한 도구 중 하나를 사용했습니다. 테스트 환경에서는 모든 것이 완벽하게 작동했지만, 운영 환경에 배포했을 때 이 시스템 호출이 전혀 작동하지 않는다는 끔찍한 오류가 발생했습니다. 결국 원인이 gVisor라는 것을 알아냈고, 작동하지 않아 실망스러웠지만, 제가 직접 프로파일러를 작성할 기회가 생길 수도 있다는 생각에 은근히 기대되었습니다.
OCaml 지원
OCaml을 지원하는 연속 프로파일러는 __Pyroscope__나 __Datadog의 Python Profiler__처럼 언어 런타임과 잘 통합되는 것이 몇 가지 있습니다 (따라서 perf_event_open을 사용하지 않습니다). 하지만 OCaml용은 없습니다. 당시 저희는 자체적으로 구축한다면, 커뮤니티에 유용하게 만들 수 있도록 오픈 소스 표준을 기반으로 해야 한다고 알고 있었습니다 (그래야 처음부터 너무 많은 툴링을 작성할 필요가 없으니까요). 특히 Pyroscope의 SDK는 __오픈 소스__인 반면 Datadog의 것은 그렇지 않습니다.
성숙도(Maturity)
OpenTelemetry 역시 당시 (pre-alpha) 프로파일링 사양과 함께 Elastic Search에서 기꺼이 기증한 __프로파일러__를 가지고 있었습니다. 이 프로파일러는 매우, 매우 훌륭하며, Python이나 Ruby 같은 인터프리터 언어의 스택 트레이스를 원시 메모리에서 디코딩하는 eBPF 프로그램을 통해 작동합니다. 정말 놀라운 기술이며, 이 게시물이 흥미롭다면 해당 리포지토리를 살펴보는 가치가 있습니다. 이를 OCaml 프로그램으로 로컬에서 실행했을 때 매우 신비한 스택 트레이스가 나왔고, 유용한 정보를 얻으려면 OCaml 스택을 탐색하는 자체 eBPF 프로그램을 작성해야 한다는 것을 곧 깨달았습니다. 비록 이것이 재미있게 들리긴 하지만, OTel 시그널도 나머지 인프라와 함께 pre-alpha였기 때문에, 설령 우리가 eBPF를 다룰 수 있다 하더라도 여전히 '만약에'가 많았습니다. 게다가, gVisor에서 작동할지조차 확신하지 못했습니다 (추가 연구 결과, 작동하지 않는다는 것을 알게 되었습니다). 따라서 이것은 엄격한 불가능이라기보다는, 이 경로를 택한다면 매우 위험하고 어려운 프로젝트였습니다.
성능 및 안전성
성능과 안전성이 필요합니다! 프로파일러가 프로그램의 런타임에 상당한 영향을 미친다면, 실제 운영 환경에서 사용하기 어렵습니다. 기존의 OCaml 프로파일러들은 견고한 도구이지만, 상당한 오버헤드를 발생시켜 시작점으로 삼기에는 부족합니다. 만약 프로그램이 느린 원인을 파악하려 하는데 프로파일러가 80%의 오버헤드를 추가한다면… 그 프로파일러는 제거해야 합니다. 완벽하게는 오버헤드가 전혀 없어야 하지만, 저희는 약 5%까지는 감수할 의향이 있습니다. 또한 프로파일러는 안전해야 합니다. 즉, 실패하더라도(스택을 추적하거나 데이터를 보고하는 등의 경우) 프로그램의 정확성에 영향을 주어서는 안 됩니다.
Pyro Caml 도입
이 시점에서 볼 때, 시중에 나와 있는 어떤 것도 저희에게 정말로 작동할 것이라는 것은 분명했고, 자체적으로 구축할 수 있는 옵션은 Pyroscope와 OTel 프로파일러에 통합하는 것이었습니다. 저는 이미 OTel 프로파일러의 위험성을 나열했으니, Pyroscope는 어떨까요? 그들의 Rust SDK는 훌륭하고, 새로운 소스에서 데이터를 받아들이도록 설정되어 있을 뿐만 아니라, 백엔드 인프라 역시 저희가 이미 가지고 있는 Grafana 인스턴스에 쉽게 구축할 수 있는 것이었습니다. 따라서 인프라 부분은 사소하며(OTel 프로파일러와 달리) SDK도 훌륭했습니다. 저희는 단지 perf_event_open 없이 성능 데이터를 수집하고 실제로 Pyroscope SDK로 전송하는 방법만 필요했을 뿐입니다.
다음은 Pyro Caml의 아키텍처입니다:
Memprof를 통한 호출 스택 샘플링
먼저, 현재 실행 중인 프로그램의 호출 스택을 얻을 방법이 필요했습니다. 이를 수행할 수 있는 몇 가지 방법이 있는데, 예를 들어 원시 메모리를 보고 DWARF 심볼과 같은 것을 사용하는 것입니다. 이는 eBPF와 prof가 하는 방식과 유사합니다. 예전에 저는 실제로 저희 라이브러리인 __OBackward__를 위해 이런 작업을 했었는데, 이 라이브러리는 OCaml 프로그램의 세그폴트(segfault) 시에 깔끔한 백트레이스(backtraces)를 제공합니다.
요약하자면, 그러한 코드는 이식성이 완전히 떨어지며, 컴파일러가 DWARF 심볼을 생성하는 데는 능숙하지만, 백트레이스는 런타임에 내장된 것만큼 유용하지 않습니다.
그래서 다른 옵션은 내장된 기능을 사용하는 것입니다. 구체적으로 OCaml 표준 라이브러리에 있는 현재 호출 스택(callstack)을 반환하는 함수인 Printexc.get_callstack을 사용합니다. 하지만 여기서 문제가 발생합니다. 아무리 이 기능을 SDK로 가져오는 좋은 방법을 가지고 있다 하더라도, 이것으로 프로그램을 쉽게 계측(instrument)할 수 있을까요? 가장 좋은 방법은 원하는 모든 곳에 코드를 추가하는 PPX (OCaml 매크로)를 작성하는 것인데, 이것이 바로 Landmarks 라이브러리가 하는 일입니다. 하지만 안타깝게도 이 코드가 대부분의 코드보다 빠르지 않다면, 계측된 빠른 함수를 반복적으로 호출할 때 그 코드 경로가 계측 자체 때문에 더 오래 걸리는 문제가 발생합니다. 이는 PPX에 주의해야 한다는 것을 의미하며, 따라서 프로그램의 어떤 부분이 느린지 또는 빠른지 알아야 하는데, 이 지점에서 프로파일러는 우리의 요구 사항을 충족시키지 못합니다.
이것이 많은 프로파일러가 통계적 샘플링 프로파일러(statistical sampling profilers)인 이유입니다. 함수가 시작하고 멈출 때와 그 호출 스택을 측정하는 대신, 단순히 설정된 빈도로 샘플링합니다. 이는 프로파일러 오버헤드가 호출 횟수에 대한 함수가 아니라 일정한 값이라는 것을 의미합니다. 결과는 약간 덜 정확하지만, 충분히 사용할 수 있는 수준입니다.
그렇다면 eBPF/perf_event_open에 접근할 수 없고 원시 메모리를 검사(introspect)하고 싶지 않을 때 OCaml에서 어떻게 이것을 할 수 있을까요? 우리는 조금 까다로운 방법을 사용하여 OCaml의 내장 메모리 프로파일러인 __Memprof__를 사용할 수 있습니다. Memprof는 구성 가능한 비율로 할당되는 콜백 함수 세트를 실행합니다. 이는 Semgrep과 같은 프로그램의 경우, 우리가 거의 모든 시간을 순수 CPU 작업에 사용하며, 여기에는 많은 할당이 포함된다는 것을 알기 때문에 좋습니다.
이는 우리가 시간 단위가 아닌 할당(allocation) 빈도로 샘플링한다는 것을 의미합니다. 따라서 어떤 함수가 다른 함수보다 더 많은 할당을 수행할 경우 부정확한 프로파일을 얻거나 오버헤드를 발생시킬 위험이 여전히 있습니다. 이는 다음 섹션에서 다룰 문제입니다.
마지막으로, 작은 보너스 하나는 Memprof 콜백이 코드가 이미 할당된 위치의 호출 스택(callstack)을 제공한다는 것입니다! 그래서 우리가 해야 할 일이 하나 줄어듭니다. 실제로 Memprof 콜백은 자체 스택에서 실행되므로, 그렇지 않았다면 우리는 훨씬 더 까다롭게 접근해야 했을 것입니다.
OCaml 런타임 이벤트를 통해 프로파일링 이벤트 내보내기
이제 여러 샘플을 확보했으므로, 두 가지를 해야 했습니다. 첫째, Memprof 샘플러가 실행하기로 결정할 때마다가 아니라 일정한 간격으로 샘플을 해석할 수 있는 방법이 필요했습니다. 둘째, 실제로 Pyroscope SDK를 호출하여 샘플을 저희 인프라로 전송해야 했습니다. 앞서 언급했듯이, 샘플링 프로세스가 오래 걸릴수록 데이터가 왜곡될 가능성이 높아지며, 네트워킹/샘플 해석 과정도 시간이 오래 걸릴 수 있습니다. 따라서 이 샘플들을 디스크에 빠르게 기록할 수 있다면, 다른 프로그램에서 처리하여 전송함으로써 오버헤드를 최소화할 수 있습니다.
OCaml 5는 우리가 정확히 원하는 __런타임 이벤트(Runtime Events)__를 도입했습니다!
[...] OCaml 런타임으로부터 매우 낮은 오버헤드로 성능 정보를 지속적으로 추출할 수 있게 해주는 런타임 이벤트 추적 시스템입니다.
이를 통해 우리는 이벤트를 백업된 링 버퍼 파일에 기록하고 다른 프로그램에서 읽어낼 수 있습니다. 이는 __Java Flight Recorder__나 Go의 __builtin runtime tracing__과 유사합니다. 따라서 이제 이러한 샘플들을 작성하고 다른 OCaml 프로그램에서 이를 읽을 수 있게 되었습니다.
샘플을 추적하는 데 도움이 되도록, 우리는 이러한 이벤트에 샘플이 기록된 시간도 함께 기록했습니다. 해당 프로파일러 프로그램은 이 이벤트들을 읽고 처리하기 시작합니다. 샘플들이 정해진 간격으로 촬영된다는 보장이 없었기 때문에, 저희가 한 일은 가능한 한 많은 것을 생성하려고 시도한 다음, 적절한 샘플 간격에 가장 가까운 것을 선택하는 것이었습니다. 이는 주어진 샘플 간격에 대해 선택할 수 있는 여러 개의 가능한 샘플이 있다는 의미였고, 이를 통해 우리가 완전한 호출 스택을 생성하기를 원하는 단일 시간 지점에 충분히 가까운 타임스탬프가 찍힌 샘플을 선택할 수 있었습니다. 여기서 단점은, 많은 샘플을 방출하지 않는 프로그램의 경우, 샘플 간격 시간보다 짧게 지속된 함수 호출에 대한 정확도를 잃고, 할당이 자주 일어나지 않은 시간 간격에는 샘플을 받지 못했을 수 있다는 것입니다.
예시 프로그램의 __flame graph__를 고려해 봅시다 (func_a가 func_b를 호출하고, 이는 다시 func_d를 호출하는 것으로 읽으십시오). 우리가 10ms마다 샘플링한다고 가정합니다 (Pyro Caml의 기본값):

시간 t+10에 샘플링한다고 해봅시다. 이상적으로는 샘플이 정확히 t+10에 발생하는 호출 스택을 포함하기를 원하겠지만, 코드가 할당될 때만 샘플을 방출하기 때문에 이를 보장할 수는 없습니다. 하지만 만약 우리가 구간 [(t+0), (t+10)] 내에서 샘플을 생성한다면, func_a의 지속 시간이 샘플 간격보다 크기 때문에 반드시 호출 스택에 func_a를 포함해야 한다는 사실을 이용할 수 있습니다. 따라서 우리는 최소한 Memprof 접근 방식을 사용하고 10ms마다 샘플을 방출하는 한, 지속 시간이 최소 10ms인 모든 함수의 경우 그 호출 스택이 우리가 설명한 이상적인 호출 스택과 동일하다는 것을 알게 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기