RunPod 模板与自定义镜像:从官方模板到自己的 Dockerfile
在 Pod 里手动装环境的人,大概都经历过这样一轮:SSH 进去 pip 装一堆包,编译几个系统依赖,把模型权重从 Hugging Face 拉下来,终于跑通了第一次推理。第二天想改一个环境变量,控制台上点了保存,Pod 重启,回来一看卷挂载路径以外的东西全没了——官方文档在环境变量那一页专门用警告框标了这件事:更新环境变量会重启 Pod,并清掉 volume 挂载路径(默认是 /workspace)以外的所有数据。装了两个小时的环境就这么消失了。
到这一步,“要不要自己做镜像”就不再是一个技术洁癖问题,而是一个成本问题。这篇文章按 RunPod 官方文档的口径,把模板这层东西拆开讲:它到底定义了什么、官方模板帮你省掉了哪些活、什么时候必须自己做镜像、以及自己维护镜像的代价真正花在哪里。涉及磁盘容量、构建耗时、基础镜像版本组合这类会变的值,本文只讲机制与判据,具体数值以官方文档和控制台当时的显示为准。RunPod 平台本身的两种形态,可以先看 RunPod 是什么。
一、模板定义的不止是一个镜像
很多人把”模板”理解成”一个 Docker 镜像的别名”,这个理解会在第一次配端口的时候出问题。按文档的说法,模板是预配置好的 Docker 镜像设置,包含启动一个配置完整的 Pod 所需的全部组件:
- 容器镜像:带着全部软件包与依赖的那个 Docker 镜像。
- 硬件规格:容器盘大小、卷大小、以及卷的挂载路径。
- 网络设置:对外暴露的 HTTP 端口与 TCP 端口。
- 环境变量:预先配好、用来改应用行为的键值对。
- 启动命令:Pod 启动时执行的指令。
也就是说,镜像只是模板的一个字段。剩下那四项决定了”这个镜像在 RunPod 上能不能按你预期的方式跑起来”。最典型的翻车是端口:镜像里的服务监听得好好的,但模板里没声明端口,外面就是连不上。文档把两类端口的差别写得很明确——HTTP 端口会走 RunPod 的 HTTP 代理,自动套上 HTTPS,通过代理 URL 访问;TCP 端口是给需要裸 TCP 连接的服务用的,比如 SSH、数据库或者自定义协议。
模板还有两个容易忽略的约束。一个是算力类型:模板被限制在特定的算力类型上,只能配匹配的硬件用,文档列的是 NVIDIA GPU、AMD GPU、CPU 三类。一个给 CUDA 编译的镜像做成的模板,不会在 CPU 机型上跑给你看。另一个是可见性:模板可以是私有的(只有你或团队成员能用),也可以公开——公开的会出现在控制台的 Explore 区,所有 RunPod 用户都能看到。
二、官方模板省掉了哪些活
文档对模板的价值说得很直白:不用自己装 PyTorch、不用自己配 JupyterLab、不用自己折腾依赖,选一个模板就是配好的环境。这句话里藏着三件被省掉的活。
第一件是依赖组合的试错。RunPod 提供带 PyTorch、CUDA 和常见依赖的基础镜像,版本是配好对的。自己从裸 Ubuntu 开始拼 CUDA 与框架版本,是个熟悉的时间黑洞。
第二件是交互式服务的进程管理。文档里提到基础镜像自带 Jupyter 和 SSH 服务,由镜像里的 /start.sh 根据模板设置自动拉起。你不用自己写进程守护,也不用操心 SSH 公钥怎么进容器——RunPod 会把授权公钥注入到环境变量里。
第三件是维护责任。官方文档把模板分成三类,支持边界写得毫不含糊:
| 类型 | 谁做的 | 支持口径 |
|---|---|---|
| Official | RunPod 精选维护,会定期测试与更新 | RunPod 提供完整支持 |
| Community | 用户创建,按社区使用量被推上来,配置五花八门 | 只有社区 Discord |
| Custom | 你自己建的,可私有也可公开分享 | 自负其责 |
这张表值得多看一眼。文档还专门加了警告:RunPod 不维护、也不为社区模板提供客户支持,出问题请直接找模板作者或去社区 Discord。落到实际选型上,这意味着社区模板省下的那半天配置时间,是用”出问题时没有 SLA”换来的。一个冷门的社区模板跑不起来,你既看不到它的 Dockerfile,也没有工单可开——这种时候自己做镜像反而更快。
三、什么信号说明该自己做镜像
文档给自定义模板列的收益是:把项目需要的一切打包成可复用的 Docker 镜像,部署时几秒钟起来,不必每次开新 Pod 都重装依赖,还能分享给团队成员和社区。据此可以反推出几条判据。
该自己做镜像的信号:依赖装起来很慢或者需要编译系统包;要跨多次部署保证环境完全一致;模型权重每次重新下载的时间已经不可忽略;要上生产、不想让 Jupyter 和 SSH 这些交互服务在里面跑着;要把构建接进 CI(文档提到可以用 GitHub Actions 一类工具自动构建,并给了一个参考仓库 runpod-workers/pod-template)。
不该急着做镜像的信号:只是多装两三个 pip 包、而且这个实验跑一次就结束;团队里只有你一个人用、也没有复现需求。这种情况下官方模板加上启动命令里的一行 pip,代价比维护镜像低得多——虽然每次开 Pod 都要重装一遍。
顺带提一句:不要把 Docker Compose 当成备选方案。文档在 Pod 的限制里明确写了,RunPod 已经在替你跑 Docker,所以你没法在 Pod 里再起一个自己的 Docker 实例,也用不了 Docker Compose。同一节还写了另外两条:Pod 只支持 TCP 和 HTTP 连接,不支持 UDP;不支持 Windows。多容器编排的架构,在这一层要换成别的思路。
四、镜像从哪来:仓库来源与标签纪律
自定义容器的来源,文档点名的是这几家:Docker Hub、GitHub Container Registry、Amazon ECR,以及”你自己的私有仓库”——用私有镜像要在模板里填 registry credentials,RunPod 才能在部署时把镜像拉下来。模板配置里给的镜像名示例就是 ubuntu:latest、pytorch/pytorch:latest 这种公共仓库的常规写法。RunPod 自家的基础镜像放在 Docker Hub 的 runpod 组织下,教程里用的是 PyTorch 基础镜像,形如:
FROM runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404
具体可用的 tag 组合会随 PyTorch、CUDA、Ubuntu 的版本演进而变,别把某个 tag 当常量写进内部文档,去 Docker Hub 上看当前有哪些。
这里有一条容易被当成小事的纪律,文档专门用提示框强调了:用带版本号的 tag,不要用 :latest。给的理由有三条——:latest 是可变的,每次推新镜像它就变了;这会让部署结果不可预测、排错困难;而且会和 RunPod 的镜像缓存机制冲突。改用语义化版本(:v1.0、:v1.1 这种)才能保证部署可预测、回滚方便。这条在自建推理场景下格外要紧:一个端点悄悄换了镜像内容而版本号没动,事后你连”到底跑的是哪版代码”都查不清。
另一条是构建平台。文档给的命令带着 --platform linux/amd64,并说明这个 flag 用来保证与 RunPod 基础设施的兼容性,在 Mac 或 ARM 机器上构建时是必需的:
docker build --platform linux/amd64 -t my-custom-template:v1.0 .
在 Apple 芯片的笔记本上忘了这个参数,镜像能构建成功、也能本地跑,推上去才发现架构不对。文档同时给了本地验证的方式:用同样带 --platform 的 docker run --rm -it ... /bin/bash 进容器里手动试一遍,这个 shell 与 RunPod 的 Web 终端等价,只是跑在你自己机器上。先在本地验证一轮,比推上去再开机排错省事。
五、镜像里该放什么、不该放什么
启动行为:三种模式,挑对一种
文档把容器启动方式拆成了三个选项,这一节其实决定了你的镜像是”开发机”还是”服务”。
选项一,保留基础镜像的全部服务(默认)。什么都不改,基础镜像的 /start.sh 会按模板设置把 Jupyter 和 SSH 拉起来,适合交互式开发和远程接入。
选项二,服务起来之后再跑你的应用。文档给的做法是加一个启动脚本,让基础服务在后台跑,再执行自己的程序:
#!/bin/bash
# Start base image services (Jupyter/SSH) in background
/start.sh &
# Wait for services to start
sleep 2
# Run your application
python /app/main.py
# Wait for background processes
wait
选项三,只跑应用。清掉基础镜像的 entrypoint,只留自己的进程,文档的定位是”适合不需要交互式访问的生产部署”,开销最小:
ENTRYPOINT []
CMD ["python", "/app/main.py"]
这三者的取舍很直接:开发期用选项一,摸清了再切选项三。留着 Jupyter 和 SSH 上生产,等于在推理容器里多开了两个对外入口。
权重:打进镜像还是留在外面
文档给预置模型的理由是:把模型预先放进镜像,就不用每次起新 Pod 都重新下载一遍,环境可复用也可分享。两种做法都写了。一种是构建时用 transformers 自动从 Hugging Face 下载并缓存,靠环境变量指定缓存目录:
ENV HF_HOME=/app/models
ENV HF_HUB_ENABLE_HF_TRANSFER=0
RUN python -c "from transformers import pipeline; pipeline('sentiment-analysis', model='distilbert-base-uncased-finetuned-sst-2-english')"
另一种是用 wget 显式把 config、safetensors、tokenizer 这些文件拉到一个本地目录,文档说这种方式控制更明确,也适用于自定义或自建托管的模型。有一个细节容易漏:走缓存那条路,运行时加载模型要带 local_files_only=True,文档给的示例代码里就是这么写的——否则它可能又去联网找一遍。
要不要把权重打进去,代价是这样分配的:打进去,换来的是启动时不必等下载,付出的是镜像体积变大、构建时间变长、容器盘要按镜像实际体积留(教程里给的容器盘下限比默认值大不少,正是因为模型在镜像里)、以及此后每次只改一行代码也要重推一个大镜像。不打进去,镜像轻、迭代快,但权重得从别处来——放网络卷或运行时下载,这就把时间转移到了首次启动上。哪种划算取决于你的部署形态:常驻的 Pod 对启动时间不敏感,而按需拉起 Worker 的场景里,这个选择直接写进冷启动时长,具体见 RunPod Serverless 部署推理服务;权重放哪一层存储的取舍,见 RunPod 存储怎么选。
不该放的东西
明文密钥不该进镜像,也不该直接写进模板的环境变量值里。RunPod 给了 secrets 机制:加密存储的字符串,创建之后值在界面上再也看不到(文档说这是防止意外泄露的安全设计),在模板里用 {{ RUNPOD_SECRET_secret_name }} 这种形式引用,Pod 启动时替换成真实值注入成环境变量。文档提的典型用途包括 Hugging Face 一类的模型访问令牌、数据库凭据、第三方 API key。有一条警告要记住:删除 secret 不可撤销,删之前先确认没有活跃的模板或 Pod 还在用它,否则那些部署会直接失败。
六、环境变量与启动命令怎么交接
启动命令在模板里是可以覆盖的,文档写明它会覆盖容器里默认的 CMD,支持两种写法:简单的 bash 命令,形如 bash -c 'mkdir /workspace && /start.sh';或者 JSON 形式同时给 entrypoint 和 cmd,形如 {"cmd": ["python", "app.py"], "entrypoint": ["bash", "-c"]}。紧跟着有一条提示值得贴在墙上:大多数 Docker 镜像自带启动命令,所以这一栏通常留空就行;自定义的时候要确认你没有覆盖掉镜像运行所必需的既有命令。基础镜像里那个 /start.sh 就属于”覆盖了会出事”的东西——这也是选项二要写成 /start.sh & 而不是直接替换掉它的原因。
环境变量有三个设置入口:创建 Pod 时点 Edit Template 展开 Environment Variables 添加;在模板里预先配好;以及 Pod 跑起来之后从 Edit Pod 改。文档给了每个 Pod 的环境变量数量上限(具体数值以官方文档为准)。第三个入口就是开头那个坑:改环境变量会重启 Pod,卷挂载路径以外的数据全部清掉。所以”临时改个变量试试”这个动作在 RunPod 上不便宜,值得在做镜像时就把该参数化的东西参数化。
RunPod 还会自动注入一批变量,可以直接在启动脚本里用来做自适应,文档列出的包括 RUNPOD_POD_ID、RUNPOD_DC_ID、RUNPOD_POD_HOSTNAME、RUNPOD_GPU_COUNT、RUNPOD_CPU_COUNT、RUNPOD_PUBLIC_IP、RUNPOD_TCP_PORT_22、RUNPOD_VOLUME_ID、RUNPOD_API_KEY、PUBLIC_KEY、CUDA_VERSION、PYTORCH_VERSION。在容器里确认它们是否注入成功,文档给的办法是 env | grep RUNPOD。RUNPOD_GPU_COUNT 这类变量对写启动脚本很实用:多卡场景下的并行度不必硬编码在镜像里,从环境读就行——这也正是划分边界的原则,镜像里只放不会变的东西,会变的一律走环境变量。想在这上面手动起推理服务,参数怎么给可以参考 vLLM 起服务的启动参数。
七、把模板固化成代码
控制台上点出来的模板有个问题:它的状态只存在于界面里。文档给了 REST 接口,可以用程序创建和更新模板,字段名如下(值我用占位符代替,字段名抄的是文档原文):
curl --request POST \
--url https://rest.runpod.io/v1/templates \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"category": "NVIDIA",
"containerDiskInGb": <按需填>,
"dockerEntrypoint": [],
"dockerStartCmd": [],
"env": { "ENV_VAR": "value" },
"imageName": "CONTAINER_IMAGE",
"isPublic": false,
"isServerless": false,
"name": "TEMPLATE_NAME",
"ports": ["8888/http", "22/tcp"],
"readme": "",
"volumeInGb": <按需填>,
"volumeMountPath": "/workspace"
}'
这份字段表本身就是对第一节那个”模板不只是镜像”的印证:算力类型、容器盘、卷与挂载路径、端口(注意写法是 <端口号>/<协议>)、环境变量、entrypoint 与启动命令、可见性,全在里面。还有一个 isServerless 字段,说明模板在平台内部是区分 Pod 与 Serverless 两种用途的——如果你打算走官方 vLLM worker 那条路,模板这一层的配置方式会不一样,见 在 RunPod 上用 vLLM 起 OpenAI 兼容接口。
把这段 curl 放进 CI,配上 runpod-workers/pod-template 那种 GitHub Actions 自动构建,“构建镜像 → 推仓库 → 更新模板”就能连成一条可追溯的链,而不是靠某个人记得去界面上改一下。
至于 RunPod Hub,官方把它定位成”发现、部署、分享为 RunPod 优化过的预配置 AI 项目”的仓库,Pod 文档里也提到可以把 Hub 上兼容 Serverless 的仓库直接当 Pod 部署。但 Hub 的完整章节不在我读的这几篇文档里,它的发布流程、版本管理这些细节,以官方 Hub 文档为准,我不替它猜。
八、自己维护镜像,真实成本在哪
写 Dockerfile 那半天是最便宜的一部分。真正持续付出的是这几笔:
基础镜像的跟进。PyTorch、CUDA、Ubuntu 的组合会往前走,你固定在某个 tag 上,迟早会遇到新模型要求更新的框架版本。升一次基础镜像意味着重新验证一轮依赖。
标签与回滚纪律。:latest 那条禁令背后是运维成本:要有版本号规范、要有”线上跑的是哪个 tag”的记录、要能回滚。这是流程成本,不是技术成本,而流程成本最容易在赶进度时被省掉。
体积带来的拖累。权重打进镜像之后,每次改代码都要重推一个大镜像,构建和上传的时间(以及 CI 的带宽)会持续吃掉你的迭代速度。文档也提示了首次部署要等 RunPod 把你的镜像拉下来。
凭据与 secret 的生命周期。私有仓库的 registry credentials、模型访问令牌,都要轮换;删错一个 secret 就会让在用的部署直接失败。
排错时没有外援。自定义模板在文档里的支持口径是”自负其责”。这一条在你还在摸索阶段时最疼——官方模板出问题可以开工单,自己的镜像出问题只能自己啃。
所以有一类情况是真的不该自己做镜像:一次性的实验、很轻的依赖、只有一个人用且不需要复现。这种时候官方模板加几行启动命令就够了,做镜像纯属给自己加活。反过来,只要”同一个环境要被起很多次”或者”要交给别人复现”成立,镜像的一次性投入很快就摊平了。
还有一件事需要读者自己量:官方基础镜像可用的 tag 组合、容器盘的合理大小、镜像该做多大,这些都跟你的模型和依赖强相关,也会随平台演进而变。本文讲的是机制和判据,具体的数值请以你构建时的官方文档与控制台显示为准——把别人文章里的数字抄进自己的部署脚本,是这类问题最常见的返工来源。
算完账发现自建推理不划算?
先用托管端点把业务跑起来,量上来了再回头算自建的平衡点。