unis_manager/deploy.sh

258 lines
12 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env bash
# 部门关键任务追踪看板 · Docker 部署脚本
# 用法: ./deploy.sh up 构建镜像并后台启动(默认命令)
# ./deploy.sh update 改完代码后重建镜像并替换容器
# ./deploy.sh restart 重启容器(不换镜像)
# ./deploy.sh down 停止并删除容器,数据留在 ./data
# ./deploy.sh logs 跟踪日志(Ctrl+C 退出)
# ./deploy.sh backup 导出数据库快照(部署到新机器时带走数据)
# ./deploy.sh status 容器状态 + 健康检查 + 访问地址
# ./deploy.sh mirror 重新探测可用的 pip 源(基础镜像只认本地已有的,不去试加速站)
# 环境变量:CN_MIRROR=0 跳过加速探测;NO_BUILD=1 不构建、直接用本地镜像
set -euo pipefail
cd "$(dirname "$0")"
CONTAINER=unis-board
# ---------- 0. 前置检查 ----------
command -v docker >/dev/null 2>&1 || { echo "!! 没有 docker 命令,请先安装 Docker / Docker Desktop" >&2; exit 1; }
docker info >/dev/null 2>&1 || { echo "!! docker 守护进程没起来(Docker Desktop 是否没运行?)" >&2; exit 1; }
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE=(docker-compose)
else
echo "!! 没找到 docker compose,也没找到 docker-compose" >&2
exit 1
fi
# 对外端口:命令行环境变量 > .env 里的 HOST_PORT > 8770
if [ -z "${HOST_PORT:-}" ] && [ -f .env ]; then
HOST_PORT="$(sed -n 's/^HOST_PORT=//p' .env | tail -1)"
fi
HOST_PORT="${HOST_PORT:-8770}"
URL="http://127.0.0.1:${HOST_PORT}"
# ---------- 0.5 通用小工具(供后面读写 .env、探测、加超时用)----------
env_get() { { [ -f .env ] && sed -n "s/^$1=//p" .env | tail -1; } || true; }
# with_timeout 秒 命令…(macOS 没有 timeout 命令,自己搭一个,防止探测卡死)
with_timeout() {
local t="$1" rc=0; shift
# 整个动作丢进子 shell 并把它的 stderr 关掉:否则后台任务被 kill 时
# bash 会往终端插一行「已终止」,看着像脚本自己挂了。
( "$@" >/dev/null 2>&1 &
pid=$!
( sleep "$t"; kill "$pid" 2>/dev/null ) >/dev/null 2>&1 &
wpid=$!
wait "$pid" || rc=$?
kill "$wpid" 2>/dev/null || true
wait "$wpid" 2>/dev/null || true
exit "$rc" ) 2>/dev/null
}
# ---------- 1. 国内加速:基础镜像 + pip 源 ----------
# 国内直连 docker.io / pypi.org 会 TLS 超时(报错形如 failed to resolve source
# metadata for docker.io/library/python),所以构建前先把「真能拉到东西的源」探出来,
# 并写回 .env —— 之后裸跑 docker compose up -d --build 用的也是同一套配置。
BASE_IMAGE="${BASE_IMAGE:-$(env_get BASE_IMAGE)}"
BASE_IMAGE="${BASE_IMAGE:-python:3.12-slim}" # 本地有哪个 python 镜像就用哪个;内网自建仓库才需要在这里钉死
PIP_URLS="${PIP_URLS:-https://pypi.tuna.tsinghua.edu.cn/simple https://mirrors.aliyun.com/pypi/simple https://mirrors.cloud.tencent.com/pypi/simple https://pypi.org/simple}"
configure_mirror() {
local force="${1:-}" url tag
PIP_INDEX_URL="${PIP_INDEX_URL:-$(env_get PIP_INDEX_URL)}"
if [ "$force" = "--force" ]; then
PIP_INDEX_URL=""
# 手工钉死过的基础镜像不冲掉,只重探 pip 源
# .env 里钉死过的基础镜像不冲掉,--force 只重探 pip 源
if [ -z "$(env_get BASE_IMAGE)" ]; then BASE_IMAGE="python:3.12-slim"; fi
fi
BASE_IMAGE="${BASE_IMAGE:-python:3.12-slim}"
if [ "${CN_MIRROR:-1}" = "0" ] || [ "${NO_BUILD:-0}" = "1" ]; then
export BASE_IMAGE PIP_INDEX_URL
echo "==> 跳过源探测(CN_MIRROR=0 或 NO_BUILD=1):${BASE_IMAGE:-官方 python} / ${PIP_INDEX_URL:-官方 pypi}"
return 0
fi
# 基础镜像只用「本地有没有」做判据,不去逐个试加速站(试一圈要几分钟,而且多半全挂)。
# 判据也不看宿主机 curl:宿主机走 HTTP_PROXY,守护进程和构建容器都不认 shell 的代理变量。
tag="${BASE_IMAGE##*/}" # 纯 tag,例 python:3.12-slim
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
echo "==> 基础镜像本地已有,不联网:$BASE_IMAGE"
elif docker image inspect "$tag" >/dev/null 2>&1; then
# 本地已有同名 tag 就直接用。镜像内容是同一份,仓库前缀只是「当初从哪儿拉的」,
# 换台机器还抱着旧前缀去联网拉,反而会把一次本来能成的构建搞挂。
echo "==> 本地已有 ${tag},直接用它(忽略 .env 里的仓库前缀)"
BASE_IMAGE="$tag"
elif with_timeout 25 docker pull --quiet "$BASE_IMAGE"; then
echo "==> 基础镜像已从当前配置的源拉取:$BASE_IMAGE"
else
echo "!! 本地没有 ${BASE_IMAGE},25 秒内也没拉到;这一步过不去,构建必然失败,先解决基础镜像:" >&2
echo " 1) 本地有别的 python 镜像 → 指定它:BASE_IMAGE=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -m1 python || echo python:3.12-slim) ./deploy.sh up" >&2
echo " 2) 手工拉一次(守护进程配的 registry-mirrors 在这一步生效):docker pull $tag" >&2
echo " 3) 机器不能上网 → 从别处拷:docker save -o py.tar $tag && scp py.tar 目标机 && docker load -i py.tar" >&2
echo " 4) 已经构建过本项目镜像 → 跳过构建直接起:NO_BUILD=1 ./deploy.sh up" >&2
return 1
fi
# pip 源:装依赖是在构建容器里发生的,容器不继承宿主机的代理,
# 所以拿刚定下的基础镜像(此时必然已在本地)真跑一次访问探测,比宿主机 curl 靠谱。
if [ -n "${PIP_INDEX_URL:-}" ]; then
echo "==> pip 源(沿用已有配置):$PIP_INDEX_URL"
else
for url in $PIP_URLS; do
if with_timeout 40 docker run --rm --entrypoint python "$BASE_IMAGE" \
-c "import sys,urllib.request;urllib.request.urlopen(sys.argv[1],timeout=8)" "$url/"; then
PIP_INDEX_URL="$url"; break
fi
done
if [ -z "${PIP_INDEX_URL:-}" ]; then
PIP_INDEX_URL="https://pypi.org/simple"
echo "!! 构建容器里访问不到任何国内 pip 源,先用官方源;超时就手工指定:" >&2
echo " PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple ./deploy.sh up" >&2
fi
fi
# 只 export 给本次构建,故意不写回 .env —— .env 在 git 里,把这台机器探到的
# 加速站带到另一台机器上,正是那边拉不到镜像的原因。
# 想固定就在 .env 里手工写 BASE_IMAGE= / PIP_INDEX_URL= 两行(裸 compose 命令也吃这两个值)。
export BASE_IMAGE PIP_INDEX_URL
echo "==> 本次构建用:基础镜像 $BASE_IMAGE | pip 源 $PIP_INDEX_URL"
}
# 构建失败时给一份可操作的排查清单(国内环境九成是源的问题)
build_hint() {
if [ -n "${HTTPS_PROXY:-}${HTTP_PROXY:-}${https_proxy:-}${http_proxy:-}" ]; then
echo "!! 注意:当前 shell 配了 HTTP(S)_PROXY,但 Docker 守护进程不认 shell 环境变量。" >&2
echo " 要用代理出网,得在 Docker Desktop → Settings → Resources → Proxies 里配" >&2
echo " http://host.docker.internal:7897(端口换成你本机代理的),否则构建期出不了网。" >&2
fi
cat >&2 <<'HINT'
!! 构建失败。国内环境最常见的两个原因:
1) 基础镜像拉不到(failed to resolve source metadata / TLS handshake timeout)
docker images | grep python # 看看本地有哪个可用
BASE_IMAGE=python:3.12-slim ./deploy.sh up # 显式指定本地已有的镜像
docker pull python:3.12-slim # 或手工拉一次
也可以给 Docker 守护进程配 registry-mirrors(服务器改 /etc/docker/daemon.json,
桌面端 Settings → Docker Engine),保存并重启 Docker 后再 ./deploy.sh up
2) pip 装依赖超时
PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple ./deploy.sh up
完全不能上网的机器:在能上网的机器上构建后拷镜像过去
docker save -o unis-board.tar unis-board:latest && scp unis-board.tar host:/srv/unis_manager/
ssh host 'docker load -i /srv/unis_manager/unis-board.tar'
然后在目标机 NO_BUILD=1 ./deploy.sh up
HINT
}
# ---------- 2. 准备 .env 与 data 目录 ----------
prepare() {
if [ ! -f .env ]; then
cp .env.example .env
echo "==> 已从 .env.example 生成 .env(AI Key、管理员账号密码请按需修改,改完 restart 生效)"
fi
# 先建好目录,免得 Docker 用 root 建出来盖掉宿主机权限
mkdir -p data/uploads data/exports
}
# ---------- 3. 等服务就绪 ----------
wait_ready() {
echo -n "==> 等待服务就绪 "
for _ in $(seq 1 30); do
state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER" 2>/dev/null || echo missing)"
case "$state" in
healthy) echo; echo "==> 已就绪:$URL"; return 0 ;;
unhealthy) echo; echo "!! 容器不健康,执行 ./deploy.sh logs 看原因" >&2; return 1 ;;
esac
echo -n "."
sleep 2
done
echo; echo "!! 等待超时(当前状态:${state}),执行 ./deploy.sh logs 看原因" >&2
return 1
}
# ---------- 4. 构建并启动(NO_BUILD=1 时直接用本地镜像)----------
up_or_exit() {
if [ "${NO_BUILD:-0}" = "1" ]; then
echo "==> NO_BUILD=1:跳过构建,直接用本地镜像 unis-board:latest"
"${COMPOSE[@]}" up -d
elif "${COMPOSE[@]}" up -d --build; then
return 0
else
build_hint; return 1
fi
}
# ---------- 5. 分发命令 ----------
case "${1:-up}" in
up|start)
first_run=0
[ -f data/board.db ] || first_run=1
prepare
configure_mirror
up_or_exit || exit 1
wait_ready
echo " API 文档:$URL/api/docs"
if [ "$first_run" = "1" ]; then
echo " 首次启动:管理员账号取自 .env 的 ADMIN_USERNAME/ADMIN_PASSWORD(默认 admin / admin123,请登录后立刻改密码)"
fi
;;
update)
prepare
configure_mirror
if [ "${NO_BUILD:-0}" = "1" ]; then "${COMPOSE[@]}" up -d --force-recreate
else "${COMPOSE[@]}" up -d --build --force-recreate || { build_hint; exit 1; }
fi
wait_ready
;;
restart)
"${COMPOSE[@]}" restart
wait_ready
;;
down|stop)
"${COMPOSE[@]}" down
echo "==> 已停止(数据仍在 ./data,要备份请复制 data/board.db)"
;;
logs)
exec "${COMPOSE[@]}" logs -f --tail=100
;;
backup)
# 用 SQLite 备份 API 取一致性快照:服务正在写也不会拷到半截页
[ -f data/board.db ] || { echo "!! 没找到 data/board.db(若已换 MySQL,请改用 mysqldump)" >&2; exit 1; }
PYBIN="$(command -v python3 || command -v python || true)"
[ -n "$PYBIN" ] || { echo "!! 备份需要 python3(只拷库,不用装项目依赖)" >&2; exit 1; }
mkdir -p data/backups
OUT="data/backups/board-$(date +%Y%m%d_%H%M%S).db"
"$PYBIN" - "$OUT" <<'PYEOF'
import os, sqlite3, sys
src = sqlite3.connect("data/board.db")
dst = sqlite3.connect(sys.argv[1])
with dst:
src.backup(dst)
print(f"[ok] 已导出 {sys.argv[1]}({os.path.getsize(sys.argv[1]) // 1024} KB)")
PYEOF
echo "==> 部署到新机器:把这个文件放到目标机的 data/board.db,再执行 ./deploy.sh up"
;;
mirror)
prepare
configure_mirror --force
echo "==> 想让以后的构建固定用这组源,把下面两行写进 .env 即可(不写也行):"
echo " BASE_IMAGE=${BASE_IMAGE:-python:3.12-slim}"
if [ -n "${PIP_INDEX_URL:-}" ]; then echo " PIP_INDEX_URL=$PIP_INDEX_URL"; fi
;;
status)
"${COMPOSE[@]}" ps
echo -n "==> /health:"
curl -s --max-time 3 "$URL/health" 2>/dev/null || echo " 无响应(容器未启动?./deploy.sh up)"
echo
echo "==> 访问地址:$URL"
;;
*)
sed -n '3,11p' "$0"
exit 1
;;
esac