GoSuda

gosuda.org/zstd: CGO不要かつクラッシュ隔離を備えたDataDog/zstdの完全互換ドロップイン置換

By Lemon Mint views ...

gosuda.org/zstd は CGO_ENABLED=0 で動作する Go 圧縮ライブラリであり、HQQ 共有メモリを介して、公式の C Zstandard (libzstd 1.5.7) エンジンを隔離されたワーカープロセス内で実行します。github.com/DataDog/zstd に対するドロップイン置換 API を提供し(一回限りの Compress/Decompress、ストリーミング io.Writer/io.Reader、事前処理済み辞書、再利用可能なゼロアロケーションのコンテキストを含む)、C による実行を Go ランタイムのアドレス空間から完全に分離した状態に保ちます。

1go get gosuda.org/zstd@v0.3.10507

展開処理を Go ヒープの外に置く理由

本番環境のバックエンドでは、圧縮と展開は信頼されていない入力の境界に直接位置します。ログコレクター、メッセージブローカー、RPC ゲートウェイ、ストレージエンジンは、外部から供給されたフレームを毎分ギガバイト単位で展開しています。

多くの Go チームは github.com/DataDog/zstd を選択しています。これは上流の C libzstd に直接バインドしているためです。C 実装は、単一コアでの高いスループット、小さなレコードに対する高密度な辞書圧縮率、そして Go のガベージコレクションのスキャン時間を増大させないオフヒープの C アリーナ (ZSTD_CCtx および ZSTD_DCtx) を提供します。

しかしながら、cgo を介して libzstd を Go サービスにリンクすると、運用上の問題が二つ生じます。

  1. ビルドおよびデプロイの困難: go-sqlite3 と同様に、DataDog/zstd は CGO_ENABLED=1 と対象となる C コンパイラツールチェーンを必要とします。linux/arm64 や windows/arm64 向けにサービスをクロスコンパイルしたり、静的バイナリをビルドしたりするには、外部の C sysroot が必要になります。
  2. 信頼されていない入力に対する共有アドレス空間: 破損した、あるいは悪意のあるペイロードを Go サーバーと同じ仮想アドレス空間内で展開すると、ネイティブ C コードやカスタム辞書処理のバグによって Go プロセス全体が終了する可能性があります。

gosuda.org/zstd は、上流の C libzstd 1.5.7 エンジンそのものと、慣れ親しんだ github.com/DataDog/zstd API を維持しつつ、C による実行を、キャッシュライン単位の共有メモリで Go プログラムに接続された検証済みの子プロセス (zstd_server) に移します。

github.com/DataDog/zstd のドロップイン置換

github.com/DataDog/zstd から移行するには、インポートパスを "gosuda.org/zstd" に変更するだけで十分です。外部バイナリのインストールやセットアップコードは不要です。最初の呼び出し時に、gosuda.org/zstd は、お使いのプラットフォーム向けの SLSA 証明付きで埋め込まれた zstd_server バイナリを展開し、gosuda.org/hqq/secureexec を通じてその SHA-256 ダイジェストを検証し、共有メモリセッションプールを開きます。

 1package main
 2
 3import (
 4	"bytes"
 5	"fmt"
 6	"io"
 7	"log"
 8
 9	"gosuda.org/zstd"
10)
11
12func main() {
13	// 1. One-shot Compress / Decompress (mirrors github.com/DataDog/zstd)
14	src := []byte("shared-memory zstd payload without cgo")
15	compressed, err := zstd.CompressLevel(nil, src, zstd.BestSpeed)
16	if err != nil {
17		log.Fatal(err)
18	}
19	plain, err := zstd.Decompress(nil, compressed)
20	if err != nil {
21		log.Fatal(err)
22	}
23	fmt.Printf("one-shot: %d bytes -> %d bytes (%s)\n", len(src), len(compressed), plain)
24
25	// 2. Streaming Writer and Reader with Dictionary Support
26	var buf bytes.Buffer
27	w := zstd.NewWriterLevel(&buf, zstd.DefaultCompression)
28	if _, err := w.Write(src); err != nil {
29		log.Fatal(err)
30	}
31	if err := w.Close(); err != nil {
32		log.Fatal(err)
33	}
34
35	r := zstd.NewReader(&buf)
36	defer r.Close()
37	streamed, err := io.ReadAll(r)
38	if err != nil {
39		log.Fatal(err)
40	}
41	fmt.Printf("streamed: %s\n", streamed)
42}

サポートされるプラットフォームと埋め込みバイナリ

six つの対象プラットフォームすべてに、プラットフォーム固有のビルドタグ (internal/embedded/embed_<goos>_<goarch>.go) を介して埋め込まれた、ビルド済みで SHA-256 により固定されたワーカーバイナリが含まれています。

対象 OS対象アーキテクチャ (GOARCH)埋め込みバイナリバイナリサイズビルドツールチェーンと来歴
Linuxamd64, arm64zstd_server_linux_<arch>約 770 - 880 KiBGCC -O3、SLSA Build L3 来歴
macOSarm64, amd64zstd_server_darwin_<arch>約 660 - 795 KiBAppleClang -O3、SLSA Build L3 来歴
Windowsamd64zstd_server_windows_amd64.exe約 818 KiBMSVC -O2、SLSA Build L3 来歴
Windowsarm64zstd_server_windows_arm64.exe約 551 KiBzig cc -target aarch64-windows-gnu -O3、SLSA L3

共有メモリのデータパスの内部

