ANTIGRAVITY LABEN
記事一覧/アプリ開発
アプリ開発/2026-09-03中級

musllinux ホイールが無い依存は、Alpine で失敗せずに遅くなります

Antigravity Python SDK の musllinux ホイール対応を機に、Alpine コンテナで C 拡張が純 Python 実装へ落ちる経路を実測しました。protobuf の直列化は 0.043ms から 36.2ms へ変わります。事前の棚卸しと検知の手順まで書き残します。

Antigravity362Python SDKAlpinemusllinuxCI8

Antigravity の Python SDK に PEP 656 の musllinux ホイールが入ったという知らせを読んだ朝、私が最初に思い出したのは、自分で書いた小さなジョブのことでした。Lab のサイト運用で使っている、記事のメタデータを集めて数えるだけの Python スクリプトです。イメージを軽くしたくて、ベースを Alpine Linux にしていました。

そのジョブは、ある時期から処理時間が説明のつかない伸び方をしていました。私は「取得先のレスポンスが遅いのだろう」と決めつけて、しばらく放っておきました。いま思えば、いちばん確かめやすいところを飛ばしていたのです。

原因は、コンテナに入った依存が C 拡張ではなく純 Python の実装だったことでした。pip はエラーを一度も出していません。ログには Successfully installed が並んでいました——失敗ではなく、減速というかたちで返ってきていたのです。

pip は「入りません」と言わず、入るものを選びます

pip はインストール先の環境から「互換タグ」の一覧を作り、その中で最初に一致した配布物を選びます。ホイールが 1 つも一致しなければ、ソース配布(sdist)を落としてビルドを試みます。

手元の環境でタグの一覧を見ておくと、この選択がどれだけ機械的なものか分かります。

python3 - <<'PY'
from packaging import tags
ts = list(tags.sys_tags())
print("total:", len(ts))
print("manylinux:", sum(1 for t in ts if "manylinux" in t.platform))
print("musllinux:", sum(1 for t in ts if "musllinux" in t.platform))
PY

glibc 2.35 の Linux(Python 3.10.12)で実行すると、私の環境では合計 818 タグ、うち manylinux が 782、musllinux が 0 でした。Alpine のように musl libc を使う環境では、この manylinux 側がまるごと外れます。外れたあとに何が残るかで、結果が三通りに分かれます。

タグそのものにも一言だけ触れておきます。musllinux のタグには musl の版が入っていて、SDK の新しいホイールは x86_64 と aarch64 向けに musllinux_1_1、cryptography は musllinux_1_2 を名乗っています。pip は小さい版を新しい musl の上でも互換と見なすので、_1_1_1_2 の環境に入り、逆は入りません。配る側が古い側のタグを選ぶのは安全側に倒す判断で、使う側は数字をあまり気にせずに済みます。

残ったものpip の振る舞い気づきやすさ
musllinux ホイールそのまま入る問題なし
sdist のみその場でビルドを試みるすぐ気づく(コンパイラが無ければ落ちます)
純 Python の py3-none-any ホイール成功して静かに入る気づきません

三つ目が厄介でした。ビルド失敗は赤い文字で教えてくれますが、純 Python 実装への切り替えは何も言いません。

同じマシンで実装だけを入れ替えて測りました

どれくらい変わるのかを、数字で持っておきたいと思いました。Alpine を用意して比べるのではなく、同じマシンで protobuf の実装だけを環境変数で切り替えて測っています。musl 環境で起きるのは、この切り替えが意図せず起こることだからです。

# pb_bench.py
import time, sys
from google.protobuf.internal import api_implementation
from google.protobuf import struct_pb2
 
impl = api_implementation.Type()
N = int(sys.argv[1]) if len(sys.argv) > 1 else 300
 
s = struct_pb2.Struct()
s.update({f"key_{i}": {"n": i, "s": "x" * 20, "b": i % 2 == 0} for i in range(200)})
blob = s.SerializeToString()
 
t0 = time.perf_counter()
for _ in range(N):
    s.SerializeToString()
t1 = time.perf_counter()
for _ in range(N):
    m = struct_pb2.Struct()
    m.ParseFromString(blob)
