把一整套运行环境塞进一个安装包


我给自己做了个命令行工具,附带一套 Python 脚本。在我这台机器上跑得好好的:Python 有、依赖装过、浏览器自动化用的 Chromium 也在。

问题是它要装到国内另一台机器上,那台机器 pip 大概率超时,npm 更别想。让用它的人先去配一遍环境,等于这工具没做完。

所以决定:全带上。做一个双击就完事的安装包,装完断网也能用。

素材总量 1 GB。下面是把它变成一个 exe 的过程,以及路上的四个坑。

为什么主程序是三个 exe

先说个不是坑但容易踩的事。我的工具编出来是三个可执行文件,必须放在同一个目录,分开放会报 program not found。

原因是 Windows 的权限是进程级的:主程序用普通权限运行,创建沙箱账户要管理员权限,实际执行被隔离的命令又要降权到受限账户。三种权限没法在一个进程里切换,只能拆成三个二进制,主程序按同级路径去找另外两个。

打包时这决定了一件事:这三个必须作为一个整体安装,不能让用户选装其中一个。

坑一:便携 Python 默认不认第三方包

Python 官方有个 embeddable 发行版,解压即用、不写注册表、不改 PATH,正好适合塞进别的程序里。20 MB 出头。

但它开箱是残废的:site 机制被关掉了,装什么包都 import 不到。解压后目录里有个 python312._pth,长这样:

python312.zip
.

# Uncomment to run site.main() automatically
#import site

要把最后一行的注释去掉,再加一行 Lib\site-packages:

(Get-Content python312._pth) -replace '^#\s*import site','import site' | Set-Content python312._pth
Add-Content python312._pth "Lib\site-packages"

它也不带 pip,得自己引:

Invoke-WebRequest https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
.\python.exe get-pip.py

到这一步,它才是个能用的 Python。

坑二:pip 会告诉你“已经装好了”,然后什么都不做

这个坑最阴。装依赖的时候,输出里有几行:

Requirement already satisfied: pyee<14,>=13 in C:\Users\...\AppData\Roaming\Python\...
Requirement already satisfied: greenlet<4.0.0,>=3.1.1 in C:\Users\...\AppData\Roaming\Python\...

pip 看到宿主机的用户目录里已经有这两个包,就跳过了。 于是安装包里缺 pyee 和 greenlet——在我的机器上一切正常,因为它们从 Roaming 里被找到了;拷到别的机器,直接 import 失败。

而且这个错误在打包机上永远复现不出来。你测多少遍都是好的。

正确的装法要同时给两个约束:

$env:PYTHONNOUSERSITE = "1"
.\python.exe -m pip install --no-user --target "Lib\site-packages" pymupdf playwright

--target 强制装到指定目录,PYTHONNOUSERSITE 让它别去看用户目录。

装完必须验证,而且不能只验证 import 成功——要看模块的真实路径:

.\python.exe -c "import fitz,playwright,pyee,greenlet; [print(m.__file__) for m in (fitz,playwright,pyee,greenlet)]"

四个路径都必须落在你的便携目录下。有一个跑到 AppData\Roaming 里,这个包就是坏的。

坑三:Chromium 有 271 MB 是白带的

浏览器自动化用的 Chromium,默认下到 %LOCALAPPDATA%\ms-playwright。打包时用环境变量把它引到包内:

$env:PLAYWRIGHT_BROWSERS_PATH = "<绝对路径>\runtime\ms-playwright"
.\runtime\python\python.exe -m playwright install chromium

拉下来一看,701 MB。展开:

目录 大小
chromium-* 427 MB
chromium_headless_shell-* 271 MB
ffmpeg-* 3 MB

ffmpeg 是录视频用的,跟我的场景无关,删。

无头壳那 271 MB 才是有意思的部分。 我本来当然想用无头模式——不弹窗口、能做定时任务,正是自动化想要的。但实测下来:

  • 一家网站给无头浏览器返回 Cloudflare 的「请稍候…」检查页
  • 另一家直接返回 HTTP 418(I’m a teapot,这是它们的反爬响应)

同一个会话、同一份 cookie,换成有窗口的浏览器就一切正常。

后来发现还要再进一步:Playwright 自带的 Chromium 也会被识别,得用系统上装的真实 Chrome,并且关掉自动化标记:

pw.chromium.launch_persistent_context(
    profile, channel="chrome", headless=False,
    args=["--disable-blink-features=AutomationControlled"],
    ignore_default_args=["--enable-automation"],
)

这么一来无头壳永远用不上,删得心安理得。701 → 427 MB。

顺带说,“无头不能用”这个结论反过来影响了整个功能的形态:它注定是“弹个窗口自己点”而不是“后台静默跑”,那么定时任务的设计就得跟着变。一个打包时的体积决策,暴露的是产品形态的约束。

坑四:别把便携 Python 加进 PATH

最直觉的做法是把 runtime\python 加到 PATH 里,这样脚本直接 python xxx.py 就能跑。

千万别。 目标机器上大概率有 conda 或者别的 Python,那是别人的环境,一个工具不该去改写它。加进 PATH 就意味着从此那台机器上所有的 python 都变成了你的。

正确做法是给一个显式入口,一个 .cmd 就够:

@echo off
setlocal
set "PLAYWRIGHT_BROWSERS_PATH=%~dp0ms-playwright"
set "PYTHONNOUSERSITE=1"
set "PYTHONIOENCODING=utf-8"
set "PYTHONUTF8=1"
"%~dp0python\python.exe" %*
endlocal

五行,每一行都对应一个具体的坑:

变量 不设会怎样
PLAYWRIGHT_BROWSERS_PATH 去用户目录找浏览器,找不到就联网重下 400 MB——而这套东西正是为了离线
PYTHONNOUSERSITE 宿主机的包混进来,就是坑二的运行时版本
PYTHONIOENCODING / PYTHONUTF8 中文路径下打印会抛 UnicodeEncodeError

最后一条我是被 conda 教育的。工作目录叫 新建文件夹 (8),conda 激活时要打印这个路径,默认编码是 cp1252,编不出中文,它自己的异常处理器跟着炸,吐出一大片红色 ERROR REPORT 加终端响铃。看起来像程序崩溃,实际上只是输出编码——底下的命令照常跑完了。

安装器:Inno Setup

打包工具用 Inno Setup,免费、单文件输出、自带卸载器。

winget install JRSoftware.InnoSetup
& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" installer.iss

注意 winget 装的位置是 %LOCALAPPDATA%\Programs\,不在 Program Files——那是免管理员安装。我第一次按惯例去 Program Files 找,没找到。

我写错的两处

AppId 得是合法 GUID。 我图省事写了 {{7A3C9E12-...-TYCODE0001}}——最后一段必须是十六进制,TYCODE 里的 T、Y、C、O 都不是。

顺带一提,这个值定下来就不要改。它是安装器识别“同一个程序”的依据,改了之后新版本会被当成另一个软件,旧的卸不掉,两个条目并存。

{userappdata} 不是用户目录。 它指向 C:\Users\你\AppData\Roaming,我写了 {userappdata}\..\.xxx 想上一级,结果上到了 AppData,文件会装到 C:\Users\你\AppData\.xxx。要用 {%USERPROFILE}\.xxx。

卸载时要清的,不在安装目录里

这一条是我这个工具特有的,但思路通用:你的程序在系统里留下的东西,删文件夹是删不掉的。

我的工具运行时会创建:

  • 本地用户账户(跑沙箱用的受限账户)
  • 一个本地组
  • 三条防火墙规则

卸载时得显式清理:

[UninstallRun]
Filename: "powershell.exe"; RunOnceId: "RemoveSandboxUsers"; \
    Parameters: "-NoProfile -Command ""Get-LocalUser -Name 'XxxSandbox*' | Remove-LocalUser"""
Filename: "netsh.exe"; RunOnceId: "RemoveFirewallRules"; \
    Parameters: "advfirewall firewall delete rule name=all program=""{app}\xxx.exe"""

值得盘一遍你的程序装完之后在系统里留下了什么:注册表项、计划任务、服务、账户、防火墙规则、证书。只有文件是删文件夹能带走的。

用户数据要留下

~/.xxx 里有配置、凭据、会话历史和用户自己改过的脚本。删掉是不可逆的,重装还要重新配。所以默认保留,只在卸载时弹一次询问,默认按钮是“否”。

程序装在 Program Files,用户数据在用户目录——这条边界也决定了哪些跟着卸载走、哪些留下。

凭据单独成一个组件

我这个包里带了自己的 API key,因为纯自用,装完就能跑。但这意味着这个 exe 不能给别人——谁拿到谁就能用我的额度。

处理方式是把凭据做成一个可选组件,并且给三种安装类型:

完整安装(含我的配置)   ← 给自己的机器
仅程序与技能            ← 给别人,不带凭据
自定义

源文件目录写进 .gitignore,安装包输出目录也写进去。installer.iss 本身不含密钥,可以照常提交。

另外凭据文件用 onlyifdoesntexist 标志——已有配置绝不覆盖。升级版本不会把目标机上已经配好的东西冲掉。

结果

原始素材    1005 MB
安装包       272 MB      压缩率 27%
压缩耗时    5 分 36 秒

用的是 Compression=lzma2/max 加 SolidCompression=yes。后者是“固实压缩”——把所有文件当成一个连续数据流压,跨文件的重复能被利用到。Chromium 和 Python 里有大量相似的二进制片段,这个开关值不少。

代价是压缩过程慢(这次五分半),而且安装时不能只解压其中一个文件——但安装本来就是全量的,无所谓。

装完的结构:

Program Files\XxxCode\
  三个 exe
  runtime\
    xxx-python.cmd      ← 显式入口,不污染 PATH
    python\             Python + 依赖
    ms-playwright\      Chromium(无头壳已删)
    node\               Node LTS
  unins000.exe

用户目录\.xxx\
  skills\               脚本,卸载时保留
  config.toml           只在选了凭据组件时写,且不覆盖

值得带走的几条

  1. 在打包机上验证便携环境是无效的——它总能从宿主机借到缺的东西。要验证依赖的真实路径,不是 import 成功与否。
  2. 体积决策会暴露产品约束。我删掉 271 MB 的无头浏览器,不是因为省空间,是因为发现无头模式根本用不了——而这直接决定了这个功能不可能做成静默的定时任务。
  3. 别动别人的 PATH。多一个 .cmd 入口的代价,远小于把目标机上的 python 改掉。
  4. 卸载要清的东西比你以为的多。文件之外还有账户、规则、注册表;而用户数据反过来不该清。