生产部署:prod.sh 直接运行,package-docker.sh 打 Docker 离线包(镜像 + 一键部署脚本 + 配置 + 说明)

- 服务端可以托管前端构建产物(AGENTLAB_WEB_DIST):页面路由回退到 index.html,/api 下不存在的路径照旧是 JSON 404,
  挡住路径穿越;index.html 不缓存,assets/ 带哈希的文件长缓存;没设时行为完全不变
- scripts/prod.sh:不用 Docker 的生产运行。按需构建前端,单进程、不热重载,默认只听本机,--host 开放到局域网并提示没有登录
- Dockerfile(多阶段构建,Linux 镜像):前端在构建机架构上构建,运行阶段用 python:3.12-slim,依赖装进独立的 venv,
  以非 root 用户(uid 10001)运行,数据在 /data,带健康检查;.dockerignore 排除 .env、data/、各种库文件,镜像里实测没有密钥和数据库
- scripts/package-docker.sh:构建指定平台的镜像(默认按本机架构,可选 linux/amd64),打成
  release/agentlab-<版本>-linux-<架构>.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS
- deploy/deploy.sh:目标机上只依赖 Docker,离线可用。install / start / stop / restart / status / logs / upgrade / uninstall;
  检查架构和配置,自动导入镜像,替换容器时保留旧容器以便回退,数据永远保留,日志轮转
- 修正 bubblewrap 的可用性判断:以前只看有没有装 bwrap,容器里和 Ubuntu 24.04 默认配置下装了却建不了命名空间,
  每次执行代码都失败;现在真起一次试,不能用就退回下一个后端,并写明原因
- README 增加「生产部署」一节,部署细节见 deploy/README.md
This commit is contained in:
ThearchyHelios
2026-09-30 09:48:16 +08:00
parent 03c18c4d78
commit 6af4e396b9
16 changed files with 1742 additions and 7 deletions
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# 打 Docker 离线包:构建 Linux 镜像,连同一键部署脚本、配置模板和部署说明打成一个 tar,
# 拷到只装了 Docker Engine、不能联网的机器上就能部署。部署说明见 deploy/README.md。
set -euo pipefail
usage() {
cat <<'USAGE'
用法:./scripts/package-docker.sh [--platform linux/arm64|linux/amd64] [--version <版本>] [--out <目录>] [--gzip]
--platform <平台> 目标机的平台。默认按本机架构取 linux/<arch>。64 位系统的 ARM 单板机是 linux/arm64,
普通 PC 和服务器是 linux/amd64。和本机架构不同时 docker 用模拟构建,会慢很多。
--version <版本> 镜像标签和包名里的版本。默认 git describe --tags --always --dirty,拿不到就用短哈希加日期。
--out <目录> 产物放哪里,默认仓库下的 release/。
--gzip 另外再产出一份 .tar.gz。docker 用 containerd 镜像存储时镜像层本身已经压缩过,.tar.gz 小不了多少。
-h, --help 显示这段说明。
产物:<out>/agentlab-<版本>-linux-<arch>.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS。
构建需要联网(拉基础镜像、装依赖);部署不需要。镜像只留在本机,不会推到任何仓库。
USAGE
}
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLATFORM=""
VERSION=""
OUT="$ROOT/release"
GZIP=""
need_value() { [ $# -ge 2 ] && [ -n "$2" ] || { echo "$1 后面要跟一个值"; echo; usage; exit 2; }; }
while [ $# -gt 0 ]; do
case "$1" in
--platform) need_value "$@"; PLATFORM="$2"; shift ;;
--platform=*) PLATFORM="${1#--platform=}" ;;
--version) need_value "$@"; VERSION="$2"; shift ;;
--version=*) VERSION="${1#--version=}" ;;
--out) need_value "$@"; OUT="$2"; shift ;;
--out=*) OUT="${1#--out=}" ;;
--gzip) GZIP=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "认不出的参数:$1"; echo; usage; exit 2 ;;
esac
shift
done
# ---- 平台 ----
if [ -z "$PLATFORM" ]; then
case "$(uname -m)" in
arm64|aarch64) PLATFORM="linux/arm64" ;;
x86_64|amd64) PLATFORM="linux/amd64" ;;
*) echo "认不出本机架构 $(uname -m),用 --platform 指定 linux/arm64 或 linux/amd64"; exit 2 ;;
esac
fi
case "$PLATFORM" in
linux/arm64|linux/amd64) ARCH="${PLATFORM#linux/}" ;;
*) echo "只支持 linux/arm64 和 linux/amd64:$PLATFORM"; exit 2 ;;
esac
# ---- 版本 ----
# GIT_OPTIONAL_LOCKS=0:只读版本库,不顺手刷新索引文件
if [ -z "$VERSION" ]; then
VERSION="$(GIT_OPTIONAL_LOCKS=0 git -C "$ROOT" describe --tags --always --dirty 2>/dev/null || true)"
fi
if [ -z "$VERSION" ]; then
HASH="$(GIT_OPTIONAL_LOCKS=0 git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo nogit)"
VERSION="$HASH-$(date +%Y%m%d)"
fi
# docker 标签只认字母、数字和 _ . -,最长 128,不能以 . 或 - 开头
VERSION="$(printf '%s' "$VERSION" | tr -c 'A-Za-z0-9_.-' '-')"
VERSION="${VERSION#[.-]}"
VERSION="${VERSION:0:120}"
[ -n "$VERSION" ] || { echo "版本号是空的,用 --version 指定"; exit 2; }
IMAGE="agentlab:$VERSION"
NAME="agentlab-$VERSION-linux-$ARCH"
# ---- 工具 ----
command -v docker >/dev/null || { echo "缺少 docker"; exit 1; }
docker version --format '{{.Server.Version}}' >/dev/null 2>&1 || { echo "连不上 docker 服务,先把 Docker 启动起来"; exit 1; }
docker buildx version >/dev/null 2>&1 || { echo "缺少 docker buildx(Docker Desktop 自带;Linux 上装 docker-buildx-plugin)"; exit 1; }
for f in deploy/deploy.sh deploy/agentlab.env deploy/README.md Dockerfile .dockerignore; do
[ -f "$ROOT/$f" ] || { echo "缺少 $f"; exit 1; }
done
if command -v sha256sum >/dev/null 2>&1; then SHA=(sha256sum); else SHA=(shasum -a 256); fi
mkdir -p "$OUT"
OUT="$(cd "$OUT" && pwd)"
STAGE="$OUT/$NAME"
TAR="$OUT/$NAME.tar"
echo "==> 版本 $VERSION · 平台 $PLATFORM · 产物 $TAR"
# ---- 1. 构建 ----
# 不带 provenance / SBOM:它们会让导出的镜像多出几份附加清单,老版本的 docker load 认不全
echo "==> 构建镜像 $IMAGE"
docker buildx build \
--platform "$PLATFORM" \
--provenance=false --sbom=false \
--build-arg VERSION="$VERSION" \
--load \
-t "$IMAGE" \
"$ROOT"
GOT="$(docker image inspect -f '{{.Os}}/{{.Architecture}}' "$IMAGE")"
[ "$GOT" = "$PLATFORM" ] || { echo "构建出来的镜像是 ${GOT},不是 $PLATFORM"; exit 1; }
# ---- 2. 暂存目录 ----
rm -rf "$STAGE"
mkdir -p "$STAGE"
trap 'rm -rf "$STAGE"' EXIT
echo "==> 导出镜像(docker save)"
if docker save --help 2>/dev/null | grep -q -- '--platform'; then
docker save --platform "$PLATFORM" -o "$STAGE/image.tar" "$IMAGE"
else
docker save -o "$STAGE/image.tar" "$IMAGE"
fi
fill() {
# 模板里的占位符换成这一次的版本和架构
sed -e "s|__AGENTLAB_VERSION__|$VERSION|g" -e "s|__AGENTLAB_ARCH__|$ARCH|g" "$1" > "$2"
}
fill "$ROOT/deploy/deploy.sh" "$STAGE/deploy.sh"
fill "$ROOT/deploy/README.md" "$STAGE/README.md"
cp "$ROOT/deploy/agentlab.env" "$STAGE/agentlab.env"
chmod 755 "$STAGE/deploy.sh"
chmod 644 "$STAGE/README.md" "$STAGE/agentlab.env" "$STAGE/image.tar"
if grep -q '__AGENTLAB_' "$STAGE/deploy.sh" "$STAGE/README.md"; then
echo "模板里还有没填上的占位符"; exit 1
fi
(cd "$STAGE" && "${SHA[@]}" image.tar deploy.sh agentlab.env README.md > SHA256SUMS)
chmod 644 "$STAGE/SHA256SUMS"
# ---- 3. 打包 ----
# macOS 的 tar(bsdtar)默认会塞进 ._ 开头的资源文件和扩展属性,Linux 上解包会报一堆警告,关掉;
# 属主统一写成 0,免得目标机上用 sudo 解包时冒出一个不存在的 uid
echo "==> 打包 $TAR"
rm -f "$TAR" "$TAR.gz"
if tar --version 2>/dev/null | grep -q 'GNU tar'; then
tar --owner=0 --group=0 --numeric-owner -cf "$TAR" -C "$OUT" "$NAME"
else
COPYFILE_DISABLE=1 tar --uid 0 --gid 0 --uname root --gname root --no-mac-metadata --no-xattrs -cf "$TAR" -C "$OUT" "$NAME"
fi
if [ -n "$GZIP" ]; then
echo "==> 压缩 $TAR.gz"
if command -v pigz >/dev/null 2>&1; then pigz -c "$TAR" > "$TAR.gz"; else gzip -c "$TAR" > "$TAR.gz"; fi
fi
size() { du -h "$1" | awk '{print $1}'; }
echo
echo "完成:"
echo " $TAR ($(size "$TAR"))"
[ -z "$GZIP" ] || echo " $TAR.gz ($(size "$TAR.gz"))"
echo " 镜像 $IMAGE 留在本机(没有推送到任何仓库)"
echo
echo "下一步:"
echo " 1. 把 $NAME.tar 拷到目标机(scp、U 盘都行)"
echo " 2. 在目标机上:"
echo " tar -xf $NAME.tar && cd $NAME"
echo " nano agentlab.env # 端口、监听地址、数据目录、模型 Key"
echo " ./deploy.sh"
echo " 详细说明见包里的 README.md(仓库里是 deploy/README.md)"
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# 不用 Docker 的生产运行:构建前端,由后端一并托管页面,单进程、不热重载。Ctrl-C 退出。
set -euo pipefail
usage() {
cat <<'USAGE'
用法:./scripts/prod.sh [--host] [--port <端口>] [--build | --no-build]
--host 监听 0.0.0.0(同一网络里的设备都能打开)。默认只听 127.0.0.1,只有本机能打开。
注意:AgentLab 没有登录,同一网络里打得开页面的人都能用它——跑工作流、花你配的
模型额度、读数据源、执行沙箱代码。只在信得过的网络里开,并配好防火墙。
--port <端口> 监听端口。也可以用环境变量 AGENTLAB_PORT,默认 8000。
--build 不管前端产物新旧,强制重新构建。
--no-build 跳过构建,直接用现有的 frontend/dist。
-h, --help 显示这段说明。
默认只在 frontend/dist 不存在、或者比前端源码旧的时候才构建。
数据目录照后端配置:默认是仓库下的 data/,用环境变量 AGENTLAB_DATA_DIR 改。
只能单进程运行:运行管理器在进程内、数据库是 SQLite,多个 worker 会互相踩。
USAGE
}
# 参数先认完再干活,认不出的直接报错,免得写错的参数被悄悄忽略
HOST="127.0.0.1"
PORT="${AGENTLAB_PORT:-8000}"
BUILD="auto"
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST="0.0.0.0" ;;
--port)
[ $# -ge 2 ] || { echo "--port 后面要跟端口号"; echo; usage; exit 2; }
PORT="$2"; shift ;;
--port=*) PORT="${1#--port=}" ;;
--build) BUILD="always" ;;
--no-build) BUILD="never" ;;
-h|--help) usage; exit 0 ;;
*) echo "认不出的参数:$1"; echo; usage; exit 2 ;;
esac
shift
done
case "$PORT" in
''|*[!0-9]*) echo "端口必须是数字:$PORT"; exit 2 ;;
esac
if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "端口超出范围:$PORT"; exit 2
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST="$ROOT/frontend/dist"
CONDA_ENV="${AGENTLAB_CONDA_ENV:-agentlab}"
# ---- 后端解释器:和 dev.sh 一样找 conda 环境,没有就按 environment.yml 建 ----
command -v conda >/dev/null || { echo "缺少 conda:https://conda-forge.org/miniforge/"; exit 1; }
if ! conda env list | awk '{print $1}' | grep -qx "$CONDA_ENV"; then
echo "==> 创建 conda 环境 $CONDA_ENV"
conda env create -f "$ROOT/environment.yml"
fi
PY_BIN="$(conda run -n "$CONDA_ENV" python -c 'import sys; print(sys.executable)' 2>/dev/null)"
[ -x "$PY_BIN" ] || { echo "conda 环境 $CONDA_ENV 不可用,试试:conda env create -f environment.yml"; exit 1; }
echo "==> 后端解释器 $PY_BIN"
# ---- 前端构建 ----
# dist 比源码旧:源码、入口页、依赖清单、构建配置里有任何一个比 dist/index.html 新
dist_stale() {
[ -f "$DIST/index.html" ] || return 0
local newer
newer="$(find "$ROOT/frontend/src" "$ROOT/frontend/public" \
"$ROOT/frontend/index.html" "$ROOT/frontend/package.json" "$ROOT/frontend/pnpm-lock.yaml" \
"$ROOT/frontend/vite.config.ts" "$ROOT/frontend/tsconfig.json" \
-newer "$DIST/index.html" -print 2>/dev/null | head -n 1)"
[ -n "$newer" ]
}
need_build=""
case "$BUILD" in
always) need_build=1 ;;
never)
[ -f "$DIST/index.html" ] || { echo "用了 --no-build,但 $DIST 下没有 index.html。先去掉 --no-build 构建一次"; exit 1; }
;;
auto)
if [ ! -f "$DIST/index.html" ]; then
echo "==> 前端还没构建过"; need_build=1
elif dist_stale; then
echo "==> 前端源码比构建产物新"; need_build=1
else
echo "==> 前端构建产物是最新的,跳过构建(--build 可强制重建)"
fi
;;
esac
if [ -n "$need_build" ]; then
command -v pnpm >/dev/null || { echo "缺少 pnpm:npm i -g pnpm"; exit 1; }
[ -d "$ROOT/frontend/node_modules" ] || (cd "$ROOT/frontend" && pnpm install --frozen-lockfile)
echo "==> 构建前端(pnpm build)"
(cd "$ROOT/frontend" && pnpm build)
[ -f "$DIST/index.html" ] || { echo "构建完了却没有 $DIST/index.html,看上面的构建输出"; exit 1; }
fi
# ---- 端口 ----
# 生产服务要固定地址,端口被占就报错,不像 dev.sh 那样自动往后挪
port_taken() {
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
else
# 没有 lsof(精简的 Linux)就试着绑一下:绑得上说明没人占
if "$PY_BIN" -c 'import socket,sys; s=socket.socket(); s.bind((sys.argv[1], int(sys.argv[2])))' "$HOST" "$1" 2>/dev/null; then
return 1
fi
return 0
fi
}
if port_taken "$PORT"; then
echo "端口 $PORT 已被占用,换一个(--port 或 AGENTLAB_PORT)或停掉占用方"
command -v lsof >/dev/null 2>&1 && { lsof -nP -iTCP:"$PORT" -sTCP:LISTEN | tail -n +2 | head -3; }
exit 1
fi
export AGENTLAB_WEB_DIST="$DIST"
DATA_SHOW="${AGENTLAB_DATA_DIR:-$ROOT/data}"
echo "==> 数据目录 $DATA_SHOW"
if [ "$HOST" = "0.0.0.0" ]; then
# 本机在局域网里的地址:macOS 取 en0 / en1,Linux 取 hostname -I 的第一个
LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || true)"
URLS="http://127.0.0.1:$PORT · http://${LAN_IP:-<本机 IP>}:$PORT"
echo "==> 监听所有网卡。注意:AgentLab 没有登录,同一网络里打得开页面的人都能用它——"
echo " 跑工作流、花你配的模型额度、读数据源、执行沙箱代码。只在信得过的网络里开。"
else
URLS="http://127.0.0.1:$PORT"
fi
# 就绪后再报一次地址:启动日志一长,前面打的那行容易被冲掉。
# 确认答话的是 agentlab 自己(/api/health 里带 "agentlab"),不是碰巧占着端口的别的服务
if command -v curl >/dev/null 2>&1; then
(
# $$ 在子 shell 里仍是主脚本的进程号,exec 之后就是 uvicorn:它退了就别再等
for _ in $(seq 1 120); do
kill -0 $$ 2>/dev/null || exit 0
if curl -sf "http://127.0.0.1:$PORT/api/health" 2>/dev/null | grep -q '"agentlab"'; then
echo
echo " AgentLab 已就绪:$URLS"
echo " 按 Ctrl-C 停止"
echo
exit 0
fi
sleep 0.5
done
) &
fi
echo "==> 启动 $URLS"
cd "$ROOT/backend"
# exec:让 uvicorn 直接收 Ctrl-C / SIGTERM,一个信号就走完整的优雅退出(收尾运行、关沙箱)。
# 要是包一层 shell 再转发,终端的 Ctrl-C 会让 uvicorn 收到两次信号,第二次直接强退、跳过收尾
exec "$PY_BIN" -m uvicorn app.main:app --host "$HOST" --port "$PORT"