社内ツールを「Python環境なしでダブルクリック起動できる Windows exe」として配布したかっただけなのに、Windows Defender に隔離され、対策で Nuitka に移ったら今度はビルドが通らず、最終的に意外な一行で解決した——という記録です。同じ沼にハマる人向けに、試行錯誤をそのまま残します。
結論(=何が効いたか)は記事の最後にまとめています。先に知りたい方は「結論・教訓」へ。
環境・前提
- 配布したいもの: PySide6 製のデスクトップGUIアプリ(社内の経理用ツール)
- 主な依存: PySide6 / PyMuPDF(fitz) / onnxruntime(RapidOCR)/ opencv など、ネイティブ拡張が重ための構成
- 配布形態: 単一 exe(onefile)。Python環境構築なしで配れること
- ビルド: GitHub Actions の windows-latest ランナーで自動化
- 制約: 社内利用。コード署名はまだ無し(未署名 exe)
背景:なぜ Defender で詰まったか
最初は PyInstaller で onefile exe を作っていました。動くのですが、配ると Windows Defender / SmartScreen が誤検知して隔離してしまい、利用者の手元で「exe が使えない」状態に。
PyInstaller の onefile は、起動時に bootloader が中身を一時展開する方式です。この挙動がヒューリスティック検知(「自己展開して実行する未署名バイナリ」=怪しい)に引っかかりやすい、というのはよく知られた話です。
本来の正攻法はコード署名ですが、有料かつ手配が要る。署名なしで誤検知を減らす方法を探しました。
対策:PyInstaller → Nuitka へ移行
Nuitka は Python を C にトランスパイルしてネイティブコンパイルするツールです。PyInstaller のような bootloader 同梱方式ではなく、本物のネイティブ実行ファイルになるため、AV のヒューリスティック誤検知が大きく減ると期待できます。
あわせて exe に VERSIONINFO(会社名・製品名・バージョン等のメタ情報)を埋め込むと、未署名でも誤検知率が下がります。Nuitka なら CLI フラグで付けられます。
ビルドコマンドはこんな形です(onefile):
python -m nuitka \
--standalone \
--onefile \
--assume-yes-for-downloads \
--enable-plugin=pyside6 \
--windows-console-mode=disable \
--include-package=desktop \
--include-package=rapidocr_onnxruntime \
--include-package-data=rapidocr_onnxruntime \
--include-package=onnxruntime \
--include-package-data=onnxruntime \
--include-data-dir=templates=templates \
--include-data-dir=build/vendor/tesseract=tesseract \
--company-name=... --product-name=... \
--file-version=0.2.0.0 --product-version=0.2.0.0 \
--onefile-tempdir-spec="{CACHE_DIR}\myapp\{VERSION}" \
--output-filename=myapp-gui.exe \
desktop/main.py
GitHub Actions でリリースを自動化
v*.*.* のタグ push をトリガに、windows-latest ランナーで Nuitka ビルド → 生成 exe を GitHub Releases に添付、という構成にしました。動作確認用に workflow_dispatch(手動起動)でも回せて、その場合は Release を作らない、としておくと便利です。
on:
push:
tags: ["v*.*.*"]
workflow_dispatch:
jobs:
build-windows:
runs-on: windows-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -e . && pip install "nuitka==2.*"
- name: Build with Nuitka (onefile)
run: python -m nuitka --onefile ... desktop/main.py
# ... Release 添付ステップ
ここまでは順調でした。地獄はリリースを切ってから始まります。
ここからが本番:デプロイの試行錯誤
失敗0:タグをバージョンbump前のコミットに付けてしまう
pyproject.toml の version を 0.2.0 に上げる前のコミットに、うっかり v0.2.0 タグが付いた状態でビルドが走り出しました。これだと exe の VERSIONINFO が古いまま。進行中のビルドをキャンセルし、タグを正しいコミットへ付け直し。
gh run cancel <run-id>
git push origin :refs/tags/v0.2.0 # リモートのタグ削除
git tag -d v0.2.0
git tag -a v0.2.0 -m "release 0.2.0" <正しいコミット>
git push origin v0.2.0
タグはまだ Release を生んでいなければ付け直して問題なし。ここはまだ序の口でした。
失敗1:59分かけてビルドが死ぬ
ビルドが無言のまま進まない。「Build with Nuitka」ステップで止まって見える。Nuitka は C コンパイル/リンク/onefile圧縮の間ほとんど標準出力を出さないので、外からは進捗が見えません(gh は実行中ジョブのログを取得できないのも厄介)。
20分、40分……結局 約59分回って失敗。落ちたログがこれ:
dist\main.build\module.pymupdf.mupdf.c(1822950) : fatal error C1002: compiler is out of heap space in pass 2
scons: *** [module.pymupdf.mupdf.obj] Error 1
FATAL: Failed unexpectedly in Scons C backend compilation.
module.pymupdf.mupdf.c の 1,822,950 行目。つまり PyMuPDF の mupdf モジュールから約182万行の巨大なCが生成され、それを **MSVC が最適化中(pass 2)にヒープ枯渇(C1002)**で死んでいました。
PyMuPDF の mupdf は SWIG 生成の超大型ラッパで、Nuitka × PyMuPDF はこのC1002が既知の難所(Nuitka 側でも未解決でマイルストーン送り)です。
試行A:巨大モジュールをコンパイルさせない(--nofollow-import-to)
「その1モジュールだけCコンパイルを避ければいい」と考え、
--nofollow-import-to=pymupdf.mupdf
を追加。結果、C1002は消えてビルドは成功。速い。……が、ここで自前で入れておいた検証ステップが役に立ちました。
- name: Verify PyMuPDF was bundled
shell: pwsh
run: |
$dist = "dist\main.dist"
$pyd = Get-ChildItem -Recurse $dist -Filter "_mupdf*.pyd"
if (-not $pyd) {
Write-Error "PyMuPDF C extension not bundled — 起動時に import fitz が失敗する"
exit 1
}
これが赤になりました。--nofollow-import-to で除外したら、Nuitka はその中の import _mupdf(実体のC拡張)もラッパ本体も辿らず=同梱しない。つまり「ビルドは成功するが、起動した瞬間に import fitz で落ちる壊れたexe」だったわけです。
教訓:「ビルド成功」を信用しない。成果物に必要物が入っているかを必ず検証する。
CIは exe を作るだけで実行しないので、この検証ステップが無ければ壊れたexeを配るところでした。
試行B:--low-memory(Nuitka公式のC1002対策)
C1002 は「ヒープ枯渇」。並列コンパイルでRAMを食い合っているせいかと考え、Nuitka 公式が C1002 に薦める --low-memory(--jobs=1 等で直列化し、その1ファイルに全RAMを与える)を試す。
結果:約85分かけて、まったく同じ C1002 で死亡。
module.pymupdf.mupdf.c(1822950) : fatal error C1002: compiler is out of heap space in pass 2
直列化しても直らない=**並列競合ではなく、「182万行の単一ファイルを最大最適化(/Ox)するコンパイラ自体の限界」**だと確定しました。
真因の特定:PyMuPDF の "rebased" 実装
ここで「そもそもこの巨大ファイルは何者か」を掘りました。PyMuPDF には2つの実装があります(公式の移行説明):
-
classic 実装:従来からのもの。
import fitzで小さな_fitz.pyd(MuPDFを静的リンクした単一拡張)を使う。巨大SWIGファイルは無い。 -
rebased 実装:新しいSWIGベース。
fitz/mupdf.py(約5万行)を持ち、これがNuitkaで約182万行のCになる ← 今回の元凶。
そして import fitz がどちらになるかはバージョンで変わる:
| PyMuPDF |
import fitz の実装 |
巨大ファイル |
|---|---|---|
| 1.22 系 | classic のみ | 無し |
| 1.23 系 | 途中から rebased 既定 | 有り |
| 1.24 系 | rebased 既定 | 有り |
実際に手元で確認すると、1.23.26 でも import fitz は rebased(hasattr(fitz, "mupdf") == True、_fitz.pyd 無し)。1.23 後期はもう rebased 既定でした。
解決:classic 実装のみの 1.22 系に固定
巨大ファイルそのものが存在しない classic 実装に寄せれば、Nuitka は普通にビルドできるはず。pyproject.toml / Dockerfile を:
- "pymupdf==1.24.*",
+ "pymupdf==1.22.*",
ローカル(Docker)で実測:
version: 1.22.5
rebased(mupdf属性)?: False
_fitz拡張: ['_fitz.cpython-311-...so']
巨大mupdf.py?: False
get_pixmap OK: 200 x 100 # PDF→画像レンダリングも正常
→ 既存の回帰テスト 19件 すべて green
PDF→画像レンダリング(fitz.open / get_pixmap)は 1.22〜1.24 で API 互換なので、用途的に挙動差はなし。CI の検証ステップも classic の _fitz*.pyd を見るように直しました。
おまけの罠:GitHub Actions が課金で止まる
修正をpushしていざビルド……今度は3秒で失敗。ログにステップ失敗は無く、注釈に:
The job was not started because recent account payments have failed
or your spending limit needs to be increased.
windows ランナーは課金が2倍。ここまで何本も ~85分の失敗ビルドを回した結果、無料枠/支出上限に到達していました。コードは正しいのにCIが動かない。支払い方法の更新と Actions の spending limit を $0 から引き上げて解消。
教訓:長い失敗ビルドはお金を溶かす。
timeout-minutesを必ず付け、失敗時はクラッシュレポートを artifact にアップロードして「1回の失敗から最大限学ぶ」。
結果:Defender にかからず配布できた
classic 固定 + 課金解消後、ビルドは無事成功。v0.2.0 を GitHub Releases に公開し、約 100MB の単一 exe が添付されました。
そして肝心の点——ダウンロードした exe は Windows Defender に隔離されませんでした。 当初の目的(署名なしで配布できる exe)を達成。
結論・教訓
最後に、今回の試行錯誤から得たものをまとめます。
-
Defender 誤検知対策として PyInstaller → Nuitka は有効。ネイティブコンパイル + VERSIONINFO 埋め込みで、未署名でも隔離されなくなった(今回のケースでは)。ただし恒久対策はコード署名で、Nuitka はあくまで誤検知率を下げる手段。
-
決め手は Nuitka のフラグではなく「依存の選び方」だった。
--nofollow-import-toも--low-memoryも外れ。効いたのは PyMuPDF を classic 実装(1.22系)に固定するという一行。重いネイティブ依存(PyMuPDF rebased / onnxruntime / PySide6)は、ビルドツールと相性の良いバージョン・実装を選ぶのが近道。 -
「ビルド成功」を信じず、成果物を検証するステップを必ず入れる。
--nofollow-import-toは通ったが中身が壊れていた。CIは exe を実行しないので、同梱物チェック(できれば起動スモークテスト)が無いと壊れたものを配ってしまう。 -
可視化と上限設定をケチらない。Nuitka の長い無言フェーズ +
ghが実行中ログを返さない、の合わせ技で原因究明が遅れた。timeout-minutes・失敗時のクラッシュレポート artifact 化・(必要なら)進捗ログを最初から入れておく。 -
Windows CI のコスト感を持つ。windows ランナーは2倍課金。試行錯誤を CI 上で繰り返すと簡単に上限に到達する。ローカル(Docker / Windows実機)で再現・切り分けてから CI を回すと、時間もお金も節約できる。
PyInstaller の Defender 問題を起点に、Nuitka 移行 → PyMuPDF の C1002 沼 → 依存バージョン固定で着地、という流れでした。「未署名exeをDefenderに弾かれない形で配る」を Nuitka で実現したい人、そして Nuitka × PyMuPDF の C1002 で困っている人の役に立てば幸いです。