
1. 先分清“本地”和“离线”五种常见的离线执行场景有一次我把一个做网页自动化的 Python 脚本拷到内网机器上双击运行然后盯着控制台等了半分钟最后看到一行ModuleNotFoundError。旁边同事笑着说“你不是说离线也能跑吗”其实那句玩笑话戳中了很多人的误区所谓离线执行自动化脚本不是你让脚本离线而是你提前把运行它所需要的所有东西都准备齐。自动化脚本要能在本地直接执行尤其在内网隔离或断网环境下核心工作从来不是写那几行业务代码而是把解释器、依赖库、浏览器驱动、公共数据和入口脚本一起打包成一个“断网也能启动”的整体。很多场景其实不是真的“完全没网”而是限制程度不同。我把平时遇到的离线执行场景分成五类每类的应对方式完全不一样。1.1 五种离线场景对应五种准备策略完全断网的单机这台机器不能访问任何外网也没有内网镜像源。所有东西必须提前放到本机pip、npm、apt 全部要走本地文件。内网隔离但允许内网镜像通常企业内部最少会提供一套软件源这时候可以把离线包传到内网服务器上让所有机器统一从内网源安装。只能访问白名单域名比如业务环境只允许访问公司官网或特定 API其他域名全部拒绝。脚本里的在线资源、CDN、公共驱动的下载地址都要改成白名单内的渠道。弱网环境网络通但极不稳定超时严重。这种环境下脚本不能默认在线资源一定可用必须加缓存、超时、重试否则跑着跑着就挂。飞行模式下的本地开发笔记本离线办公也需要跑自动化脚本但所有依赖都在本地缓存里。在开始做离线方案之前先花十分钟确认三个问题脚本运行最依赖的外部资源是什么是第三方库、浏览器驱动、数据库、模型权重还是某个外部接口这些资源多大能否提前拷贝到目标机器目标机器有没有内网仓库或者镜像源搞清楚这三个问题再决定是把依赖打进脚本目录还是做一套离线包或者直接 Docker 镜像搬运。1.2 “直接执行”不是有脚本就行而是五个指标同时满足很多人觉得“在本地直接执行”就是拿到一台机器敲一条命令就能跑。但实际落地要看五个检查项检查项常见问题解释器系统里有没有python3/node版本是否匹配依赖包import xxx是否能成功第三方库是否齐全工作目录脚本里的相对路径是否总能指向预期位置数据文件配置文件、驱动、模型文件、CSV 数据是否在权限有没有写日志、创建临时目录、读取外设的权限这五个检查项随便哪个出问题都会让脚本“看起来不能离线跑”。我之前帮同事排查过一个脚本代码本身完全没问题但脚本里用了./config.json结果他换了一台机器人坐在/home/admin目录下执行/data/tools/run.py那必然读不到/data/tools/config.json。这类问题后面专门有一章展开这里先记住本地离线执行的第一步不是写代码而是把这五个指标列成一个清单逐项打勾。1.3 最小可复现示例一个断网也能运行的 Python 脚本空谈容易我给出一个最简单可复现的例子。假设有一个数据处理脚本run.py它读取当前目录下的data.csv做一次清洗后输出result.txtimport csv from pathlib import Path base_dir Path(__file__).resolve().parent input_file base_dir / data.csv output_file base_dir / result.txt with open(input_file, r, encodingutf-8) as f: rows list(csv.DictReader(f)) valid_rows [row for row in rows if row.get(score) and int(row[score]) 60] with open(output_file, w, encodingutf-8) as f: for row in valid_rows: f.write(f{row[name]}: {row[score]}\n) print(fdone, {len(valid_rows)} rows)如果它只用标准库那确实不需要装任何第三方包。但我们大部分自动化脚本会用 requests、pandas、selenium 这类库所以离线准备的核心是用另一台联网机器把依赖打包python3 -m venv venv source venv/bin/activate pip install -r requirements.txt pip download -r requirements.txt -d ./offline_packages然后把整个项目目录拷贝到内网机器上重建环境python3 -m venv venv source venv/bin/activate pip install --no-index --find-links./offline_packages -r requirements.txt python run.py这里有两个细节必须说清楚--no-index表示不让 pip 去 PyPI 搜索--find-links指定从本地目录查找包。如果不加--no-indexpip 仍然会尝试连接外网一旦连不上就会来回重试浪费时间。另一个细节是pip download只能解决 Python 包层面的问题如果脚本依赖libnss3.so这类系统动态库或者依赖 Chrome、ChromeDriver那还得另想办法第四、五章会专门讲。2. 入口脚本设计从命令行到自动化脚本里调用别的脚本“直接执行”听起来简单但实际会面对两种完全不同的需求。第一种是你在命令行手敲命令或者双击一个脚本文件第二种是你本身在写一个自动化框架想让框架去执行另一个本地离线脚本比如通过 subprocess 调度 job。这两者看起来差不多但设计思路不同。2.1 命令行执行背后的三个隐藏变量命令行执行一个 Python 脚本很多人只敲python run.py但背后有三个隐藏变量决定你能不能跑通用的是python还是python3某些老系统里python指向 Python 2直接跑就跑飞了。当前工作目录是哪脚本里的相对路径是相对于当前终端所在的目录不是脚本所在目录。PATH里能不能找到解释器如果解释器安装在某个自定义目录PATH没配好命令行会直接提示命令不存在。我在内网机上部署脚本时习惯写一个run.sh入口把这三个变量全部固化#!/usr/bin/env bash set -euo pipefail cd $(dirname $0) source ./venv/bin/activate python run.py $然后执行chmod x run.sh。之后不管我在什么目录下只要执行/path/to/run.sh脚本一定会先进入它自己所在的目录再激活它自带的虚拟环境最后运行run.py。这样能避免“换一个执行位置就报 ModuleNotFoundError”的经典问题。2.2 在自动化脚本里调用本地脚本subprocess 的正确姿势如果你正在写一个自动化测试框架或任务编排脚本想要直接调用另一个本地离线脚本最常用的不是os.system而是subprocess.run。对比一下import subprocess import sys result subprocess.run( [python, run.py, --env, offline], cwd/data/tools, capture_outputTrue, textTrue, timeout120, ) print(return code:, result.returncode) print(stdout:, result.stdout) print(stderr:, result.stderr)cwd指定子进程的工作目录timeout防止离线脚本卡死capture_output捕获控制台信息。我不建议用shellTrue拼字符串因为一旦脚本路径里包含空格或特殊字符很容易出现想象不到的转义问题。列表形式的参数传递是最安全的。还有一点如果被调脚本使用了自己的虚拟环境那subprocess里要显式指定虚拟环境中的 Python 路径比如/data/tools/venv/bin/python不要用系统默认的python。否则即使入口脚本里激活了 venvsubprocess起的新进程也不一定会继承这个激活状态。2.3 让入口脚本可信日志、错误码、退出码直接执行的脚本最怕“没人盯着”。命令行下你还能看到报错但如果是调度系统自动执行脚本失败后如果没有返回非零退出码调度系统会认为任务成功问题就藏住了。所以在设计本地离线脚本时我有三个习惯所有异常捕获后至少打印到stderr并用sys.exit(1)结束而不是让解释器抛出未捕获异常后退出码还是 0。日志文件按日期命名例如logs/run_20250617_1530.log并记录启动时间、关键参数、结束时间。在脚本结束前把处理结果和最后一条错误写入一个status.txt文件方便第二天直接看文件判断状态。比如在run.py里加一句try: main() except Exception as exc: with open(status.txt, w, encodingutf-8) as f: f.write(fFAILED: {exc}\n) raise else: with open(status.txt, w, encodingutf-8) as f: f.write(SUCCESS\n)这样即便没有任何人在终端旁边自动化脚本被调度系统调用之后也能通过退出码和状态文件双重确认结果。2.4 Windows 下“双击执行”和定时任务的两个坑Windows 环境下很多人喜欢把脚本做成 .bat 文件直接双击运行。我先给出一个相对可靠的模板echo off chcp 65001 nul cd /d %~dp0 call .\venv\Scripts\activate.bat python run.py %* logs\run.log 21这里%~dp0是 bat 文件所在目录cd /d是切换盘符和目录。如果不写这一行双击运行后工作目录很可能是系统解释器的当前位置脚本里的相对路径大概率出错。chcp 65001是把控制台代码页切到 UTF-8否则脚本里输出中文容易乱码。还有个小细节bat 文件本身的编码最好保存为 ANSI 或 GBK如果带 BOM 的 UTF-8 反而会报错这是 Windows 老毛病。Windows 定时任务的坑更多。你在任务计划程序里配置python run.py但那台机器的“当前目录”并不是脚本所在目录所以脚本里如果是相对路径定时执行基本都会失败。正确做法是任务计划里直接指向run.bat让 bat 负责切目录和环境。这是最省心的方案。3. 离线依赖搬运实战pip、npm、Maven 三套方案离线执行最常见的失败点其实不是代码逻辑而是依赖管理。不同语言生态有不同的搬运方式这里针对 Python、Node、Java 三套主流方案做个实战总结。3.1 Python 项目pip download 制作离线依赖包前面已经给了最基本的pip download命令。实际项目中还需要处理一个细节某些包在 PyPI 上有多个版本而pip download默认下载当前平台匹配的 wheel。如果内网机器是 Linux x86_64联网下载机器也是 Linux x86_64那没问题但如果你的本机是 macOS目标机是 centos 7那就必须用--platform参数指定目标平台否则拷过去的 wheel 根本装不上。pip download -r requirements.txt \ --platform manylinux2014_x86_64 \ --python-version 3.8 \ --implementation cp \ --abi cp38 \ --only-binary:all: \ -d ./offline_packages这条命令下载的包会尽量带上平台标签避免把 mac 版包带到 Linux。不过它对一些只有源码包的库不友好需要灵活处理。我的建议是先在自己和目标机相同操作系统的联网环境把依赖打包这是最省事的方案。如果实在做不到再用平台参数区分。在离线机器安装时还可以加上--no-cache-dir减少磁盘缓存占用。如果你有多个内网机器完全可以在一台机器上搭一个本地 PyPI 服务用pip install --index-url http://内网地址/simple指向它。但这是比较重量的做法脚本数量少的时候没必要。3.2 Node 项目npm pack 与直接拷贝 node_modulesNode 自动化脚本遇到离线环境时常见的做法有两种。第一种是在联网机器执行npm pack然后会把项目打包成一个.tgz文件拷贝到内网后执行npm install ./your-package-1.0.0.tgz这种方式适合你要分发的是一个 npm 包或者在多台机器间同步。第二种是直接拷贝整个node_modules文件夹这在小型项目里最粗暴也最有效。但要注意如果项目里使用了原生模块比如node-sass、sharp它们依赖的系统库和 Node ABI 版本必须和目标机一致否则直接拷贝过来后启动时会出现NODE_MODULE_VERSION不匹配的报错。无论哪种方式一定记得锁版本。项目里保留package-lock.json离线安装时才能保证依赖树和联网环境完全一致。如果你只是简单地在 package.json 里写了express: ^4.18.0那换一台机器安装时可能会拉到不一样的小版本最终导致行为不一致。离线执行环境最怕这种隐蔽的版本漂移。3.3 Maven 本地仓库有包但项目就是引不进来这个现象在 Java 项目里特别常见也就是热词里提到的“maven本地有包但是引不进来”。明明~/.m2/repository下面文件都在离线执行mvn package却报Failure to find。我踩过几次之后总结出四个最可能的原因版本不一致本地仓库里的 jar 版本和 pom.xml 里声明的不完全一样哪怕只是1.0.0和1.0.0-RELEASE的差异Maven 也认为不存在。_remote.repositories文件作祟Maven 在下载依赖时会记录来源仓库 ID离线时如果它记录的远程仓库不可达就会拒绝使用本地文件。解决方法是找到对应目录下的.lastUpdated文件和_remote.repositories文件删掉后执行mvn install -o。settings.xml 配置了错误的 mirror 或 profile本地仓库被指向了一个内网不存在的镜像仓库导致 Maven 每个依赖都要去镜像找不到白白超时。IDE 与命令行用了不同的 Maven 配置IDEA 里配的 Maven home 和 settings.xml 与终端里的不一样导致你在命令行看着没问题在 IDE 里执行却拉不到包。排查时先执行两句话mvn dependency:tree -Dverbose mvn dependency:get -DartifactgroupId:artifactId:version -o如果第二条命令在离线模式下能拉到说明依赖本身存在如果拉不到就优先检查版本号和_remote.repositories。我曾经遇到一个模块本地明明有spring-boot-starter-web-2.7.18.jar但项目一直报找不到最后发现该目录下有个_remote.repositories文件里写着central而离线时central仓库无法访问Maven 就认为这个 jar 不能直接用。删除之后问题彻底解决。3.4 离线依赖包的通用管理铁律针对不同语言有三条通用的东西锁定版本Python 用requirements.txt最好带哈希Node 用 lock 文件Java 用 pom 里的精确版本。保留来源离线包里放一个README记录下载日期、来源仓库、下载机器架构方便半年后有人问“这个包哪来的”时能回答。定期更新离线环境不等于永远不变除非业务要求否则建议每季度做一次依赖更新并形成新的离线包。4. 网页自动化脚本离线执行驱动和浏览器的准备网页自动化脚本是离线执行里最容易踩坑的场景。你写好了 Selenium 脚本本地能跑拿到内网服务器上就报SessionNotCreatedException或者干脆报chromedriver不存在。这里的核心问题是Selenium 脚本运行需要的不是只有 Python 包还包括一套完整的浏览器 驱动环境。4.1 为什么离线环境下要单独准备浏览器和驱动Selenium 的库本身是 Python 包可以用 pip 离线安装。但webdriver.Chrome()启动时底层它需要两个东西一个可执行的 Chrome 浏览器和一个与浏览器版本严格匹配的 ChromeDriver 驱动。正常情况下webdriver-manager库会联网下载驱动但在离线环境里这个下载必然失败。所以你必须提前把对应版本的浏览器和驱动拷贝到目标机器并且在脚本里显式指定路径。浏览器和驱动的版本匹配是首要问题。Chrome 109、Chrome 110、Chrome 120不同版本对应的 ChromeDriver 的主版本号必须一致小版本不一定要完全相同但最好接近。如果你使用 Chrome for Testing它对应驱动版本完全锁定下载时直接命名成一样的版本号比较省心。4.2 配置路径而不是依赖 PATH 自动查找在脚本里我建议显式设置路径不要依赖系统 PATH。示例from selenium import webdriver from selenium.webdriver.chrome.service import Service options webdriver.ChromeOptions() options.binary_location ./tools/chrome/chrome options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--disable-gpu) service Service(executable_path./tools/driver/chromedriver) driver webdriver.Chrome(serviceservice, optionsoptions)binary_location指定 Chrome 可执行文件位置executable_path通过Service指定驱动位置。如果你用的是比较老的 Selenium 3 写法executable_path是直接传给webdriver.Chrome()的Selenium 4.10 之后推荐用Service。关键点是路径最好写成相对脚本目录而不是写死/home/user/...这样整个项目目录拷到任何机器都能用。配合前面说的run.sh里先切换目录相对路径就安全了。4.3 Linux 服务器上的系统库最容易被忽略的一环在 Ubuntu 或 CentOS 服务器上跑 Selenium即使浏览器和驱动都对了启动时也可能报error while loading shared libraries: libnss3.so。因为浏览器在显示页面时需要一堆图形和字体相关的动态链接库。完整列表很长常见的有libnss3.so libatk-bridge-2.0.so.0 libgbm.so.1 libasound.so.2 libxkbcommon.so.0在有网的 Ubuntu 机器上可以执行sudo apt-get install -y libnss3 libatk-bridge2.0-0 libgbm1 libasound2 libxkbcommon0在完全离线的机器上就需要提前下载这些 deb 包并拷贝过去安装。判断缺失哪个库的方法很简单在目标机上执行ldd ./tools/driver/chromedriver | grep not found以及ldd ./tools/chrome/chrome | grep not found每一行输出都是一个缺失的动态库按图索骥一个个补全。这个步骤比较枯燥但它是离线跑通网页自动化的必经之路。4.4 离线环境下为脚本加“稳定保险”离线环境通常没有外部干预手段所以脚本本身要设计得更谨慎。我建议至少加三点显式等待设短一些。网络不在线时页面元素可能永远等不到默认 10 秒超时看起来很合理但 30 个元素等下来一次失败就要卡好几分钟。失败重试有上限。让浏览器操作失败时最多重试 3 次每次间隔 2 秒避免死循环。关键步骤记录日志。每次点击、截图、输入都记录到结构化日志中离线跑挂后回看日志能快速定位是哪一步。5. 踩坑实录五起离线脚本事故的完整排查链路这一节我挑五个自己真实遇到、也经常在别人项目里见到的“离线脚本事故”不直接给结论而是带着排查思路走一遍。这个流程比答案本身更重要。5.1 工作目录与相对路径换机必挂的头号原因第一个事故很典型。脚本在开发机上跑得好好的拷到内网机器后直接报FileNotFoundError: [Errno 2] No such file or directory: config.json。第一反应是配置文件没拷过去但检查后发现文件确实在项目根目录。排查链路在脚本开头增加print(os.getcwd())看当前目录到底是什么。发现打印出的是执行命令时所在的目录而不是项目目录。确认根因脚本用了open(config.json)这个相对路径是相对于当前工作目录的而不是脚本文件所在目录。修复把路径改成基于脚本文件定位from pathlib import Path BASE_DIR Path(__file__).resolve().parent config_path BASE_DIR / config.json这样无论从哪个目录执行路径都不会错。这个坑在所有语言里都存在Node 里是__dirnameJava 里要看user.dirJava 的File(xxx)默认也是基于进程工作目录。5.2 系统编码与换行符Windows 与 Linux 之间的隐形冲突第二个事故发生在跨平台搬运脚本时。我在 Windows 上写了个脚本输出 CSV 文件拿到 Linux 服务器上执行后文件里所有中文字符变成乱码每行末尾还带\r。排查链路怀疑是文件内容本身乱码因为记事本打开 CSV 显示乱码。用file output.csv查看发现编码是ISO-8859-1或application/octet-stream。根因Python 的open()如果没有指定encoding在 Windows 上默认用 GBK 写入在 Linux 上默认用 UTF-8 写入。而 CSV 里的数据是 UTF-8 编码的字符串被 Windows 默认 GBK 写出去自然乱码。修复所有文件读写显式指定encodingutf-8并且写入时设置newline避免多余换行with open(output.csv, w, encodingutf-8, newline) as f: writer csv.writer(f) writer.writerows(data)Windows 与 Linux 之间如果还要传 bat 脚本也建议在入口处chcp 65001 nul并且让 bat 文件使用 CRLF 行尾否则可能被系统识别失败。5.3 动态库缺失本机能跑服务器上缺全家桶第三个事故是 Selenium 跑在纯净版 CentOS 服务器上启动 Chrome 时报error while loading shared libraries: libX11.so.6。因为开发机上开发工具链完整图形库都有但标准服务器镜像为了精简很多 X11 库都没装。排查链路先看完整报错确认是缺共享库不是驱动问题。执行ldd /path/to/chrome | grep not found列出所有缺失库。逐个查询这些库属于哪个 RPM 包。在有网环境用yum provides */libX11.so.6或apt-file search找。下载对应的 rpm/deb 包拷到内网安装。这类问题还有一个加速技巧与其一个个补库不如直接在有网络且系统和目标机一致的环境里先装好所有依赖再用docker export或dpkg -l导出包清单批量打包。5.4 环境变量污染全局 PYTHONPATH 干扰 venv第四个事故是脚本通过run.sh执行明明已经激活了 venv但import requests时报的包路径来自系统全局 Python导致版本不匹配。排查时我先打印了sys.path发现里面有/usr/lib/python3/dist-packages而这个路径不是 venv 里的。根因用户主目录里的.bashrc或系统环境变量设置了PYTHONPATH当脚本以 shell 方式启动时PYTHONPATH被传递给了 Python 进程覆盖了虚拟环境默认的sys.path顺序。修复在入口脚本开头强制清除这个变量unset PYTHONPATH或者直接在 Python 内启动时强制使用 venv 解释器避免依赖 shell 环境。这件事也是为什么我更推荐用subprocess.run调用显式路径的 Python而不是依赖 shell 激活。5.5 一个完整 Selenium 离线故障排查案例第五个事故是内网服务器上跑 Selenium一直报session not created: This version of ChromeDriver only supports Chrome version 114但系统里安装的 Chrome 版本是 120。光看报错就感觉要重新下载驱动。排查链路查看 Chrome 版本google-chrome --version确认为 120.x。查看 chromedriver 版本chromedriver --version确认为 114.x。确认版本不对从有网机器下载匹配 Chrome 120 的 ChromeDriver拷贝过来。重新执行又报缺libatk相关动态库。用ldd查到缺失的库下载对应 deb 包安装。再执行成功启动浏览器。这类问题一定要按顺序排查不要一看到驱动不匹配就只改驱动改完之后还可能被系统库卡住。流程走完整下来才能真正把“离线可执行”变成“离线可复现”。6. 再进一步把整套离线执行环境打包成可交付物前面讲的都是怎么在目标机器上“拼积木”。如果这种环境要多台机器部署或者要交付给其他团队逐台安装依赖就不是好方案。这时候应该把整套执行环境打包成一个可交付物拷过去直接跑。6.1 用 Docker 离线镜像解决“环境不一致”Docker 是目前最推荐的离线交付方式。在联网机器上构建一个包含 Python、Node、Chrome、ChromeDriver、所有依赖的镜像然后导出docker save -o offline_runner.tar offline_runner:latest把 tar 文件拷到内网机器后导入docker load -i offline_runner.tar运行docker run --rm --network none -v /data/project:/app offline_runner python /app/run.py这里关键的参数是--network none它强制容器创建的网络命名空间没有网络接口真正做到“离线运行”。这样也能避免脚本在离线环境里某些地方尝试联网导致超时。-v是把宿主机上的项目目录挂载进去这样镜像只管环境数据文件跟脚本项目本身保持独立更新脚本时不需要重新构建镜像。需要注意docker save和docker export不是一回事。docker save保存的是镜像完整的分层结构能够被docker load恢复并继续运行docker export导出的是容器文件系统快照导入后是一个裸文件系统不具备镜像层的版本信息。做离线交付时用docker save是正确选择。6.2 内网下统一入口脚本的设计即使有了 Docker日常跑脚本时也不可能每次手敲一长串docker run。我会在项目根目录放一个run.sh把所有命令封装起来#!/usr/bin/env bash set -euo pipefail cd $(dirname $0) IMAGE_NAMEoffline_runner:latest PROJECT_DIR$(pwd) docker run --rm --network none \ -v ${PROJECT_DIR}:/app \ -w /app \ ${IMAGE_NAME} \ python /app/run.py $这样内网机器上只需要提前安装好 Docker 并加载镜像之后执行./run.sh就能跑。任何同事拿到项目目录不需要在宿主机上装任何语言解释器也不需要处理 venv这比逐台机器装环境可靠得多。如果目标机器连 Docker 都没装那就要用离线方式安装 Docker。大体思路是在有网机器下载 Docker 的 rpm 或 deb 包外加所有依赖拷到内网后本地安装。这也是比较成熟的做法但要注意 Docker 和内核版本、systemd 的兼容性不能只看网上的通用教程。6.3 无人值守时的自愈与现场保留离线环境通常意味着没人盯着终端所以脚本失败后必须能“留下话”。我习惯在统一入口脚本里加入现场保留逻辑日志目录按日期归档文件名带时间戳比如logs/20250617_1530.log。失败时把当前目录文件列表、最近 50 行日志、环境变量快照写入debug.txt。如果脚本具有可重试性比如从断点继续失败后最多自动重试两次。具体可以在 Python 中增加一个简单的重试装饰器或者在run.sh里写一个带循环的调用逻辑。我的实际经验是宁可让脚本多写一个 debug 文件也不要让它在离线环境里悄悄失败。因为在线环境你可以上服务器看实时日志离线环境一旦部署到用户现场你只能依赖这些落盘信息。整个离线执行项目做到这一步基本就达到“拷贝即用”的状态了。这套方案我在公司的内网自动化测试、本地数据处理、模型推理脚本上都验证过稳定性远高于“临时把文件拷过去试试”的做法。如果你现在正被内网机器的各种依赖问题折磨建议直接从一个小脚本开始先把入口脚本、离线依赖、日志落盘这三件事做扎实再往后扩展。