cgo によるインプロセス実行に匹敵するスループットを維持するため、gosuda.org/zstd はホットパスにおけるソケットによるシリアライズとヒープアロケーションを回避しています。

1. ゼロアロケーションのスロットエンコーディング (zstd_ctx.go と c/zstd_hooks.c)

再利用可能な zstd.Ctx を使用する場合、またはパッケージレベルの Compress と Decompress 関数が内部セッションプールから Ctx を借用する場合、データはマッピングされた StandardLink スロットを通じて直接流れます。

1Go Caller (zstd.Ctx)                    Shared-Memory Slot                    C11 Worker (zstd_hooks.c)
2--------------------                    ------------------                    -------------------------
3src []byte  ------------------------->  Request Slot Payload  ------------->  ZSTD_compressCCtx(
4                                                                                cctx, resp_slot, src, level)
5dst []byte  <-------------------------  Response Slot Payload <-------------  Writes compressed frame directly
6                                                                              into shared response slot
  1. 生成された hqq-bindgen クライアントは、src をメモリマップされたリクエストスロットに直接コピーし、SeqLock のシーケンスワードを反転させます。
  2. C ワーカーは、リクエストスロットから src をその場で読み取り、レスポンススロットのペイロードポインタを、送信先バッファとして ZSTD_compressCCtx または ZSTD_decompressDCtx にそのまま渡します。
  3. Go クライアントは、結果のスライスをレスポンススロットから呼び出し元が指定した dst スライスにコピーします。定常状態において、ctx.CompressTo(dst, src, level) と ctx.DecompressInto(dst, src) は 0 allocs/op で動作します。

事前処理済みの辞書 (CDict および DDict) は、zstd.idl の handle CDict、handle DDict で定義された、世代検査付きの 64 ビットハンドルの背後で C ワーカー上に存在します。ctx.NewCDict(rawDict, level) で辞書を一度作成すると、C ワーカー内で辞書テーブルが解析され、辞書のバイト列を再送することなく、数千回の呼び出しで再利用できる軽量なハンドルが返されます。

2. バルクフレーミングによる IPC オーバーヘッドの償却 (zstd_bulk.go)

大きなペイロード(16 KiB 以上)の場合、約 200 ns の共有メモリのラウンドトリップは、libzstd 内で費やされる CPU 時間に比べて無視できる程度です。しかしながら、100 バイトから 1 KiB の小さなメッセージを数千個まとめて圧縮するワークロードでは、項目ごとに 200 ns を費やすだけでも累積します。

gosuda.org/zstd は、項目ごとのラウンドトリップを排除するために、BulkCtx(zstd_bulk.go)上に CompressBulk と DecompressBulk を提供します。

 1bctx, err := client.NewBulkCtx(1 << 20)
 2if err != nil {
 3	log.Fatal(err)
 4}
 5defer bctx.Close()
 6
 7items := [][]byte{
 8	[]byte(`{"event":"login","user":101}`),
 9	[]byte(`{"event":"purchase","user":101,"amount":4200}`),
10	[]byte(`{"event":"logout","user":101}`),
11}
12compressedItems, err := bctx.CompressBulk(nil, items, zstd.DefaultCompression)

CompressBulk は、[][]byte のバッチ全体を、共有メモリ内の単一の長さ接頭辞付きリクエストフレームにパックします。C ワーカーは、C 内(c/zstd_hooks.c の compress_bulk)ですべての項目をループ処理し、圧縮されたフレームをすべて連続してレスポンススロットに書き込み、バッチ全体を単一の共有メモリのラウンドトリップで返します。

3. ワーカーの終了を生き延び、呼び出し元をクラッシュさせない

zstd_server は独立した OS プロセスで実行されるため、C ワーカー内で発生した致命的な障害が Go アプリケーション全体を停止させることはありません。

  • zstd_server が外部から(例えば SIGKILL や OOM イベントによって)強制終了された場合、HQQ トランスポート層は切断されたピアを即座に検出し、進行中の Compress または Decompress 呼び出しから型付きの *rt.TransportError を返します。
  • パッケージレベルの関数(zstd.Compress、zstd.Decompress、zstd.NewWriter、zstd.NewReader)は、共有デフォルトワーカーが存在しないことを自動的に検出し、次の呼び出し時に検証済みの新しい zstd_server インスタンスを起動して、通常の動作を再開します。

リポジトリの構成

コードベースは、ドロップインの DataDog/zstd ファサードと、低レベルの hqq-bindgen セッションおよびバルクプリミティブとを分離しています。

1gosuda.org/zstd
2├── autolaunch.go / hashes.go  # Drop-in Compress/Decompress API, auto-recovery pool, and SHA-256 hashes
3├── zstd_stream.go             # Streaming Writer/Reader (NewWriterLevelDict, NewReaderDict)
4├── zstd_ctx.go                # Zero-allocation Ctx, CDict, and DDict wrappers over hqq-bindgen
5├── zstd_bulk.go               # Single-round-trip CompressBulk and DecompressBulk framing
6├── zstd.go                    # Generated hqq-bindgen Go client for the zstd IDL service
7├── zstd.idl                   # HQQ IDL schema defining Context, CDict, DDict, and RPC methods
8├── internal/embedded/         # Platform-tagged //go:embed zstd_server binaries (6 OS/Arch targets)
9└── c/                         # C11 server implementation (zstd_server.c, zstd_hooks.c, main.c)

はじめに

go get で gosuda.org/zstd をインストールし、CGO_ENABLED=0 を指定して使用します。

1go get gosuda.org/zstd@v0.3.10507