結論
torch.onnx.export() が吐いた .onnx の中に、自分のホームディレクトリの絶対パスが168行分埋まっていた。
File "/home/n/GitHub/kusoapp/packages/model/src/cloudseg/model.py", line 56, in forward
File "/home/n/GitHub/kusoapp/packages/model/.venv/lib/python3.12/site-packages/torch/nn/modules/conv.py", line 553, in forward
そしてそのファイルは、ブラウザで推論させるために public/ に置いて全世界に配信する予定だった。
抑制するオプションは、 現時点(PyTorch 2.11)存在しない。
何をしていたか
以前、空の雲がうんこ型かどうかをブラウザで判定するアプリを作りました。
このときは無料Colab GPUでTensorFlowのU-Netを回して、tensorflow.js形式で書き出していました。
で、今回。特に不満があったわけではなく、単にPyTorchの触り心地が気になったので移行してみました。ずっとtfおじさんをやっていたので。
- Colab → ローカル
- TensorFlow → PyTorch
- tensorflow.js → ONNX (onnxruntime-web)
- pip → uv
移行自体はすんなり終わりました。Blackwell世代なのでCUDA 12.8のホイールを指定する必要があるくらいです。
[tool.uv.sources]
torch = [{ index = "pytorch-cu128", marker = "sys_platform == 'linux'" }]
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true
600px・batch 8 で17秒/エポック。early stoppingで11エポック、val IoU 0.80で止まりました。
ONNXへの変換も一発です。
program = torch.onnx.export(
module,
(example,),
dynamo=True,
input_names=["image"],
output_names=["mask"],
dynamic_shapes={"x": {0: torch.export.Dim("batch")}},
opset_version=18,
)
program.optimize()
program.save(str(output))
PyTorchとONNX Runtimeの出力差は 8.94e-07。ブラウザでも1秒かからずに推論できました。
やったね。
エディタで開いてみた
できあがった2MBの .onnx を、なんとなくエディタで開いてみました。
バイナリなので当然文字化けしているわけですが、その中にやたら読める部分がありました。
$ strings -n 8 cloud_segmentation.onnx | grep "/home"
File "/home/n/GitHub/kusoapp/packages/model/src/cloudseg/model.py", line 28, in forward
File "/home/n/GitHub/kusoapp/packages/model/src/cloudseg/model.py", line 56, in forward
File "/home/n/GitHub/kusoapp/packages/model/.venv/lib/python3.12/site-packages/torch/nn/modules/batchnorm.py", line 194, in forward
File "/home/n/GitHub/kusoapp/packages/model/.venv/lib/python3.12/site-packages/torch/nn/modules/upsampling.py", line 174, in forward
...
$ strings -n 8 cloud_segmentation.onnx | grep -c "/home/n"
168
168行。
漏れているのは、
- ユーザー名(
/home/nのn) - リポジトリの絶対パスとディレクトリ構成
- 仮想環境の場所とPythonのバージョン
- 自作モジュールのファイル名と行番号
これが public/cloud_segmentation.onnx としてCDNから配信されるところでした。
僕の名前は「n」ではないですが、ここが本名だった人はそのまま出ます。会社のマシンなら /Users/yamada.taro/projects/秘密のプロジェクト名/ みたいなものが出るでしょう。
なぜ入るのか
調べると、dynamoエクスポータは torch/onnx/_internal/exporter/_core.py の _set_node_metadata() で、ノードごとに pkg.torch.onnx.stack_trace を書き込んでいます。if はひとつもなく、分岐なしで必ず入ります。
torch.onnx.export() の引数も全部見ましたが、これを止めるフラグはありませんでした。dynamoエクスポータを使う限り諦めるしかないです。
悪意があるわけではない
念のため書いておくと、これは隠されている情報ではありません。Netronで開けば普通に表示されます。
ONNXの仕様上、doc_string は「人間可読のドキュメント」を置く場所として定義されていて、デバッグ情報を入れること自体は正当です。
そもそもONNXのノードとPyTorchのソースコードは1対1で対応しません。変換結果がおかしいときに「このノードは元コードのどこ由来か」を辿る手段が他にないので、スタックトレースを持たせるのは開発者にとってありがたい機能ではあります。
つまりこれは、
- エクスポータ側:自分がデバッグする中間成果物を作っているつもり
- 僕:そのままCDNに置く最終成果物を作っているつもり
という認識のズレです。
そして厄介なのは、tensorflow.js時代にはこの問題が存在しなかったことです。SavedModel経由の変換ではスタックトレースが落ちるので、同じことをやっても何も漏れませんでした。移行して初めて生えた穴なので、既存のチェックリストには載っていません。
直す
opt-outが無い以上、書き出したあとに自分で消すしかありません。
def strip_source_metadata(onnx_path: Path) -> None:
"""ONNX から出力元のローカル環境情報を取り除く。
dynamo エクスポータは各ノードの doc_string と metadata_props に Python の
スタックトレース(絶対パス入り)を埋め込む。このファイルはブラウザに配信されるため、
ユーザー名やディレクトリ構成が外部に漏れないよう保存後に必ず削除する。
"""
model = onnx.load(onnx_path)
model.ClearField("doc_string")
model.ClearField("metadata_props")
_strip_graph(model.graph)
for function in model.functions:
function.ClearField("doc_string")
function.ClearField("metadata_props")
for node in function.node:
_strip_node(node)
onnx.save(model, onnx_path)
def _strip_graph(graph: onnx.GraphProto) -> None:
"""グラフ本体と、そこに含まれる全ノード・値の由来情報を再帰的に消す。"""
graph.ClearField("doc_string")
for value in (*graph.input, *graph.output, *graph.value_info):
value.ClearField("doc_string")
for initializer in graph.initializer:
initializer.ClearField("doc_string")
for node in graph.node:
_strip_node(node)
def _strip_node(node: onnx.NodeProto) -> None:
node.ClearField("doc_string")
node.ClearField("metadata_props")
# If / Loop などのサブグラフは属性として入れ子になっている。
for attribute in node.attribute:
if attribute.HasField("g"):
_strip_graph(attribute.g)
for subgraph in attribute.graphs:
_strip_graph(subgraph)
ポイントは、これをエクスポート手順の中に埋め込むことです。
program.optimize()
program.save(str(config.output))
strip_source_metadata(config.output) # ← 通らない道を作らない
_assert_parity(module, config.output, example)
「書き出したあとに手で消す」だと絶対に忘れます。忘れた回だけ漏れます。
テストで縛る
さらに、バイト列を直接見るテストを置きました。
def test_export_contains_no_local_paths(onnx_model: Path) -> None:
"""配信されるファイルにローカル環境の痕跡(絶対パス・ホーム配下)が残らないこと。"""
blob = onnx_model.read_bytes()
for marker in (b"/home/", b"site-packages", b"Traceback", b".py\"", b"C:\\\\"):
assert marker not in blob, f"exported ONNX leaks {marker!r}"
def test_export_keeps_graph_intact_after_stripping(onnx_model: Path) -> None:
"""メタデータ除去でグラフ構造まで壊していないこと。"""
model = onnx.load(onnx_model)
onnx.checker.check_model(model)
assert [i.name for i in model.graph.input] == ["image"]
assert [o.name for o in model.graph.output] == ["mask"]
assert len(model.graph.node) > 0
assert all(not node.doc_string for node in model.graph.node)
「特定のフィールドを消したか」ではなく「最終的なバイト列に文字列が残っていないか」を見るのが大事です。
前者だとPyTorchのバージョンが上がってメタデータの置き場所が増えたときに素通りします。後者なら気づけます。
結果
$ strings -n 6 cloud_segmentation.onnx | grep -cE "/home|site-packages|\.py"
0
$ strings -n 6 cloud_segmentation.onnx | head -6
pytorch
2.11.0+cu128:
module.enc1.block.0.weight
module.enc1.block.0.weight_bias
getitem
node_Conv_203"
残ったのは生成元がpytorch 2.11.0+cu128であることと、レイヤー名だけ。ここは秘密でも何でもないので放置します。
ファイルサイズも 2,024,928 → 1,967,844 バイトと57KB減りました。モデルの3%近くがスタックトレースだったということです。
推論結果も変わらず、ブラウザで「うんこ雲発見!」が出ることも確認しました。
教訓
エッジ推論をやると、モデルファイルはビルド成果物ではなく配信物になります。
サーバー側で推論している限り .onnx は外に出ないので、中に何が入っていようが実害はありません。それが public/ に移動した瞬間、JSのバンドルと同じ「公開物」になります。
なのに、
- バイナリなのでdiffに出ない
- 警告も何も出ない
- リンタもCIも見ていない
-
.gitignoreの話でもない
という状態で、レビューをすり抜けます。今回はたまたまエディタで開いたから気づいただけです。
同じ穴は他にもあります。.ptのpickleにはモジュールの完全修飾名が入りますし、Keras/SavedModelにもビルド時のパスが残ることがあります。
モデルを配信するときは、一度 strings に通す。これだけ習慣にしておくと安いと思います。
strings -n 8 model.onnx | grep -E "/home|/Users|C:\\\\|site-packages"
おまけ
ちなみにこの調査、「なんかバイナリ開いたら読める文字があるな」から始まっています。
普段からバイナリをエディタで開く癖は、持っておいて損はないです。
コーディングエージェントで浮いた時間を、こういった調査の時間に割けるのはいい時代です。