0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyInstaller製exeがWindows Defenderに隔離される → Nuitkaに移行したら今度はPyMuPDFでビルドが通らなかった話

0
Posted at

社内ツールを「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.c1,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)を達成。


結論・教訓

最後に、今回の試行錯誤から得たものをまとめます。

  1. Defender 誤検知対策として PyInstaller → Nuitka は有効。ネイティブコンパイル + VERSIONINFO 埋め込みで、未署名でも隔離されなくなった(今回のケースでは)。ただし恒久対策はコード署名で、Nuitka はあくまで誤検知率を下げる手段。

  2. 決め手は Nuitka のフラグではなく「依存の選び方」だった--nofollow-import-to--low-memory も外れ。効いたのは PyMuPDF を classic 実装(1.22系)に固定するという一行。重いネイティブ依存(PyMuPDF rebased / onnxruntime / PySide6)は、ビルドツールと相性の良いバージョン・実装を選ぶのが近道。

  3. 「ビルド成功」を信じず、成果物を検証するステップを必ず入れる--nofollow-import-to は通ったが中身が壊れていた。CIは exe を実行しないので、同梱物チェック(できれば起動スモークテスト)が無いと壊れたものを配ってしまう

  4. 可視化と上限設定をケチらない。Nuitka の長い無言フェーズ + gh が実行中ログを返さない、の合わせ技で原因究明が遅れた。timeout-minutes・失敗時のクラッシュレポート artifact 化・(必要なら)進捗ログを最初から入れておく。

  5. Windows CI のコスト感を持つ。windows ランナーは2倍課金。試行錯誤を CI 上で繰り返すと簡単に上限に到達する。ローカル(Docker / Windows実機)で再現・切り分けてから CI を回すと、時間もお金も節約できる。

PyInstaller の Defender 問題を起点に、Nuitka 移行 → PyMuPDF の C1002 沼 → 依存バージョン固定で着地、という流れでした。「未署名exeをDefenderに弾かれない形で配る」を Nuitka で実現したい人、そして Nuitka × PyMuPDF の C1002 で困っている人の役に立てば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?