webman 应用的容器运行时:PHP CLI Alpine + S6 Overlay v3 托管 webman 常驻进程,
应用挂载在 /app,首次启动自动 composer install,默认监听 8787。
⚠️ 重要提示:服务器内存小于 16G 时,不要在服务器上用 pnpm 安装依赖或构建前端。madong 的
template/约 30 万个文件,含 11 个 pnpm workspace 子项目:
操作 内存峰值 pnpm install(11 个 workspace)0.8 ~ 1.2 GB vite build(admin 前端)1.5 ~ 3 GB 在 4G / 8G 机器上执行,会被内核 OOM 杀掉,表现为构建莫名中断、容器自动退出, 严重时整机失去响应、只能硬重启(面板打不开、SSH 卡死)。
推荐做法:在本地或大内存机器上装好依赖、构建完成,再打包上传。
- 打包必须用
tar/rsync(保留符号链接)- 不要用 zip,不要从 Windows 直接复制目录 —— pnpm 的
node_modules由大量符号链接构成,跨平台复制后会全部失效,pnpm 只能推倒重装- 详细步骤见 第 11 节:小内存服务器
主要特性:
| 特性 | 说明 |
|---|---|
| 挂载仓库根 | /app/backend(后端)+ /app/template(前端)同时可读;仓库根直接就是 webman 应用时,自动回落为 /app |
| 内置 pnpm | 镜像自带 Node + pnpm,后端可在运行期推算相对路径直接构建 template 前端工作区 |
| 进程托管 | S6 Overlay v3 守护 webman 常驻进程,WEBMAN_CMD 可自定义启动命令 |
| 版本即 tag | 镜像 tag 就是 PHP 版本号(如 8.2-cli-alpine),打 tag 由 CI 自动构建推送 |
| 离线可构建 | s6-overlay 与 PIE 随仓库提供,构建机无需访问 GitHub |
docker-webman/
├── Dockerfile # 多阶段:node 官方镜像提供 Node/pnpm 二进制 + PHP CLI Alpine + S6 v3
├── entrypoint.sh # 校验挂载 / composer install / 权限修正 / 交给 S6
├── config/
│ ├── php.ini # 默认 PHP 配置(容器内 zzz-webman.ini)
│ └── s6-rc.d/webman/run # webman longrun 服务定义
├── extension/
│ └── install.sh # 扩展安装(PIE)+ 安装后校验门(离线可放 pie.phar)
├── overlay/ # 离线构建时放 s6-overlay-*.tar.xz
├── .github/workflows/build.yml # 打 tag 即构建并推送镜像
├── .gitattributes / .gitignore
├── LICENSE
└── README.md
方式 A:拉取已发布的镜像(CI 已构建好,推荐)
# 公开包直接拉
docker pull ghcr.io/madong-code/docker-webman:8.2-cli-alpine
# 私有包先登录(PAT 需勾选 read:packages)
echo "<YOUR_GITHUB_PAT>" | docker login ghcr.io -u madong-code --password-stdin
docker pull ghcr.io/madong-code/docker-webman:8.2-cli-alpine国内服务器拉
ghcr.io经常不通,处理办法见 9.1。
方式 B:本地构建
# 一键脚本(推荐)
sh build.sh # 默认 8.2
sh build.sh 8.3 # 指定 PHP 版本
sh build.sh 8.3 myreg.example.com/docker-webman # 指定镜像仓库前缀
# 或直接用 docker build(脚本已兼容 8.3 / 8.4 / 8.5,按需构建)
docker build -t docker-webman:8.2-cli-alpine .
docker build --build-arg PHP_VERSION=8.3 -t docker-webman:8.3-cli-alpine .完整扩展清单(含 swoole / intl / xlswriter)单次构建约 8~15 分钟。
build.sh会先检查 docker 是否可用、离线资源是否齐全,构建完成后自动跑一次 镜像自检(PHP 版本 / 关键扩展 / Node / pnpm / s6 服务定义)。
镜像名按你实际使用的替换:拉取的是 ghcr.io/madong-code/docker-webman:8.2-cli-alpine,
本地构建的是 docker-webman:8.2-cli-alpine。
docker run -d --name webman \
-p 8787:8787 \
-v /path/to/your-app:/app \
ghcr.io/madong-code/docker-webman:8.2-cli-alpineWindows(Docker Desktop):
docker run -d --name webman -p 8787:8787 -v D:/www/your-app:/app ghcr.io/madong-code/docker-webman:8.2-cli-alpine容器监听什么端口由应用自身的配置决定,不由镜像决定。8787 只是 webman 官方默认值,
本文的 docker run 示例沿用它;换应用(如 madong)就必须换数字,否则映射配得再对也访问不通。
先启动一次,看监听列:
docker exec webman php start.php status常见情况:
| 应用 | 容器内监听 | 端口映射写 |
|---|---|---|
| webman 裸装默认 | http://0.0.0.0:8787 |
-p 8787:8787 |
| madong | http://0.0.0.0:8500 |
-p 8500:8500 |
| madong(前端 websocket 推送) | websocket://0.0.0.0:3501 |
-p 3501:3501 |
端口来源是后端 config/server.php 里的 listen;容器侧不需任何改动,
改后端配置后按同样的数字映射即可。
进入容器 / 查看日志:
docker exec -it webman sh
docker logs -f webmandocker exec webman php -v # PHP 版本
docker exec webman php -m # 已加载扩展
docker exec webman php start.php status
curl -i http://127.0.0.1:8787/ # 应用响应推荐在仓库根下分后端与前端两个目录:
your-app/
├── backend/ -> 容器 /app/backend (webman 应用,APP_ROOT)
└── template/ -> 容器 /app/template (pnpm 前端工作区)
- 挂载仓库根,而不是只挂
backend/:后端常驻进程才能读到前端构建产物 (资源发布、代码生成器模板、SSR 等场景)。 - 仓库根直接就是 webman 应用(无
backend/)时,entrypoint.sh会检测到/app/start.php并把APP_ROOT回落为/app,与「直接挂 webman 应用目录」的用法一致。
可覆盖的环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
APP_MOUNT |
/app |
容器内挂载点 |
APP_ROOT |
/app/backend |
webman 工作目录 |
BACKEND_DIR / TEMPLATE_DIR |
backend / template |
仓库根下的子目录名 |
WEBMAN_CMD |
php start.php start |
启动命令(改端口/进程数常改这里) |
backend/ 中 runtime 与 storage 会被自动置为可写(0777)。
镜像内置 Node 与 pnpm(/usr/local/bin/pnpm,位于标准 PATH),宿主无需安装 Node。
典型用法是后端进程自己推算相对路径去调用 pnpm,而不是从宿主机进容器手工执行。
后端工作目录是 /app/backend,前端工作区是它的兄弟目录 ../template,
所以后端只要按相对关系推算就能拿到前端目录:
// 方式一:相对推算(不依赖任何配置)
$backend = base_path(); // /app/backend
$template = dirname($backend) . '/template'; // /app/template
// 方式二:读容器导出的环境变量(entrypoint / s6 均已注入)
$template = getenv('APP_TEMPLATE_DIR') ?: dirname(base_path()) . '/template';
// 执行 pnpm —— 镜像内已是全局命令,子进程直接调用即可
exec("cd {$template} && pnpm install --frozen-lockfile && pnpm run build 2>&1", $output, $code);这里的相对关系之所以成立,正是因为挂载的是仓库根:
/app/backend <- webman 后端(cwd)
/app/template <- pnpm 工作区(../template)
前端语言栈相关的注意点:
-
pnpm / node 都在镜像里,容器重建后依旧可用,宿主不需要装 Node
-
node_modules、构建产物都落在挂载目录(宿主机)里,不会随容器销毁而丢失 -
pnpm store 在容器内
/root/.local/share/pnpm/store,容器重建后会重新下载依赖; 需要复用时可挂个卷:docker run -d --name webman -p 8787:8787 \ -v /path/to/your-app:/app \ -v pnpm-store:/root/.local/share/pnpm/store \ docker-webman:8.2-cli-alpine
在运行中的容器里构建:
docker exec -it webman sh -lc "cd /app/template && pnpm install && pnpm build"用一次性容器构建(不启动服务):
docker run --rm \
-v /path/to/your-app:/app \
-w /app/template \
docker-webman:8.2-cli-alpine \
sh -lc "pnpm install --frozen-lockfile && pnpm build"只构建某个子包(pnpm 工作区):
docker run --rm -v /path/to/your-app:/app -w /app/template \
docker-webman:8.2-cli-alpine sh -lc "pnpm --filter @your-scope/admin build"说明:
- pnpm 与 npm 的 registry 默认指向
registry.npmmirror.com,构建时可用--build-arg NPM_MIRROR=https://registry.npmjs.org改回官方源。 - pnpm 版本由
--build-arg PNPM_VERSION=10决定。 - Node 版本由
--build-arg NODE_VERSION固定(默认 22),二进制取自官方node:<版本>-alpine镜像而非 apk。apk 的 nodejs 会随 Alpine 仓库漂移 (曾装到 Node 24,导致cross-env的 ESM 入口报ERR_MODULE_NOT_FOUND,前端构建失败)。 - 构建产物位于
/app/template/**/dist,后端进程(cwd/app/backend)可直接读取。
镜像 tag 就是 PHP 版本号,首个版本从 8.2 开始。
git tag 8.2-cli-alpine
git push origin 8.2-cli-alpine.github/workflows/build.yml 会在 tag 推送时:
- 从 tag 解析
PHP_VERSION(取第一个-之前的部分,8.2-cli-alpine→8.2) docker build --build-arg PHP_VERSION=<解析结果>- 跑一次容器自检(
php -v/php -m/node -v/pnpm -v) - 推送镜像到 GHCR:
ghcr.io/madong-code/docker-webman:<tag>与:latest
一个 Dockerfile 覆盖所有版本,不需要为每个 PHP 版本维护分支或单独文件。
版本只体现在「构建参数 PHP_VERSION」和「tag」两处:
Dockerfile 里 ARG PHP_VERSION=8.2 是默认值(不带参数就是 8.2),
FROM php:${PHP_VERSION}-cli-alpine 决定基础镜像,extension/install.sh 按版本自动门控差异。
| PHP 版本 | 基础镜像 | tag | 构建参数 | 备注 |
|---|---|---|---|---|
| 8.2 | php:8.2-cli-alpine |
8.2-cli-alpine |
--build-arg PHP_VERSION=8.2 |
首发版本,也是 Dockerfile 默认值 |
| 8.3 | php:8.3-cli-alpine |
8.3-cli-alpine |
--build-arg PHP_VERSION=8.3 |
无需改任何代码 |
| 8.4 | php:8.4-cli-alpine |
8.4-cli-alpine |
--build-arg PHP_VERSION=8.4 |
无需改任何代码 |
| 8.5 | php:8.5-cli-alpine |
8.5-cli-alpine |
--build-arg PHP_VERSION=8.5 |
OPcache 已内置,脚本自动跳过编译 |
Node / pnpm 不受 PHP 版本影响:Node 由
--build-arg NODE_VERSION(默认 22)统一固定, pnpm 版本由--build-arg PNPM_VERSION决定,pnpm 10 在 Node 18+ 均可用。
| tag 形态 | 含义 | 适用场景 |
|---|---|---|
8.2-cli-alpine |
跟随官方 php:8.2-cli-alpine 的最新补丁 |
日常发布、滚动更新 |
8.2.28-cli-alpine |
锁定到具体补丁版本,构建结果可复现 | 生产固定版本、排查回归 |
两种都支持:workflow 取第一个 - 之前的部分作为 PHP_VERSION,
所以 8.2.28-cli-alpine 会去构建 php:8.2.28-cli-alpine。
git tag 8.3-cli-alpine
git push origin 8.3-cli-alpine # CI 自动构建 + 自检 + 推 GHCR只有当「该版本行为与已有版本不同」时才需要动 extension/install.sh,
差异用版本门控写在脚本里即可(8.5 的 OPcache 就是这么处理的):
if isPhpVersionGreaterOrEqual 8 5; then
# 已静态编译进核心:跳过编译,只校验存在
fi# 单个版本
docker build --build-arg PHP_VERSION=8.2 -t docker-webman:8.2-cli-alpine .
# 一次构建全部版本
for v in 8.2 8.3 8.4 8.5; do
docker build --build-arg PHP_VERSION="$v" -t "docker-webman:${v}-cli-alpine" .
done
# 推私有仓库
docker tag docker-webman:8.3-cli-alpine your-registry/docker-webman:8.3-cli-alpine
docker push your-registry/docker-webman:8.3-cli-alpine运行时"设置版本"就是换镜像 tag:
docker run -d --name webman -p 8787:8787 \
-v /path/to/your-app:/app \
docker-webman:8.3-cli-alpine # ← 换这里即换 PHP 版本Dockerfile 里不写死补丁号,精确版本由 tag 决定。 理由是:
- 写死就等于"一个版本一份文件",失去单 Dockerfile 覆盖全版本的意义
- 默认滚动小版本(
php:8.2-cli-alpine)能让重建自动吃到安全补丁 —— webman 是常驻服务,安全补丁比"逐字节可复现"更重要 - 需要可复现时,把补丁号写进 tag 即可(
8.2.28-cli-alpine), CI 会自动用它当PHP_VERSION,不用改任何文件
| 场景 | PHP_VERSION 取值 |
效果 |
|---|---|---|
| 日常开发 / 本地构建 | 8.2 |
跟随 8.2 线最新补丁,重建即升级 |
| 发布正式镜像 | tag 带的精确值,如 8.2.28 |
构建结果可复现、可追溯 |
| 安全补丁发布 | 新 tag,如 8.2.29-cli-alpine |
老 tag 不动,新 tag 上线 |
构建期依赖的固定情况(按"是否需要人工跟进"决定):
| 依赖 | 当前设置 | 说明 |
|---|---|---|
| s6-overlay | 3.2.0.2 精确 |
很少变动,固定更稳 |
| PIE | 1.4.9 精确 |
同上 |
| Node | 22(大版本) |
由 --build-arg NODE_VERSION 固定,二进制取自官方 node 镜像;apk 的 nodejs 已不再使用 |
| pnpm | 10(大版本) |
默认跟随 10.x;发布时可用 --build-arg PNPM_VERSION=10.15.0 精确固定 |
| apk 包(各类 -dev) | 不固定 | 随基础镜像的 Alpine 版本走,只有锁 base 镜像 digest 才能完全复现 |
真要做到 100% 可复现,可以把 digest 也带上:
docker build --build-arg PHP_VERSION=8.2.28 -t ... .配合FROM php:8.2.28-cli-alpine@sha256:...(把 digest 写进 Dockerfile 或用构建参数传入)。 一般项目不需要,除非有合规/审计要求。
内置清单(extension/install.sh,可用 --build-arg EXTENSIONS= 覆盖):
bcmath, bz2, calendar, event, exif, gd, intl, mysqli, opcache,
pcntl, pdo_mysql, redis, sockets, swoole, xlswriter, zip
外加官方镜像自带的 pdo / posix / curl / mbstring / openssl。
裁剪清单(逗号包裹且首尾各带一个逗号):
docker build \
--build-arg EXTENSIONS=",bcmath,gd,opcache,pcntl,pdo_mysql,redis,sockets,zip,event," \
-t docker-webman:8.2-slim .- PHP 8.2 / 8.3 / 8.4:OPcache 走
docker-php-ext-install opcache - PHP 8.5:OPcache 已静态编译进核心,脚本只做存在性校验(版本门控已内置)
扩展通过 PIE(PECL 的官方替代品)安装,所需资源已随仓库提供,见第 7 节。
构建时唯一需要访问 GitHub 的两个依赖,已随仓库提供:
overlay/s6-overlay-noarch.tar.xz (约 7 KB)
overlay/s6-overlay-x86_64.tar.xz (约 642 KB)
extension/pie.phar (约 6.6 MB)
Dockerfile 检测到文件存在就直接使用,因此没有外网的构建机(含国内 CI、内网服务器)也能构建。
升级这两个依赖时(同时改 Dockerfile 里的 S6_OVERLAY_VERSION / PIE_VERSION),
重新下载覆盖即可(GitHub 直连受限时可在地址前加加速前缀,如 https://ghproxy.net/):
# s6-overlay(按 CPU 架构取文件:x86_64 / aarch64)
curl -fL -o overlay/s6-overlay-noarch.tar.xz \
https://github.com/just-containers/s6-overlay/releases/download/v3.2.0.2/s6-overlay-noarch.tar.xz
curl -fL -o overlay/s6-overlay-x86_64.tar.xz \
https://github.com/just-containers/s6-overlay/releases/download/v3.2.0.2/s6-overlay-x86_64.tar.xz
# pie.phar(PIE 本体)
curl -fL -o extension/pie.phar \
https://github.com/php/pie/releases/download/1.4.9/pie.phar扩展源码(redis / swoole / event / xlswriter)由 PIE 从 Packagist 拉取,国内一般可达; 完全离线时把
EXTENSIONS裁到纯内置扩展。
apk / composer / npm 源默认已指向国内镜像,可用
--build-arg APK_MIRROR= / COMPOSER_MIRROR= / NPM_MIRROR= 置空改回官方源。
容器内默认配置是 /usr/local/etc/php/conf.d/zzz-webman.ini。
业务自定义配置挂到排序更靠后的文件即可覆盖:
docker run -d --name webman \
-p 8787:8787 \
-v /path/to/your-app:/app \
-v /path/to/custom.ini:/usr/local/etc/php/conf.d/zzz-custom.ini:ro \
docker-webman:8.2-cli-alpine本镜像不带 compose 编排,在 1Panel 上直接「创建容器」即可。需要 MySQL / Redis 时, 用面板应用商店安装,再把容器接入同一网络。
容器 → 镜像 → 拉取:ghcr.io/madong-code/docker-webman:8.2-cli-alpine
(私有包需填 GitHub 用户名 + 带 read:packages 的 PAT)
国内服务器拉 ghcr.io 通常会失败(1Panel 的「镜像加速」只对 Docker Hub 生效),三种处理:
| 方案 | 做法 |
|---|---|
| 改为公开包 | GitHub → 你的 Packages → 该包 → Package settings → Change visibility → Public |
| 让 CI 同时推 ACR | 仓库加 3 个 Secrets:ACR_IMAGE_PREFIX / ACR_USERNAME / ACR_PASSWORD,重跑 workflow;服务器改拉 ACR |
| 人工中转 | 在能访问 ghcr 的机器 docker pull → docker tag → docker push 到 ACR,服务器再拉 ACR |
容器 → 容器 → 创建容器:
| 字段 | 填什么 |
|---|---|
| 名称 | webman |
| 镜像 | ghcr.io/madong-code/docker-webman:8.2-cli-alpine |
| 端口映射 | 按②实际监听填:madong 是 宿主 8500 → 容器 8500(前端推送再加 3501);webman 裸装才用 8787 |
| 挂载 | 宿主 /opt/app/madong → 容器 /app,挂仓库根(含 backend/ 与 template/,不要只挂 backend) |
| 环境变量 | TZ=Asia/Shanghai |
| 重启策略 | 除非停止(unless-stopped) |
| 网络 | 1panel-network(要与面板装的 MySQL / Redis 互通就选它) |
容器日志正常应出现:
[entrypoint] PHP : 8.2.x
[entrypoint] pnpm : 10.x
[entrypoint] APP_ROOT : /app/backend
[s6][webman] cwd = /app/backend
[s6][webman] template = /app/template
[s6][webman] command = php start.php start
面板终端里:
docker exec webman php start.php status # 先看 listen 列,确认端口(madong = 8500)
curl -i http://127.0.0.1:8500/ # 端口按实际监听填
docker exec webman php -m | tr '\n' ' '| 现象 | 原因 / 处理 |
|---|---|
FATAL: 目录不存在 /app/backend |
挂载点挂成了 backend/ 子目录,要挂仓库根 |
| 容器起来但端口不通 | 后端 config/server.php 的监听端口与映射不一致 |
composer install 失败 |
容器内出网问题;镜像里 composer 已指向阿里云 |
| 前端 404 / 空白 | template/ 未构建:docker exec webman sh -lc "cd /app/template && pnpm install && pnpm build" |
| 需要 MySQL / Redis | 面板应用商店安装,后端 backend/.env 的 DB_HOST / REDIS_HOST 填面板里的容器名,并把容器加入同一网络 |
1Panel 相关的报错见 9.4。
| 现象 | 处理 |
|---|---|
启动报 FATAL: 目录不存在 /app/backend |
挂载目录里没有 backend/,且根下没有 start.php;确认 -v 路径是否正确 |
| 端口不通 | webman 监听端口与 -p 映射不一致;改 WEBMAN_CMD 或 config/server.php |
composer install 慢或失败 |
设置 --build-arg COMPOSER_MIRROR=(默认已用阿里云) |
| pnpm 安装慢 | 默认走 npmmirror;企业内网可改 NPM_MIRROR |
ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY |
node_modules 是跨环境装的(宿主机 / 旧容器 / 换过 Node 版本),pnpm 检测到 store 配置不一致要删除重装,但 proc_open 没有 TTY 无法确认。镜像已设 pnpm config set confirm-modules-purge false;旧镜像可临时 docker exec -it <容器> sh -lc "cd /app/template && CI=true pnpm install"。预防:依赖只在容器内装一次,别在宿主机装 |
前端构建报 ERR_MODULE_NOT_FOUND |
镜像内 Node 版本过新(旧镜像用 apk 装到过 Node 24)。重建镜像即修复(已固定 Node 22);应急可用 docker run --rm -v /path/to/your-app:/app -w /app/template/admin node:22-alpine sh -lc "npm i -g pnpm@10 && pnpm i --frozen-lockfile && pnpm run build" |
| 改代码不生效 | webman 常驻进程需 reload:docker exec webman php start.php reload |
脚本报 #!/bin/sh^M: not found |
换行符被改成 CRLF,仓库已带 .gitattributes(强制 LF);必要时 dos2unix |
| 扩展编译失败(swoole / event / xlswriter) | 从 EXTENSIONS 移除后重建;PHP 8.5 上优先只保留稳定扩展 |
常用命令:
docker exec webman php start.php reload # 平滑重载(改完代码最常用)
docker exec webman php start.php restart # 重启
docker exec webman php start.php stop # 停止
docker exec webman php start.php status # 进程与连接状态madong 的 template/ 有四万多个文件,vite 构建 admin 时内存峰值可达 1.5~3 GB。
若同机还跑着 MySQL / Redis / 面板,很容易触发内核 OOM ——
表现是容器莫名退出、面板卡死,甚至整机失去响应。
free -h
dmesg -T | grep -iE "out of memory|killed process" | tail -20
docker inspect <容器名> --format '{{.State.OOMKilled}}' # true 即被 OOM 杀① 加 swap(最有效的兜底,强烈建议)
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab # 开机自动挂载
free -h② 给 Node 限制堆内存
容器环境变量:
| 变量 | 建议值 | 说明 |
|---|---|---|
NODE_OPTIONS |
--max-old-space-size=1536 |
4G 机器给 1.5G;2G 机器给 768,再不够就说明不该在本机构建 |
CI=true |
true |
可选,让 pnpm / vite 走非交互模式 |
--max-old-space-size只约束 V8 堆,esbuild(Go 编写)不受它限制, 所以还要靠 ① 的 swap 和 ③ 的容器内存限制兜底。
③ 限制容器内存,别让它吃光整机
docker run ... --memory=2g --memory-swap=3g ...容器内 OOM 只会杀掉构建进程(docker inspect 显示 OOMKilled=true),
比整机被拖死好得多 —— 至少面板还活着。
4G / 8G 机器上装依赖、构建大型前端本就勉强。正确姿势是在本地或 CI 完成这两步,只把结果传上去。
内存门槛参考:
| 服务器内存 | 建议 |
|---|---|
| < 8G | 绝对不要在服务器上跑 pnpm,依赖与构建全部外部完成 |
| 8 ~ 16G | 不推荐在服务器构建;非要跑需配 swap + .npmrc 降并发 + 容器内存上限 |
| ≥ 16G | 可以正常使用后台的一键「安装依赖 / 构建前端」 |
做法一:依赖也一起打包上传(服务器完全不跑 pnpm)
# ① 本地安装依赖(Linux / macOS;Windows 请用 WSL 或在容器里装)
docker run --rm -v /path/to/madong:/app -w /app/template \
docker-webman:8.2-cli-alpine \
sh -lc "pnpm install --frozen-lockfile"
# ② 用 tar 打包(关键:保留符号链接。千万不要用 zip)
tar -czf node-modules.tar.gz \
template/node_modules \
template/admin/node_modules \
template/web/node_modules
# ③ 上传到服务器并解包
scp node-modules.tar.gz user@yourserver:/opt/docker-data/playground/madong-test/
ssh user@yourserver 'cd /opt/docker-data/playground/madong-test && tar -xzf node-modules.tar.gz && rm node-modules.tar.gz'为什么必须是 tar:pnpm 的
node_modules由大量符号链接构成(指向.pnpm虚拟存储)。zip打包、或用 Windows 资源管理器直接复制目录,都会把符号链接变成普通文件甚至丢失, 结果 pnpm 判定"依赖是坏的",又要推倒重装 —— 白传一场。 同理,不要上传在 Windows 下装出来的node_modules。
做法二:只传构建产物(服务器连 node_modules 都不需要)
# 本地构建
docker run --rm -v /path/to/madong:/app -w /app/template/admin \
docker-webman:8.2-cli-alpine \
sh -lc "pnpm install --frozen-lockfile && pnpm run build:integrated"
# 只上传产物(通常只有几 MB)
scp -r template/admin/dist user@yourserver:/opt/docker-data/playground/madong-test/template/admin/dist之后在后台点「构建Admin前端」,就只剩"迁移部署"这一步(把 dist 复制到 backend/public/admin),几乎不吃内存。
做法三:CI 里构建(团队协作推荐)
把安装 / 构建放进 CI,产物归档成 artifact 或直接 rsync 到服务器, 容器只负责运行 —— 这也正是镜像里保留 pnpm 的原因:能力留着,但不必在低配机上用。