由于Python是一种解释型语言,只要设备上安装了Python解释器,无需编译即可源码运行。但如果想要把Python发布给别人使用,要求别人安装Python或者第三方库显然不合理。
因此产生了一些Python打包工具,常见的例如pyinstaller,能够将Python解释器与源码一同打包,实现单文件分发。
但这种方法也存在一些问题,尤其是在Linux系统下,pyinstaller不会将Python解释器的动态依赖库一并打包,常见的例如GLIBC。如果你的打包环境和运行环境的GLIBC版本不一致,那么就会报错无法使用。
staticx应运而生,它能够将动态链接库一并打包,避免不同设备上动态库版本不一致问题。
一、pyinstaller
安装
pip install pyinstaller
打包
pyinstaller --onefile test.py
打包完成后即可在dist目录下找到可执行文件test/test.exe。
pyinstaller能够将Python程序及其依赖的库、以及Python解释器打包成为一个可执行文件。对Windows系统而言,到这一步就可以了。但对于Linux系统,还需要将动态链接库进行打包。
二、staticx
安装
pip install staticx
打包
staticx test_bin test_static
staticx打包后,可执行文件即可分发部署到其他Linux系统上,而不再受诸如GLIBC等动态库的困扰。
三、可执行文件路径问题
有时我们希望给代码增加一些配置文件,用来保存一些随时可能被修改的参数,又或者是一些图片、音乐等资源文件,而修改这些文件时我们又不想重新编译打包代码。这时我们会尝试获取当前可执行文件的绝对路径,并以此来计算资源文件的路径,进行读取资源文件的操作。
在Python中可以使用如下代码获取当前.py文件的路径:
import os
execPath = os.path.realpath(__file__)
print(execPath)
~# python /home/test/test.py
/home/test/test.py
当你使用pyinstaller打包后,执行这个可执行文件可能会得到这样的结果:
~# /home/test/test_bin
/tmp/_MEIzDcG8B/test.py
这是因为应用程序在执行时,会将打包的Python程序、解释器等内容解压到/tmp下的临时文件夹下,此时获取到的路径就是临时文件夹的路径,而不是可执行文件实际路径。此时可以使用 sys.executable 来获取路径:
import os
import sys
execPath = sys.executable if getattr(sys, "frozen", False) else os.path.realpath(__file__)
print(execPath)
这时执行pyinstaller打包后的文件可以得到正确的路径
~# /home/test/test_bin
/home/test/test_bin
但是。。。使用staticx打包后,这个方法也会失效。
~# /home/test/test_static
/tmp/staticx-EMdpDD/test
对于这个问题,我百思不得其解,网上的方法都试了一个遍,仍然都无效,诸如
sys.argv[0] ×
sys.path[0] ×
sys._MEIPASS ×
直到我看到了staticx的官方主页介绍
这句话的意思大概就是:这个生成的可执行文件实际上是StaticX引导加载程序,附带一个包含用户可执行文件和库的归档文件。而pyinstaller打包后的可执行文件是被staticx bootloader在/tmp文件夹下引导加载的,怪不得python中通过各种方法获取的路径都是/tmp临时文件夹。
也就是说我们想要获取的其实是staticx bootloader可执行文件的绝对路径,这在python里面并没有提供获取方法,但官方已经给出了答案
staticx提供了两个环境变量,分别用来获取临时文件夹路径和可执行文件绝对路径,我们想要的就是第二个环境变量,将上述代码稍作修改:
import os
import sys
execPath = (os.environ.get("STATICX_PROG_PATH") if os.environ.get("STATICX_PROG_PATH") else sys.executable) if getattr(sys, "frozen", False) else os.path.realpath(__file__)
print(execPath)
大功告成
~# /home/test/test_static
/home/test/test_static
四、动态库路径问题
当在python中使用 subprocess.Popen 方法去执行shell指令时,如果shell指令不是内置命令,例如包含第三方工具等,在使用pyinstaller打包后,有可能会出现工具调用失败。看下面这个例子:
import subprocess
def run_cmd(cmd):
p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
while p.poll() is None:
pass
out, err = p.communicate()
print(out, err, p.returncode)
return out, p.returncode
run_cmd("ipmitool mc info")
直接运行该脚本,没有任何问题,能够成功调用ipmitool工具。但当使用pyinstaller打包后,将可执行文件分发到另一台机器上运行时,却出现了报错:
ipmitool: symbol lookup error: ipmitool: undefined symbol: MD2_Init, version OPENSSL_1_1_0
可以大概看出,这个报错是ipmitool工具调用动态库时出现的错误,但在该机器上直接执行ipmitool mc info指令没有任何问题。为了找到原因,又找到另外一台设备进行测试,结果可执行文件调用ipmitool工具又成功了🤔
首先查看这两台机器和打包环境的openssl的版本
OpenSSL 1.1.1f 31 Mar 2020 --> 打包环境
OpenSSL 1.1.1k 25 Mar 2021 --> 执行失败的机器A
OpenSSL 1.0.2k 26 Jan 2017 --> 执行成功的机器B
可以看到执行失败的机器A的openssl的版本高于打包环境,在机器A上的ipmitool工具依赖的也是高版本的openssl,如果在运行时调用了打包环境对应的低版本动态库文件,那么就可能会出现错误。openssl通常是向后兼容的,这应该也是能够在低版本环境B成功运行的原因。
报错原因大概找到了,那么python打包之后调用系统下的工具为何会使用打包环境的动态库呢?
首先在 Linux 系统中,动态链接库(共享对象,.so 文件)的默认搜索路径按照先后顺序包括以下几个位置:
- 编译时指定的路径:编译时通过
-rpath选项指定的路径。 - 运行时指定的路径:通过
LD_LIBRARY_PATH环境变量指定的路径。 - 默认系统路径:通常包括
/lib和/usr/lib等。 /etc/ld.so.conf文件中指定的路径:这个文件包含了额外的库路径,通常在安装新的库时会更新这个文件。/etc/ld.so.conf.d/目录中的配置文件:这个目录中的文件也可以指定额外的库路径。
pyinstaller打包时,会分析所有模块的依赖项,这其中就包括动态链接库,并将其打包到一个文件中。当运行该文件时,可执行文件会自动解压所有打包的资源文件到一个临时目录中。
为了使python文件分发到其他环境后能够使用正确的动态链接库,而不是在默认系统路径下查找动态链接库,pyinstaller修改了LD_LIBRARY_PATH 环境变量,使其指向解压的临时目录。
print(f"LD_LIBRARY_PATH: {os.environ.get('LD_LIBRARY_PATH', '')}")
# 打包后运行结果为 LD_LIBRARY_PATH: /tmp/_MEIC2WhMk
在调用ipmitool工具时,也会按照先后顺序先去LD_LIBRARY_PATH 环境变量对应的路径下查找动态库文件,这时就会使用打包进去的openssl动态库文件,出现版本不匹配。
解决方案:
将环境变量中的LD_LIBRARY_PATH 删除,使系统工具执行时在默认路径下查找动态库文件。
subprocess.Popen方法中可以指定指令执行时的环境变量,不影响python文件runtime的环境变量。
import subprocess
import os
def run_cmd(cmd):
# 获取所有环境变量的拷贝,并删除其中的 LD_LIBRARY_PATH
env_copy = os.environ.copy()
env_copy.pop("LD_LIBRARY_PATH", None)
p = subprocess.Popen(
cmd.split(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env_copy, # 指定运行时的环境变量
)
while p.poll() is None:
pass
out, err = p.communicate()
print(out, err, p.returncode)
return out, p.returncode
run_cmd("ipmitool mc info")