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))
PYglibc 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 300Python 3.10.12・protobuf 7.35.1・glibc 2.35 の x86_64 Linux で、200 キーの Struct(直列化後 13,690 バイト)を 300 回ずつ処理した結果です。
| 実装 | 直列化 1 回 | 解析 1 回 |
|---|---|---|
upb(C 拡張) | 0.043 ms | 0.090 ms |
python(純 Python) | 36.201 ms | 25.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 日に実行した結果から、性格の違うものを抜き出します。
| パッケージ | 版 | ホイール総数 | musllinux | musl 環境での結末 |
|---|---|---|---|---|
| pydantic-core | 2.48.0 | 136 | 27 | そのまま入ります |
| grpcio | 1.83.1 | 50 | 15 | そのまま入ります |
| cryptography | 50.0.1 | 45 | 6 | abi3 で 3.9 以降を覆います |
| aiohttp | 3.14.3 | 118 | 36 | そのまま入ります |
| tokenizers | 0.23.1 | 16 | 4(cp310 のみ) | 3.11 以降は sdist ビルド |
| protobuf | 7.36.1 | 7 | 0 | 純 Python 実装へ |
| google-genai | 2.22.0 | 1 | 0 | もともと純 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}")
PYglibc の側で走らせると、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 と出たなら、そこから先は測る価値があります。
私も、伸びた処理時間の理由をネットワークのせいにしていた側でした。読んでくださってありがとうございました。