t2 = time.perf_counter()
 
print(f"impl={impl} bytes={len(blob)} "
      f"serialize={(t1 - t0) / N * 1e3:.3f}ms parse={(t2 - t1) / N * 1e3:.3f}ms")
python3 pb_bench.py 300
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python python3 pb_bench.py 300

Python 3.10.12・protobuf 7.35.1・glibc 2.35 の x86_64 Linux で、200 キーの Struct(直列化後 13,690 バイト)を 300 回ずつ処理した結果です。

実装直列化 1 回解析 1 回
upb(C 拡張)0.043 ms0.090 ms
python(純 Python)36.201 ms25.691 ms

直列化でおよそ 840 倍、解析でおよそ 285 倍でした。最初に N を 2,000 で走らせたときは、純 Python 側が 60 秒のタイムアウトに引っかかって結果が返ってきませんでした。桁が違うと、測ろうとした手つきのほうが先に壊れます。

なぜこれほど開くのかも、押さえておくと判断が楽になります。C 拡張はフィールドの符号化と復号をコンパイル済みのコードで済ませますが、純 Python の経路は入れ子のメッセージもマップの要素も一つずつインタプリタで辿ります。今回のメッセージは入れ子の構造体を 200 個持っているので、その分の手間が 1 回の呼び出しごとに、組み立てと分解の両方で発生しました。

裏を返せば、気にするかどうかの線引きも見えてきます。1 回の実行で小さなリクエストを数件送るだけなら、まず体感には出ません。数千件を回すジョブでは、1 秒未満だった処理が休憩時間に変わります——しかもプロファイラは依存ではなく自分のコードを指すので、原因が遠く見えるのです。

どちらが動いているかは、一行で確かめられます。

python3 -c "from google.protobuf.internal import api_implementation as a; print(a.Type())"
# upb    → C 拡張。想定どおり
# python → 純 Python。ここが今回の落とし穴です

入ったか入らなかったかではなく、何が入ったかを見ます。 この一行を確かめる癖だけは、急いでいる日でも省かないようにしています。

入れる前に、依存のホイールを棚卸しします

PyPI の JSON API を叩けば、その版がどのホイールを配っているかを数えられます。CI に組み込む前に、手元で一度回しておくと安心できます。

# wheel_audit.py
import json, urllib.request, sys
 
for pkg in sys.argv[1:]:
    d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{pkg}/json", timeout=15))
    files = [f["filename"] for f in d["urls"] if f["filename"].endswith(".whl")]
    musl = [f for f in files if "musllinux" in f]
    pure = [f for f in files if f.endswith("py3-none-any.whl")]
    verdict = "musl wheel" if musl else ("pure python" if pure else "sdist build")
    print(f"{pkg:22} {d['info']['version']:10} whl={len(files):4d} "
          f"musl={len(musl):3d} pure={len(pure)}  -> {verdict}")

2026 年 9 月 3 日に実行した結果から、性格の違うものを抜き出します。

パッケージホイール総数musllinuxmusl 環境での結末
pydantic-core2.48.013627そのまま入ります
grpcio1.83.15015そのまま入ります
cryptography50.0.1456abi3 で 3.9 以降を覆います
aiohttp3.14.311836そのまま入ります
tokenizers0.23.1164(cp310 のみ)3.11 以降は sdist ビルド
protobuf7.36.170純 Python 実装へ
google-genai2.22.010もともと純 Python なので影響なし

見どころは 2 行あります。ひとつは cryptography で、musllinux ホイールは 6 件しかないのに困りません。cp39-abi3 という安定 ABI のホイールが 3.9 以降をまとめて覆うからです。数だけを見て「少ないから危ない」と判断すると読み違えます。

もうひとつは tokenizers です。musllinux ホイールはありますが cp310 に限られていて、Alpine の新しめのイメージが載せている 3.12 や 3.13 では一致しません。この場合は sdist に落ち、Rust のツールチェーンを要求されます。こちらは派手に失敗するので、むしろ手当てしやすい側です。

そして protobuf は、musllinux ホイールが 0 件でありながら py3-none-any を配っています——だから失敗しません。gRPC まわりの依存として引き込まれやすい位置にいるぶん、静かに紛れ込みます。

