Pip 和 Conda 是 Python 的两大软件包管理工具,它们的官方源在国内访问困难,下载速度非常慢。一般情况下我们使用的都是国内的镜像源,例如清华大学的 TUNA 镜像站、阿里云的镜像站。
但是有些软件包体积非常大,安装的时候从镜像站下载下来仍然需要等待很长时间,如果正巧遇到镜像站负载高峰导致下载速度缓慢,那更是雪上加霜。
为了防止配环境的时候软件包下载等待时间过长,一个可行的方法就是搭建一个本地镜像源,在下载的时候直接从本地镜像源下载,速度能够达到内网带宽。如果是千兆内网,那么理论可以达到 125MB/s,这个速度即使是好几个 GB 的软件包,也能在半分钟内装好。
1. 前言
首先我们需要知道缓存源和镜像源的区别:
- 缓存源:初始状态为空。下载请求的软件包没有缓存,则回源到设置的上游镜像源,然后该软件包会被缓存。如果请求的软件包已经被缓存,则直接从本地缓存返回用户。
- 下载速度:第一次速度 = 通过外网从上游镜像源下载的速度;之后的速度 = 内网带宽速度。
- 磁盘空间:少。初始时只保存了软件包索引,随着使用过程,软件包被缓存,磁盘占用逐渐变大。
- 镜像源:初始状态含有所有软件包,并且定时与上游镜像源同步。当下载请求到来时直接返回本地文件。
- 下载速度:内网带宽速度,即使公网断了也能正常下载。
- 磁盘空间:极大。完整的镜像都是 10TB+ 级别的,当然我们可以选择镜像一个子集。
通过上面的对比,可以发现这两种方案各有优劣。缓存源类似 CDN 缓存,镜像源则相当于全部复制。
这篇文章选择的方案是:
- PyPI:使用 devpi (+ Nginx) 搭建缓存源
- Conda:使用 Python + Nginx 搭建镜像源
PyPI 选择搭建缓存源的原因是 Pypi 的完整库体积过大(目前已经有 16TB,详见 https://pypi.org/stats/),全部镜像一遍成本过高,且平时根本用不到所有软件包,所以选择搭建缓存源。如果你恰好财力雄厚,也可以选择搭建镜像源,可使用 bandersnatch 进行同步(TUNA 就是用的这个程序).
而 Conda 选择搭建镜像源的原因是没有好用的缓存源程序,并且 Conda 的软件包数目比 Pypi 少很多,而且如果只下载 Windows 和 Linux 版本的软件包,搭建镜像源所需储存空间也能够接受(~800GB). 当然如果如果要搭建完整镜像,大小仍然是很夸张的 11TB.
2. 使用 devpi 搭建 PyPI 缓存源
快速配置
devpi 本身是一个 Python 软件包,可通过 pip 下载:pip install devpi
安装好后,首先需要初始化。使用 devpi-init --serverdir=[PATH] 进行初始化,其中 [PATH] 代表程序的工作目录,配置文件和缓存都储存在这个目录下,因此该目录的储存空间一定要充足。如果不指定这个参数,则工作目录默认在用户目录下:~/.devpi/server
初始化后,就可以启动服务器了。使用 devpi-server --host=[HOST] --port=[PORT] --serverdir=[PATH] 启动服务器,其中 [HOST] 为监听的地址,填写 127.0.0.1 则只有本机能访问,填写 0.0.0.0 则任何主机都能访问,[PORT] 为监听的端口,[PATH] 为刚才初始化选择的工作目录。
此时缓存源就已经正常运行了,不过 devpi 默认的上游镜像源是官方源,回源的时候会很慢,我们可以改成国内镜像。首先使用 devpi use http://[HOST]:[PORT]/root/pypi 选择我们刚才搭建的镜像源(此时不要关闭 devpi-server),然后使用 devpi login root 登陆 root 账号,默认密码为空直接回车即可。
然后选择使用以下命令切换上游镜像:
# 清华源
devpi index root/pypi "mirror_web_url_fmt=https://pypi.tuna.tsinghua.edu.cn/simple/{name}/" "mirror_url=https://pypi.tuna.tsinghua.edu.cn/simple/"
# 阿里源
devpi index root/pypi "mirror_web_url_fmt=https://mirrors.aliyun.com/pypi/simple/{name}/" "mirror_url=https://mirrors.aliyun.com/pypi/simple/"
配置 pip 下载源
如果是临时使用的话,在 pip 安装时指定 -i 参数填写下载源即可:
pip install -i [HOST]:[PORT] some-package
pip 需要手动信任非 https 的源,因此需要额外加 --trust-host 参数:
pip install -i http://[HOST]:[PORT]/root/pypi --trust-host [HOST]:[PORT] some-package
如果需要将其设为默认,则需要修改 pip 设置:
pip config set global.index-url http://[HOST]:[PORT]/root/pypi
pip 需要手动信任非 https 的源,因此需要额外修改:
pip config set global.trusted-host [HOST]:[PORT]
高级配置
上面的快速配置只是提供了基本的服务,如果你是安装在主力机上临时使用,这样就已经足够了。如果你想配置到服务器上永久使用,则需要一些高级配置。下面的操作均使用 Linux 系统完成。
首先生成配置文件,使用 devpi-gen-config --host=[HOST] --port=[PORT] --serverdir=[PATH],配置文件就会生成到当前目录的 gen-config 文件夹下。
我们本篇教程用到的是:
- devpi.service:systemctl 服务配置文件
- nginx-devpi.conf:Nginx 站点配置文件
首先使用 systemctl 实现服务自启,将配置文件拷贝到服务目录:cp gen-config/devpi.service /etc/systemd/system/
然后启用服务:systemctl enable devpi.service
然后启动服务:systemctl start devpi.service
查看服务状态:systemctl status devpi.service
如果显示绿色则说明服务正常启动了。
然后使用 Nginx 实现反向代理,首先要保证服务器装有 Nginx:apt install nginx
然后将配置文件拷贝到 Nginx 配置目录:cp gen-config/nginx-devpi.conf /etc/nginx/sites-available
然后链接到启动的网站目录:ln -s /etc/nginx/sites-available/nginx-devpi.conf /etc/nginx/sites-enabled/nginx-devpi.conf
最后重载配置文件:systemctl reload nginx
3. 使用 Python + Nginx 搭建 Conda 镜像源
镜像同步
同步上游镜像源使用的是 TUNA 提供的 Python 脚本,开源在 GitHub 上:https://github.com/tuna/tunasync-scripts/blob/master/anaconda.py
该脚本默认上游源为官方源,同步规模为完整同步 (11TB),我将这个脚本进行了调整,具体调整和对应代码行号如下:
- (19~29) 上游源调整为 TUNA 清华源,加速同步
- (34~36) 软件包只同步 Linux 64 位、Windows 64 位和通用三种系统架构,减小镜像体积。大家可以根据自己的设备情况进行调整。
- (38~40, 235~236) Conda 安装包只同步 Linux 64 位、Windows 64 位两种,减小镜像体积。
- (42~44) 删除全部额外 Conda 频道,减小镜像体积。如果大家需要用到 conda-forge 频道,可以把注释去掉。如果要用到其他频道,可以去 GitHub 原版脚本查找。
- (69, 80) 哈希校验全部跳过,直接返回 True,加快同步过程。如果大家想要启动校验,则把这两行修改恢复即可。
- (223~224) 每次同步都完整检查安装包。脚本默认设置的是每次同步 10% 的几率进行完整同步,若不是完整同步,则脚本如果发现最新版已经同步了之后就会直接跳过,不再检查旧版。
如果使用我这个脚本进行同步的话,镜像大小大约为 800GB. 大家可以根据自己的实际情况进行调整。不过有几点需要注意:
- 由于 TUNA 源的安装包列表格式和官方源不同,所以如果上游源指定为 TUNA 源时,安装包 (archive 和 miniconda) 无法正常同步。如果想要同步安装包,需要把上游源改回官方源。
- Python 元组不能只有一个元素,
("main")这种元组会被直接视为字符串,会导致脚本异常。如果你修改脚本时将一些元组删的只剩一个了,记得保留一个逗号,变成("main",)这样脚本就能正常运行了。
修改版的脚本如下:
#!/usr/bin/env python3
import hashlib
import json
import logging
import os
import errno
import random
import shutil
import subprocess as sp
import tempfile
from email.utils import parsedate_to_datetime
from pathlib import Path
from pyquery import PyQuery as pq
import requests
DEFAULT_CONDA_REPO_BASE = "https://mirrors.tuna.tsinghua.edu.cn/anaconda"
DEFAULT_CONDA_CLOUD_BASE = "https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud"
CONDA_REPO_BASE_URL = os.getenv("CONDA_REPO_URL", "https://mirrors.tuna.tsinghua.edu.cn/anaconda")
CONDA_CLOUD_BASE_URL = os.getenv("CONDA_COULD_URL", "https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud")
# DEFAULT_CONDA_REPO_BASE = "https://repo.continuum.io"
# DEFAULT_CONDA_CLOUD_BASE = "https://conda.anaconda.org"
# CONDA_REPO_BASE_URL = os.getenv("CONDA_REPO_URL", "https://repo.continuum.io")
# CONDA_CLOUD_BASE_URL = os.getenv("CONDA_COULD_URL", "https://conda.anaconda.org")
WORKING_DIR = os.getenv("TUNASYNC_WORKING_DIR")
CONDA_REPOS = ("main", "free", "r", "msys2")
CONDA_ARCHES = (
"noarch", "linux-64", "win-64"
)
CONDA_INSTALLER_ARCHES = (
"Linux-x86_64.sh", "Windows-x86_64.exe"
)
CONDA_CLOUD_REPOS = (
# "conda-forge/linux-64", "conda-forge/win-64", "conda-forge/noarch"
)
EXCLUDED_PACKAGES = (
"pytorch-nightly", "pytorch-nightly-cpu", "ignite-nightly",
)
# connect and read timeout value
TIMEOUT_OPTION = (7, 10)
# Generate gzip archive for json files, size threshold
GEN_METADATA_JSON_GZIP_THRESHOLD = 1024 * 1024
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] [%(levelname)s] %(message)s",
)
def sizeof_fmt(num, suffix='iB'):
for unit in ['','K','M','G','T','P','E','Z']:
if abs(num) < 1024.0:
return "%3.2f%s%s" % (num, unit, suffix)
num /= 1024.0
return "%.2f%s%s" % (num, 'Y', suffix)
def md5_check(file: Path, md5: str = None):
return True
m = hashlib.md5()
with file.open('rb') as f:
while True:
buf = f.read(1*1024*1024)
if not buf:
break
m.update(buf)
return m.hexdigest() == md5
def sha256_check(file: Path, sha256: str = None):
return True
m = hashlib.sha256()
with file.open('rb') as f:
while True:
buf = f.read(1*1024*1024)
if not buf:
break
m.update(buf)
return m.hexdigest() == sha256
def curl_download(remote_url: str, dst_file: Path, sha256: str = None, md5: str = None):
sp.check_call([
"curl", "-o", str(dst_file),
"-sL", "--remote-time", "--show-error",
"--fail", "--retry", "10", "--speed-time", "15",
"--speed-limit", "5000", remote_url,
])
if sha256 and (not sha256_check(dst_file, sha256)):
return "SHA256 mismatch"
if md5 and (not md5_check(dst_file, md5)):
return "MD5 mismatch"
def sync_repo(repo_url: str, local_dir: Path, tmpdir: Path, delete: bool):
logging.info("Start syncing {}".format(repo_url))
local_dir.mkdir(parents=True, exist_ok=True)
repodata_url = repo_url + '/repodata.json'
bz2_repodata_url = repo_url + '/repodata.json.bz2'
# https://docs.conda.io/projects/conda-build/en/latest/release-notes.html
# "current_repodata.json" - like repodata.json, but only has the newest version of each file
current_repodata_url = repo_url + '/current_repodata.json'
tmp_repodata = tmpdir / "repodata.json"
tmp_bz2_repodata = tmpdir / "repodata.json.bz2"
tmp_current_repodata = tmpdir / 'current_repodata.json'
curl_download(repodata_url, tmp_repodata)
curl_download(bz2_repodata_url, tmp_bz2_repodata)
try:
curl_download(current_repodata_url, tmp_current_repodata)
except:
pass
with tmp_repodata.open() as f:
repodata = json.load(f)
remote_filelist = []
total_size = 0
packages = repodata['packages']
if 'packages.conda' in repodata:
packages.update(repodata['packages.conda'])
for filename, meta in packages.items():
if meta['name'] in EXCLUDED_PACKAGES:
continue
file_size = meta['size']
# prefer sha256 over md5
sha256 = None
md5 = None
if 'sha256' in meta:
sha256 = meta['sha256']
elif 'md5' in meta:
md5 = meta['md5']
total_size += file_size
pkg_url = '/'.join([repo_url, filename])
dst_file = local_dir / filename
dst_file_wip = local_dir / ('.downloading.' + filename)
remote_filelist.append(dst_file)
if dst_file.is_file():
stat = dst_file.stat()
local_filesize = stat.st_size
if file_size == local_filesize:
logging.info("Skipping {}".format(filename))
continue
dst_file.unlink()
for retry in range(3):
logging.info("Downloading {}".format(filename))
try:
err = curl_download(pkg_url, dst_file_wip, sha256=sha256, md5=md5)
if err is None:
dst_file_wip.rename(dst_file)
except sp.CalledProcessError:
err = 'CalledProcessError'
if err is None:
break
logging.error("Failed to download {}: {}".format(filename, err))
if os.path.getsize(tmp_repodata) > GEN_METADATA_JSON_GZIP_THRESHOLD:
sp.check_call(["gzip", "--no-name", "--keep", "--", str(tmp_repodata)])
shutil.move(str(tmp_repodata) + ".gz", str(local_dir / "repodata.json.gz"))
else:
# If the gzip file is not generated, remove the dangling gzip archive
try:
os.remove(str(local_dir / "repodata.json.gz"))
except OSError as e:
if e.errno != errno.ENOENT:
raise
shutil.move(str(tmp_repodata), str(local_dir / "repodata.json"))
shutil.move(str(tmp_bz2_repodata), str(local_dir / "repodata.json.bz2"))
tmp_current_repodata_gz_gened = False
if tmp_current_repodata.is_file():
if os.path.getsize(tmp_current_repodata) > GEN_METADATA_JSON_GZIP_THRESHOLD:
sp.check_call(["gzip", "--no-name", "--keep", "--", str(tmp_current_repodata)])
shutil.move(str(tmp_current_repodata) + ".gz", str(local_dir / "current_repodata.json.gz"))
tmp_current_repodata_gz_gened = True
shutil.move(str(tmp_current_repodata), str(
local_dir / "current_repodata.json"))
if not tmp_current_repodata_gz_gened:
try:
# If the gzip file is not generated, remove the dangling gzip archive
os.remove(str(local_dir / "current_repodata.json.gz"))
except OSError as e:
if e.errno != errno.ENOENT:
raise
if delete:
local_filelist = []
delete_count = 0
for i in local_dir.glob('*.tar.bz2'):
local_filelist.append(i)
for i in local_dir.glob('*.conda'):
local_filelist.append(i)
for i in set(local_filelist) - set(remote_filelist):
logging.info("Deleting {}".format(i))
i.unlink()
delete_count += 1
logging.info("{} files deleted".format(delete_count))
logging.info("{}: {} files, {} in total".format(
repodata_url, len(remote_filelist), sizeof_fmt(total_size)))
return total_size
def sync_installer(repo_url, local_dir: Path):
logging.info("Start syncing {}".format(repo_url))
local_dir.mkdir(parents=True, exist_ok=True)
# full_scan = random.random() < 0.1 # Do full version check less frequently
full_scan = True
def remote_list():
r = requests.get(repo_url, timeout=TIMEOUT_OPTION)
d = pq(r.content)
for tr in d('table').find('tr'):
tds = pq(tr).find('td')
if len(tds) != 4:
continue
fname = tds[0].find('a').text
sha256 = tds[3].text
if not any(fname.endswith(suffix) for suffix in CONDA_INSTALLER_ARCHES):
continue
if sha256 == '<directory>' or len(sha256) != 64:
continue
yield (fname, sha256)
for filename, sha256 in remote_list():
pkg_url = "/".join([repo_url, filename])
dst_file = local_dir / filename
dst_file_wip = local_dir / ('.downloading.' + filename)
if dst_file.is_file():
r = requests.head(pkg_url, allow_redirects=True, timeout=TIMEOUT_OPTION)
len_avail = 'content-length' in r.headers
if len_avail:
remote_filesize = int(r.headers['content-length'])
remote_date = parsedate_to_datetime(r.headers['last-modified'])
stat = dst_file.stat()
local_filesize = stat.st_size
local_mtime = stat.st_mtime
# Do content verification on ~5% of files (see issue #25)
if (not len_avail or remote_filesize == local_filesize) and remote_date.timestamp() == local_mtime and \
(random.random() < 0.95 or sha256_check(dst_file, sha256)):
logging.info("Skipping {}".format(filename))
# Stop the scanning if the most recent version is present
if not full_scan:
logging.info("Stop the scanning")
break
continue
logging.info("Removing {}".format(filename))
dst_file.unlink()
for retry in range(3):
logging.info("Downloading {}".format(filename))
err = ''
try:
err = curl_download(pkg_url, dst_file_wip, sha256=sha256)
if err is None:
dst_file_wip.rename(dst_file)
except sp.CalledProcessError:
err = 'CalledProcessError'
if err is None:
break
logging.error("Failed to download {}: {}".format(filename, err))
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--working-dir", default=WORKING_DIR)
parser.add_argument("--delete", action='store_true',
help='delete unreferenced package files')
args = parser.parse_args()
if args.working_dir is None:
raise Exception("Working Directory is None")
working_dir = Path(args.working_dir)
size_statistics = 0
random.seed()
logging.info("Syncing installers...")
for dist in ("miniconda", "archive"):
remote_url = "{}/{}".format(CONDA_REPO_BASE_URL, dist)
local_dir = working_dir / dist
try:
sync_installer(remote_url, local_dir)
size_statistics += sum(
f.stat().st_size for f in local_dir.glob('*') if f.is_file())
except Exception:
logging.exception("Failed to sync installers of {}".format(dist))
for repo in CONDA_REPOS:
for arch in CONDA_ARCHES:
remote_url = "{}/pkgs/{}/{}".format(CONDA_REPO_BASE_URL, repo, arch)
local_dir = working_dir / "pkgs" / repo / arch
tmpdir = tempfile.mkdtemp()
try:
size_statistics += sync_repo(remote_url,
local_dir, Path(tmpdir), args.delete)
except Exception:
logging.exception("Failed to sync repo: {}/{}".format(repo, arch))
finally:
shutil.rmtree(tmpdir)
for repo in CONDA_CLOUD_REPOS:
remote_url = "{}/{}".format(CONDA_CLOUD_BASE_URL, repo)
local_dir = working_dir / "cloud" / repo
tmpdir = tempfile.mkdtemp()
try:
size_statistics += sync_repo(remote_url,
local_dir, Path(tmpdir), args.delete)
except Exception:
logging.exception("Failed to sync repo: {}".format(repo))
finally:
shutil.rmtree(tmpdir)
print("Total size is", sizeof_fmt(size_statistics, suffix=""))
if __name__ == "__main__":
main()
# vim: ts=4 sw=4 sts=4 expandtab
如果要使用该脚本,首先要安装 Python,然后再安装对应的 Python 软件包。启动指令为:
python anaconda.py --working-dir=[PATH]
其中 [PATH] 为储存镜像的目录,需要保证这个目录空间充足,建议至少留 500GB 空间。我宿舍的网速限制为 50Mbps,同步 800GB 花了两天。
运行 HTTP 服务
同步好后,就可以配置 Nginx HTTP 服务器了,当然如果懒得用 Nginx,也可以直接用 Python,性能差一点罢了:python -m http.server
Nginx 配置文件中,配置好 root 目录就行了。同时也可以打开 autoindex 功能,这样访问的时候就能列出所有内容了。配置文件的片段如下,记得把 [PATH] 替换为自己的目录:
root [PATH];
location / {
try_files $uri $uri/ =404;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
}
最后,还可以用 crontab 实现定时任务,定时进行同步,使用 crontab -e 编辑任务,然后添加一行:
0 9 * * * /mirror/anaconda.py --working-dir=/mirror/conda
前面的五位为定时时间,上面设置的是每天早上 9 点,后面是定时运行的指令,记得自己调整目录。
配置 conda 镜像源
conda 的配置文件在 ~/.condarc,打开这个配置文件,设置:
show_channel_urls: true default_channels: - https://conda.dorm.diona.moe/pkgs/main - https://conda.dorm.diona.moe/pkgs/free - https://conda.dorm.diona.moe/pkgs/r - https://conda.dorm.diona.moe/pkgs/msys2 custom_channels: conda-forge: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud msys2: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud bioconda: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud menpo: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud pytorch: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud pytorch-lts: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud simpleitk: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
default_channels 是同步的默认源,将其替换为我们本地镜像的地址。custom_channels 是额外的频道,我搭建的时候没有同步额外频道,所以这里我填写的是清华 TUNA 源。如果大家同步了额外频道,则将其对应的修改为本地镜像的地址。
![[doge]](https://static.zouht.com/wp-content/plugins/WP-Alu2Button/static/img/%5Bdoge%5D.png)
发表回复