用 Wasmer 运行 Python 并安装依赖

约 7 分钟 #开发
杨秋逸
杨秋逸

什么是 Wasmer

Wasmer 是一个 WebAssembly 运行时,可以把各种语言编译成 .wasm 然后在任何地方运行。它社区维护了一个 Python 的 WASIX 构建,让你可以在不安装 Python 的机器上跑 Python 脚本:

wasmer run python/python@3.13 -- -c "print('hello world')"

一行命令,不需要安装 Python,不需要管版本。这在 CI、临时脚本、或者不想污染本地环境时特别有用。

问题:怎么装依赖

直接用 pip install 是不行的——WASIX 构建的 Python 缺少 mmap 等 C 模块,pip 的 install 命令跑不起来。

但换个思路:不用 pip,直接用 Python 标准库下载 wheel 解压到 site-packages。Python 的 wheel 本质上就是个 zip 包,解压到 site-packages 目录就能 import。

两类依赖

Python 包分两种:

  1. 纯 Python 包(如 pytz、six):wheel 里只有 .py 文件,任何平台都能用,从 PyPI 下载即可
  2. 含 C 扩展的包(如 numpy、pandas):wheel 里有编译好的 .so/.dll,需要对应平台的预编译版本

对于第二类,Wasmer 社区维护了一个 WASIX Python 索引,提供了 60+ 常用包的 WASIX 预编译 wheel。

完整方案

下面这个脚本可以在 Wasmer Python 里直接运行,自动处理两类依赖:

import sys, os, zipfile, urllib.request, re, tempfile, json

site_packages = os.path.join(
    sys.base_exec_prefix, 'lib',
    f'python{sys.version_info.major}.{sys.version_info.minor}',
    'site-packages'
)
os.makedirs(site_packages, exist_ok=True)

def install(name):
    # 先试 WASIX 索引(含 C 扩展的包)
    try:
        url = f'https://pythonindex.wasix.org/simple/{name}/'
        html = urllib.request.urlopen(url, timeout=30).read().decode()
        links = re.findall(
            r'href="([^"]+cp313-cp313-wasix_wasm32\.whl[^"]*)"', html
        )
        if links:
            target = ('https://pythonindex.wasix.org/packages/'
                      + links[0].split('/')[-1].split('#')[0])
            print(f'[wasix] {name}...')
            resp = urllib.request.urlopen(target, timeout=120)
            tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.whl')
            tmp.write(resp.read()); tmp.close()
            with zipfile.ZipFile(tmp.name) as zf:
                zf.extractall(site_packages)
            os.unlink(tmp.name)
            return True
    except:
        pass

    # 再从 PyPI 下载纯 Python wheel
    data = json.loads(
        urllib.request.urlopen(f'https://pypi.org/pypi/{name}/json').read()
    )
    ver = data['info']['version']
    for f in data['releases'].get(ver, []):
        fn = f['filename']
        if 'py3-none-any' in fn and fn.endswith('.whl'):
            print(f'[pure] {name} {ver}...')
            resp = urllib.request.urlopen(f['url'], timeout=60)
            tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.whl')
            tmp.write(resp.read()); tmp.close()
            with zipfile.ZipFile(tmp.name) as zf:
                zf.extractall(site_packages)
            os.unlink(tmp.name)
            return True

    print(f'[FAIL] {name}')
    return False

然后正常使用:

# 安装依赖
for pkg in ['pytz', 'six', 'python-dateutil', 'numpy', 'pandas']:
    install(pkg)

# 使用
import numpy as np
import pandas as pd

print(np.array([1, 2, 3]) * 10)
print(pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]}))

运行

把上面的代码保存为 script.py,然后:

wasmer run python/python@3.13 --net -- script.py
需要加 --net 参数开启网络访问,否则无法下载 wheel。

依赖下载到哪了

wheel 解压后去了哪?看代码里的 site_packages 变量:

site_packages = os.path.join(
    sys.base_exec_prefix, 'lib',
    f'python{sys.version_info.major}.{sys.version_info.minor}',
    'site-packages'
)

sys.base_exec_prefix 在 Wasmer Python 里是 /usr/local,所以最终路径是:

/usr/local/lib/python3.13/site-packages/

这个路径在 WASM 虚拟机的文件系统里,不在你的宿主机上。每次 wasmer run 都是一个全新的环境,上次装的包不会保留。

如果你希望依赖持久化,可以把 site-packages 挂载到宿主机目录:

# 第一次运行,装依赖
wasmer run python/python@3.13 --net \
  --volume ./site-packages:/usr/local/lib/python3.13/site-packages \
  -- script.py

# 之后运行,直接用已装好的依赖
wasmer run python/python@3.13 \
  --volume ./site-packages:/usr/local/lib/python3.13/site-packages \
  -- script.py

这样 wheel 会被解压到宿主机的 ./site-packages/ 目录,下次运行时直接挂载即可,不需要重新下载。

完整示例

把依赖安装和脚本运行分开,第一次装依赖,之后直接跑:

# 第一步:装依赖(写入宿主机 ./site-packages/)
wasmer run python/python@3.13 --net \
  --volume ./site-packages:/usr/local/lib/python3.13/site-packages \
  -- -c "
import sys, os, zipfile, urllib.request, re, tempfile, json

site_packages = '/usr/local/lib/python3.13/site-packages'
os.makedirs(site_packages, exist_ok=True)

def install(name):
    try:
        url = f'https://pythonindex.wasix.org/simple/{name}/'
        html = urllib.request.urlopen(url, timeout=30).read().decode()
        links = re.findall(r'href=\"([^\"]+cp313-cp313-wasix_wasm32\.whl[^\"]*)\"', html)
        if links:
            target = 'https://pythonindex.wasix.org/packages/' + links[0].split('/')[-1].split('#')[0]
            print(f'[wasix] {name}...')
            resp = urllib.request.urlopen(target, timeout=120)
            tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.whl')
            tmp.write(resp.read()); tmp.close()
            with zipfile.ZipFile(tmp.name) as zf: zf.extractall(site_packages)
            os.unlink(tmp.name)
            return True
    except: pass
    data = json.loads(urllib.request.urlopen(f'https://pypi.org/pypi/{name}/json').read())
    ver = data['info']['version']
    for f in data['releases'].get(ver, []):
        fn = f['filename']
        if 'py3-none-any' in fn and fn.endswith('.whl'):
            print(f'[pure] {name} {ver}...')
            resp = urllib.request.urlopen(f['url'], timeout=60)
            tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.whl')
            tmp.write(resp.read()); tmp.close()
            with zipfile.ZipFile(tmp.name) as zf: zf.extractall(site_packages)
            os.unlink(tmp.name)
            return True
    print(f'[FAIL] {name}')

for pkg in ['pytz', 'six', 'python-dateutil', 'numpy', 'pandas']:
    install(pkg)
"

# 第二步:运行脚本(直接用已装好的依赖)
wasmer run python/python@3.13 \
  --volume ./site-packages:/usr/local/lib/python3.13/site-packages \
  --volume ./script.py:/script.py \
  -- script.py

为什么 pip 不能直接用

WASIX 构建的 Python 缺少 mmap 模块(pip 内部依赖它来做缓存),所以 pip install 跑不了。但 ensurepip 模块是有的,可以提取出 pip 本身——只是 install 命令不可用。

版本注意事项

  • 需要用 python/python@3.13,因为 WASIX 索引的 wheel 都是 cp313 的
  • wasmer/python 是 3.12 版本,对应的 WASIX wheel 较少
  • 如果包在 WASIX 索引里找不到,那它要么没有 C 扩展(走 PyPI 纯 Python wheel),要么就是不支持

总结

在 Wasmer 里跑 Python 并安装依赖的关键:

  1. 不用 pip,用 urllib + zipfile 手动下载解压 wheel
  2. 含 C 扩展的包从 pythonindex.wasix.org 下载
  3. 纯 Python 包从 PyPI 下载 py3-none-any wheel
  4. 运行时加 --net 开启网络
  5. 用 --volume 把 site-packages 挂载到宿主机,实现依赖持久化

这样就能在完全不需要本地 Python 环境的情况下,运行任何 Python 脚本了。