もう一段確実なのは、数えるのをやめて pip 自身に選ばせ、結果のファイル名だけを受け取る方法です。--dry-run は実際には入れず、--report が選定結果を JSON で書き出します。

pip install --dry-run --ignore-installed --report /tmp/rep.json \
    google-genai protobuf grpcio
python3 - <<'PY'
import json
d = json.load(open("/tmp/rep.json"))
for it in d["install"]:
    name = it["metadata"]["name"]
    fn = it.get("download_info", {}).get("url", "").rsplit("/", 1)[-1]
    print(f"{name:22} {fn}")
PY

glibc の側で走らせると、protobuf-7.36.1-cp310-abi3-manylinux2014_x86_64.whl のように C 拡張入りのファイル名が並びます。同じコマンドを Alpine のイメージの中で走らせて、protobuf-7.36.1-py3-none-any.whl に変わっている行を探す——それが今回いちばん短い確認手順でした。依存が 28 件あっても、比べるのはファイル名だけです。

起動時に気づける形にしておきます

--only-binary :all: を付ければ sdist ビルドは禁じられますが、純 Python のホイールは正当な配布物なので素通りします。オプションでは防げないところは、自分で見るしかありません。

私はジョブの入口に、次の 10 行ほどを置くようにしました。CI では落とし、本番では警告だけにしています。

# runtime_guard.py
import os, sys, logging
 
def check_protobuf_impl(strict: bool = False) -> str:
    from google.protobuf.internal import api_implementation
    impl = api_implementation.Type()
    if impl != "upb":
        msg = f"protobuf implementation is '{impl}' (expected 'upb')"
        if strict:
            raise RuntimeError(msg)
        logging.warning("%s — 直列化が数百倍遅くなる可能性があります", msg)
    return impl
 
if __name__ == "__main__":
    print(check_protobuf_impl(strict=os.environ.get("CI") == "true"))
    sys.exit(0)

strict=True は CI だけに効かせています。本番のジョブを実装の違いで止めてしまうと、遅くなるどころか動かなくなるからです。速いことより、動き続けることを優先したい場面が確かにあります——両方を残したまま、環境で使い分けるのがちょうどよいと感じています。

イメージをどうしても軽くしたい場合と、素直に glibc へ戻す場合の線引きも、私の中では単純になりました。musllinux ホイールが揃っている依存だけで組めるなら Alpine のままにします。揃っていない依存が 1 つでも中心にいるなら、python:3.12-slim へ移して、増えた数十 MB を受け入れます。

まず 1 行だけ、ログに出してみてください

コンテナのビルドログをさかのぼるより、動いているジョブに api_implementation.Type() を出力させるほうが早く済みます。出てきた文字列が upb なら、この記事のことは忘れていただいて大丈夫です。python と出たなら、そこから先は測る価値があります。

私も、伸びた処理時間の理由をネットワークのせいにしていた側でした。読んでくださってありがとうございました。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

アプリ開発2026-07-10
エージェントの書いたORMコードでp95が5倍に — クエリ回数をテストで測り、CIで止める
エージェントの書いた ORM コードで一覧 API の p95 が約5倍に跳ねた経験から、クエリ回数をテストで測れる数値にして CI で止める仕組みを作りました。固定閾値が効かない理由と、データ量の傾きで判定するアサーションの設計を解説します。
アプリ開発2026-06-24
IDE を開かずに、配信前の検証を機械へ預ける — Android CLI を無人パイプラインの検証ゲートに据える設計
Android CLI v1.0 を、IDE を前提にしない無人パイプラインの検証ゲートとして組み込む設計です。三層の検査・終了コードの契約・密度と言語のマトリクスまで、個人開発の身の丈で運用するための考え方をまとめました。
アプリ開発2026-07-15
エージェントが足した依存を、インストール前に検疫する — postinstall を既定で止めて通す設計
npm install の postinstall を既定で止め、エージェントが足した依存をインストール前に検疫する設計です。スクリプトを持つ依存の棚卸し、新規依存への検疫スコア付け、無人ランへの差し込みを実装し、導入後1ヶ月の運用も振り返ります。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →