资讯动态

Golang+PyTorch双语言AI推理镜像Dockerfile模板实战

发布时间:2026/10/21 23:48:10 来源:尧图企业网站定制
做 AI 推理服务这些年我发现自己反复卡在同一件事上Go 的业务代码写得再利索最后都得想办法跟 PyTorch 的模型推理塞进同一个容器。这个「基于 GolangPyTorch 的 AI 推理镜像 Dockerfile 模板」就是我在好几个项目里反复打磨沉淀出来的一套通用做法。它解决的痛点很具体怎么用一份 Dockerfile把 Go 写的推理调度服务、PyTorch 模型、CUDA 运行库、Python 依赖全部打包成一个可上线的镜像而不是靠一堆手工命令在服务器上凑出运行环境。这套模板适合谁如果你手头有一个训练好的 PyTorch 模型想用 Go 对外提供 HTTP/gRPC 推理接口又不想为了模型推理单独部署一套 Python 服务那这份模板可以直接抄。它同样适合已经用 Go 搭好了微服务体系、需要把推理能力内嵌进去的团队。下面我不会只贴 Dockerfile还会把每一层为什么这么写、哪些参数不能省、哪些坑我踩过全部拆开讲清楚。1. 为什么要做这个“双语言”推理镜像1.1 推理服务的两种典型架构先看现状。绝大多数 PyTorch 推理服务团队会从两种架构里选一种。第一种是纯 Python 方案。模型推理用 FastAPI 或 TorchServe 包一层 HTTP 接口然后直接对外提供服务。这种方案开发效率高模型加载、预处理、推理、后处理全在一个进程里调起来也顺手。但它有几个天生的短板Python 的 GIL 限制了并发面对突发流量时吞吐上不去模型常驻内存后Python 的引用管理稍微不注意内存就会缓慢上涨再加上限流、熔断、链路追踪这些在线服务必备能力FastAPI 生态里都要另接组件真要做成高可用的服务得补一大堆东西。第二种是 Go 负责流量入口、Python 负责模型推理的混合方案。Go 服务接收外部请求做鉴权、限流、参数校验、路由然后把推理任务交给本地的 Python worker 进程通过 localhost 的 gRPC 或 HTTP 通信拿结果再做后处理和返回。我在实际项目里更偏向这种。Go 服务的并发模型、内存控制、部署便利性都是为在线服务设计的而 PyTorch 的模型推理又没法用 Go 替代所以两个进程配合是性价比最高的折中。1.2 为什么不直接用纯 Go 或纯 Python有人问过既然 Go 这么香为什么不用 Go 直接调 libtorch我也试过结论是很痛苦。libtorch 的 C API 虽然能用 cgo 封装但依赖链非常长光是 CMake 配置和 ABI 匹配就能折腾一周而且 PyTorch 生态里大量用 Python 写的预处理、模型定义、算子扩展很难整体移植到 Go。反过来如果整个服务都用 Python 写在线服务的稳定性、并发能力、多租户隔离这些需求又需要投入大量精力去打磨。双进程方案的好处是把两边的优势都拿到Go 管流量和稳定性Python 管模型和算法。两个进程之间的通信在 localhost 场景下开销非常低完全可以接受。这个架构唯一的复杂度就是要在一个镜像里同时装好 Go 和 Python 的运行环境并且处理两个进程的启动、退出、信号传递。这也正是这份模板存在的意义。1.3 模板要解决的核心问题把需求拆开这份 Dockerfile 模板要解决四件事第一依赖隔离。Go 编译出的二进制和 Python 的 site-packages、CUDA 运行库、系统库要能在一个镜像里共存不能互相污染也不能缺依赖。第二镜像体积。PyTorch 加 CUDA 动辄七八个 GB如果再带上 Go 编译环境、pip 缓存、apt 缓存镜像会膨胀到没法看。必须用多阶段构建把「构建时依赖」和「运行时依赖」彻底分开。第三进程模型。容器里有两个进程时谁做 PID 1、信号怎么传递、子进程挂了怎么处理这些都要在 Dockerfile 阶段就设计好。第四可复用。模板不是给某个项目定死的模型路径、端口、Python 依赖列表、CUDA 版本都应该是可配置的换一个模型只需要改几行而不是重写 Dockerfile。这四个问题在后面的每一节里都会对应到具体写法。2. 镜像整体设计与基础镜像选型2.1 多阶段构建的整体思路多阶段构建是这套模板的地基。很多新手会把 golang 镜像当成基础镜像把 Go 源码、Python 依赖、PyTorch 全塞进去最后镜像直接往 10GB 以上奔。多阶段构建的思路很简单把「编译 Go 服务」这个过程放到一个临时镜像里完成最终运行镜像里只保留编译出来的二进制和推理环境。我的习惯是至少拆两个阶段。第一阶段用 golang 官方镜像编译 Go 二进制这个阶段有完整的 Go 工具链、模块缓存甚至可以用 BuildKit 的缓存挂载来加速 go mod download。第二阶段是真正的运行镜像基于 CUDA 运行时镜像安装 Python、PyTorch 和项目依赖然后把第一阶段产出的二进制 COPY 进来。这样Go 工具链、编译中间文件、模块缓存全都不会进最终镜像。如果项目里还涉及前端静态文件或模型预处理脚本也可以继续加阶段但再多的阶段都是同一个思路每个阶段只做一件事最终镜像只拿需要的产物。2.2 基础镜像怎么选三类方案的取舍运行阶段的基础镜像我经常被问怎么选。这里有三类主流选择我直接做成对比方案基础镜像优点缺点适用场景Apytorch/pytorch:2.3.1-cuda12.4-cudnn8-runtimetorch 已装好省一层安装版本经过官方验证镜像非常大Python 包管理封闭扩装系统库麻烦快速验证、不想折腾 CUDA 版本对齐Bnvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04干净、可控可自选 Python 版本和包体积相对小要手动装 Python、pip、torch版本搭配容易出错生产环境、精细控制组件版本Cpython:3.11-slim体积最小只有几百 MB没有 CUDA只能跑 CPU 推理本地开发、纯 CPU 场景、CI 测试我自己的倾向是凡是需要上 GPU 的生产服务都用方案 B。原因很简单方案 A 虽然省事但官方镜像里预装的包经常跟我项目里的 numpy、opencv 版本打架而且它的基础系统是定制过的想加一个 apt 包有时候会遇到奇怪的依赖残留。方案 B 装出来的环境更接近自己从零搭的虚拟机出了问题也好排查。方案 C 则适合做开发机上的快速迭代本地没有 GPU装 CPU 版 PyTorch 足够跑通业务逻辑。2.3 两个进程怎么共处进程模型设计镜像里有两个进程就要提前想好进程模型。我的方案是Go 服务做主进程由它负责拉起 Python worker 子进程并监控 worker 的健康状态。Go 主进程启动时通过 exec.Command 启动 python3 /app/worker/worker.py建立 gRPC 连接做心跳检测worker 异常退出时 Go 可以自动重启或者直接退出整个容器交给编排平台重启。这样用户只会看到「一个容器 一个推理服务」调度层不需要知道内部还有第二个进程。这里有个关键点容器里的 PID 1 必须是 tini而不是直接写 Go 二进制。原因在于 Go 服务会 fork 出 worker 进程如果 PID 1 是 Go 二进制子进程变成僵尸后没人回收信号也会被吞掉。tini 是一个极轻量的 init挂在 Dockerfile 的 ENTRYPOINT 位置负责转发信号和回收僵尸进程这是容器内多进程方案的标配。两个进程的通信我推荐 gRPC。模型推理的 payload 通常比较大gRPC 基于 HTTP/2、有二进制序列化比 JSON over HTTP 更省带宽。worker 端口只绑 127.0.0.1不暴露到容器外部外部流量全部走 Go 的 8080 端口。3. Dockerfile 核心实现与逐层拆解3.1 完整 Dockerfile 模板先贴出模板的核心文件。下面这个版本面向 GPU 推理基础镜像采用上面说的方案 B。# syntaxdocker/dockerfile:1.4 # ---------- Stage 1: 编译 Go 推理调度服务 ---------- FROM golang:1.22-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mounttypecache,target/go/pkg/mod \ go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -trimpath -ldflags-s -w \ -o /out/inference-server ./cmd/server # ---------- Stage 2: PyTorch CUDA 运行环境 ---------- FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 ENV DEBIAN_FRONTENDnoninteractive \ LANGC.UTF-8 \ PYTHONUNBUFFERED1 \ PIP_NO_CACHE_DIR1 RUN apt-get update apt-get install -y --no-install-recommends \ python3.10 python3-pip python3-dev libgomp1 curl tini ca-certificates \ ln -sf /usr/bin/python3.10 /usr/local/bin/python3 \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip3 install --no-cache-dir --upgrade pip \ pip3 install --no-cache-dir -r /app/requirements.txt # 单独装 torch利用镜像层缓存避免反复重装 RUN pip3 install --no-cache-dir \ torch2.3.1 torchvision0.18.1 \ --index-url https://download.pytorch.org/whl/cu124 COPY --frombuilder /out/inference-server /app/inference-server COPY worker/ /app/worker/ COPY models/ /app/models/ COPY configs/ /app/configs/ RUN groupadd -r appuser useradd -r -g appuser -u 10001 appuser \ chown -R appuser:appuser /app USER appuser ENV GIN_MODErelease \ MODEL_DIR/app/models \ WORKER_ADDR127.0.0.1:8081 EXPOSE 8080 ENTRYPOINT [/usr/bin/tini, --] CMD [/app/inference-server]3.2 构建阶段逐层解释先看 Stage 1。基础镜像用 golang:1.22-bookworm选 bookworm 是因为它的 glibc 版本较新后面如果 Go 二进制里带了 cgo 依赖跟运行镜像的兼容性会好一些。不过我通常会直接让 Go 静态编译彻底摆脱 glibc 的困扰。COPY go.mod go.sum ./ RUN --mounttypecache,target/go/pkg/mod \ go mod download这两行的顺序是刻意的。先把依赖描述文件拷进去再拉模块这样只要 go.mod 和 go.sum 不变这一层就会命中 Docker 的层缓存后续迭代源码时不用重复下载依赖。--mounttypecache是 BuildKit 的缓存挂载模块缓存会存在构建机的缓存目录里比把缓存写进镜像层更干净。编译命令里几个参数值得说明RUN CGO_ENABLED0 GOOSlinux go build -trimpath -ldflags-s -w \ -o /out/inference-server ./cmd/serverCGO_ENABLED0强制静态编译出来的二进制不依赖系统的 libc。否则镜像里可能缺动态链接库启动时会报“no such file or directory”这种让人摸不着头脑的错。-trimpath去掉编译路径信息-ldflags-s -w去掉符号表和调试信息能减小几十 MB 体积。GOOSlinux是交叉编译的保险写法虽然本来就在 Linux 构建但显式写上能避免环境变量干扰。3.3 运行阶段逐层解释Stage 2 是整个模板的核心。基础镜像选nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04注意是 runtime 而不是 devel。devel 版本自带编译器、头文件、静态库体积多出几个 GB但运行推理根本用不到纯属浪费。apt 安装的包每个都有用途python3.10 python3-pip python3-devUbuntu 22.04 默认就是 Python 3.10直接装系统的版本避免手工编译 Python 的麻烦。python3-dev 是给一部分要编译 C 扩展的 pip 包用的。libgomp1OpenMP 运行库PyTorch 的 CPU 算子并行会用到不装的话某些模型在 CPU fallback 时直接段错误。curl给 HEALTHCHECK 用同时方便进容器排障。tini前面说的僵尸进程回收和信号转发。ca-certificates一些模型下载和对象存储访问要校验 HTTPS 证书不装会报证书错误。环境变量里LANGC.UTF-8和PYTHONUNBUFFERED1是很多项目的隐形杀手。前者不设Python 读取带有中文的配置或模型词典时可能直接抛 UnicodeEncodeError后者不设Python 的 stdout 有缓冲容器里看日志会感觉“卡住”实际上只是输出还没刷出来。装依赖的顺序也讲究COPY requirements.txt /app/requirements.txt RUN pip3 install --no-cache-dir --upgrade pip \ pip3 install --no-cache-dir -r /app/requirements.txt RUN pip3 install --no-cache-dir \ torch2.3.1 torchvision0.18.1 \ --index-url https://download.pytorch.org/whl/cu124requirements.txt 里放业务依赖比如 numpy、grpcio、protobuf、opencv-python-headless、transformers但不要把 torch 写进去。这样 torch 单独占一层每次改业务依赖时不会触发 torch 重装反过来升级 torch 版本时也不会把业务依赖全部重装一遍。这是我在镜像构建缓存上最值钱的一条经验。--index-url指定 PyTorch 官方 CUDA 12.4 的 wheel 源确保装到的是 GPU 版而不是 CPU 版。如果直接用默认的 PyPI 源torch 的 wheel 大且版本解析容易回溯构建时间会很难看。最后的用户切换和入口配置RUN groupadd -r appuser useradd -r -g appuser -u 10001 appuser \ chown -R appuser:appuser /app USER appuser ENTRYPOINT [/usr/bin/tini, --] CMD [/app/inference-server]容器内用非 root 用户跑服务是安全底线给固定 UID 10001 是方便编排平台做特权模式映射。ENTRYPOINT固定为 tiniCMD才是真正要启动的 Go 服务这样运行参数可以覆盖但 tini 始终在。3.4 配套文件requirements.txt 与 .dockerignore模板不是只有 Dockerfile配套文件同样重要。requirements.txt 是标准的 Python 依赖列表numpy1.26.4 grpcio1.62.1 protobuf4.25.3 opencv-python-headless4.9.0.80 transformers4.40.1所有版本都要钉死任何或者~都可能在某次构建时拉到一个不兼容的新版本把线上服务搞挂。这也是为什么我一直强调“可复现镜像”的核心是版本全锁定。.dockerignore 的作用容易被低估。它决定哪些文件进入构建上下文直接影响构建速度和镜像内容.git .vscode __pycache__ data/ logs/ *.md build/ tmp/这里要特别提醒别在 .dockerignore 里写*.pt或者*.onnx。我有一次排查了很久才发现模型文件一直没进镜像正是因为忽略了 Dockerfile 的同级目录规则模型模式被 .dockerignore 排除了。模型文件是推理服务的核心资产宁可构建上下文大一点也不要误伤。Go 主进程拉起 worker 的代码也很简单核心思路如下// cmd/server/worker.go cmd : exec.CommandContext(ctx, python3, /app/worker/worker.py) cmd.Env append(os.Environ(), WORKER_ADDRworkerAddr) cmd.Stdout os.Stdout cmd.Stderr os.Stderr if err : cmd.Start(); err ! nil { return fmt.Errorf(start worker: %w, err) } go waitWorker(cmd)waitWorker里要同时处理三种情况正常退出、异常退出、被信号终止。异常退出时根据策略决定是重启 worker 还是直接让主进程退出交给编排层拉起整个容器。4. 构建、验证与镜像瘦身实操4.1 构建命令与 BuildKit 缓存这份模板默认用 BuildKit 构建因为前面用到了--mounttypecache。构建命令很简单# 打开 BuildKit--progressplain 能看到完整日志 DOCKER_BUILDKIT1 docker build --progressplain -t inference-server:1.0.0 . # 如果要多架构比如只出 linux/amd64 docker buildx build --platform linux/amd64 -t inference-server:1.0.0 --load . # 构建完看一眼体积 docker images | grep inference-server第一次构建会比较慢主要时间花在拉 CUDA 基础镜像和 pip 安装 torch 上。后续构建如果依赖没变这几层都会命中缓存通常几十秒就能结束。这也是为什么上面的层顺序那么重要把最容易变化的业务源码 COPY 放在最后把依赖安装放在前面。4.2 本地起容器验证推理链路镜像构建好之后先在有 GPU 的机器上跑一下完整链路docker run --gpus all -p 8080:8080 --shm-size2g \ -e MODEL_DIR/app/models \ inference-server:1.0.0--shm-size2g很多人会漏。PyTorch 的 DataLoader 如果开了多进程dataloader 的共享内存会用到 /dev/shm容器默认只有 64MB模型稍大一点就直接崩溃。这个参数不是可选项是必选项。起来之后先用健康检查接口确认服务活着curl -v http://127.0.0.1:8080/healthz # 再发一个真实推理请求 curl -X POST http://127.0.0.1:8080/v1/infer \ -H Content-Type: application/json \ -d {text:hello world}正常情况会在几秒内返回推理结果。如果卡住先看容器日志里 Go 主进程有没有成功拉起 worker再看 worker 有没有报 CUDA 错误。进容器排障也有固定姿势docker run -it --rm --entrypoint /bin/bash inference-server:1.0.0 python3 -c import torch; print(torch.__version__, torch.cuda.is_available())这一步能快速确认 PyTorch 是否真的能用 GPU很多 CUDA 配置问题在这一步就暴露了。4.3 镜像体积控制实录我把同一套服务用不同方式构建过体积差异非常直观构建方式体积说明单阶段golang 镜像直接装 torch11.2 GBGo 工具链、编译缓存、apt 缓存全保留多阶段仅拷贝 Go 二进制8.6 GB少了 Go 工具链但 apt/pip 缓存还在多阶段 apt clean pip no-cache runtime 基础镜像5.1 GB主体是 CUDA 运行库和 PyTorch换用 pytorch 官方 runtime 基础镜像约 4.5 GB体积略小但可控性下降瘦身的关键动作都写进了上面的模板用 runtime 而不是 devel 基础镜像apt 装完立刻rm -rf /var/lib/apt/lists/*pip 全部--no-cache-dirGo 编译用-s -w绝不 COPY 训练代码、notebook、checkpoint 备份。还有一个容易被忽略的点模型文件如果是从训练环境拷来的里面可能带了很多无关的辅助文件进镜像之前先清理一遍只保留推理用的权重和配置文件。5. 常见问题与排查技巧实录5.1 问题速查表先给一张速查表后面再展开讲我实际踩过的坑现象可能原因处理办法容器启动失败日志报 python3: command not found镜像里没装 python3或 PATH 不对apt 安装 python3.10并 ln -sf 建立链接worker 进程变僵尸服务慢慢失去响应缺少 initPID 1 不回收子进程ENTRYPOINT 换成 tini --模型加载报 libcudnn.so.8: cannot open shared object filetorch 需要的 cuDNN 版本与基础镜像不符对齐 CUDA/cuDNN 版本或换官方 pytorch 镜像推理时报 CUDA out of memory容器没限制显存多进程争抢设置 CUDA_VISIBLE_DEVICES调小 batch sizeDataLoader 多进程必崩容器默认 /dev/shm 只有 64MBdocker run --shm-size2g或把 num_workers 设为 0模型文件不在容器里.dockerignore 误排除了模型后缀检查 .dockerignore确认 COPY 前上下文pip 装 torch 很慢或超时默认源下载慢、版本解析回溯指定官方 wheel 源钉死精确版本日志中文乱码locale 未设置Python 默认编码不对设置 LANGC.UTF-8、PYTHONIOENCODINGutf-85.2 四个典型踩坑过程踩坑一僵尸进程拖垮线上服务。某次线上推理服务跑了两天ps一看一堆defunct状态的 python3 子进程。原因是 Go 主进程启动 worker 后没有及时调用cmd.Wait()而容器 PID 1 直接是 Go 二进制没有子进程回收能力。最后我在模板里加了 tini并且要求waitWorker必须无条件 Wait问题才根治。这件事给我的教训是容器里只要存在两个进程就必须引入 init 系统不能觉得自己“写代码够小心”就能绕过。踩坑二libcudnn 版本错位。基础镜像用的nvidia/cuda:12.4.1-cudnn-runtime但安装 torch 2.3.1 时我的 requirements 配置从默认源拉到了和基础镜像不匹配的 wheel运行时一直报找不到 libcudnn。排查了很久才确认是 cuDNN 版本对不上。从此我固定了两件事torch 版本必须是精确版本号并且显式指定对应的 CUDA wheel 源。版本对齐这种事靠“顺手装”一定会翻车。踩坑三模型文件“消失”。一次联调我反复确认配置都对但模型加载一直 404。最后发现.dockerignore里写了一条*.pt把COPY models/变成了空操作。这个问题隐蔽在本地构建时 Docker 不会报错COPY 一个空目录也是成功的只有进容器看文件才知道真相。现在我的 .dockerignore 里永远不会出现模型相关的排除规则就算模型文件很大我也宁可多等几秒构建。踩坑四/dev/shm 导致的段错误。第一次用 DataLoader 做批量推理时容器里总是Segmentation fault宿主机上同样的代码一切正常。后来查到是容器默认/dev/shm太小dataloader 多进程共享内存时直接崩溃。这个问题的隐蔽之处在于它不一定每次必现模型大、batch 大的时候才触发排查周期很容易被拉长。现在模板的部署文档里我会直接标红--shm-size2g是运行参数不是建议参数。5.3 排障通用套路排障不能靠猜我有几条固定的方法论。第一条进容器手工复现。用docker run -it --rm --entrypoint /bin/bash进入镜像把所有中间结果都手工跑一遍。这一步能快速区分是镜像环境的问题还是代码的问题。第二条分层定位。把 Dockerfile 从上往下逐层注释构建一个“最小可运行镜像”。如果最小镜像能跑问题一定出在被注释的某一层如果最小镜像都不能跑那就是基础镜像或硬件环境的问题。第三条看退出码。容器的退出码是 128signal 形式比如 137 表示 SIGKILL、139 表示段错误。配合 dmesg 里有没有 OOM 记录能快速判断是不是资源问题。启动即退出的容器优先怀疑 CMD 路径、二进制权限和动态链接库。第四条保持最小复现。遇到 PyTorch 相关的问题我会先在一个python:3.11-slim容器里用 CPU 版 torch 跑一段最小代码。CPU 能跑通说明模型代码没问题问题在 GPU 环境CPU 也跑不通那就专心查模型代码本身。6. 模板的扩展思路6.1 CPU / GPU 双模式切换不是所有环境都有 GPU。为了让模板同时适配开发机和生产环境可以用 ARG 控制基础镜像和 torch 安装ARG BASE_IMAGEnvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 FROM ${BASE_IMAGE} AS runtime构建时传参数切模式# GPU 模式 docker build --build-arg BASE_IMAGEnvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 -t inference-server:gpu . # CPU 模式 docker build --build-arg BASE_IMAGEpython:3.11-slim -t inference-server:cpu .CPU 模式下就不要装 cu124 的 torch wheel 了直接装 PyPI 默认的 CPU 版本体积能小一大半。如果嫌 ARG 太糙也可以用两套完整的 Dockerfile 或者 docker-compose 覆盖原理相通重点是把差异收敛到一处而不是复制粘贴整份文件。6.2 模型热更新与对象存储拉取模板默认把模型 COPY 进镜像优点是版本和代码强绑定、回滚容易适合几百 MB 以内的小模型。如果模型大到几个 GB每次发版都重打镜像就不划算了。两个方向可以考虑。一是把模型放在 volume 里容器启动时从对象存储拉取拉取前校验 checksum然后加载。二是做蓝绿目录切换新模型下载到新目录加载成功后原子切换 symlink再 unload 旧模型。这两种方案都需要改 Go 主进程的模型加载逻辑但 Dockerfile 只需要把模型 COPY 改成启动脚本拉取模板的整体骨架不用动。6.3 安全加固建议生产环境里模板还需要往上叠几层加固非 root 用户已经在模板里做了这是底线。容器启动时加--read-only --tmpfs /tmp --tmpfs /dev/shm让根文件系统只读防止运行时写文件被入侵后横向扩散。用--cap-drop ALL --cap-add NET_BIND_SERVICE去掉多余的内核能力。worker 端口不暴露EXPOSE 只留 Go 服务的 8080。定期重建镜像拉取基础镜像的安全更新别让 CVE 烂在线上。我个人体会是安全加固和镜像体积优化一样都是需要持续投入的琐碎事但回报是长期的。模板一旦把这些默认值写对后面每个项目都自动受益。最后分享一点经验。这份模板真正值钱的不是那几行 Dockerfile而是它逼着我把「两个进程怎么协作、依赖怎么隔离、版本怎么对齐、异常怎么恢复」这些问题都想清楚了。我最近接的每个模型项目都是拿这份模板过来改模型路径、改 Python 依赖、改端口半天就能起一个可以联调的推理服务。你在实际用的时候大概率也会踩到一些我没列出来的坑欢迎按同样的思路往模板里补。迭代自己的版本才是最靠谱的维护方式。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