258 lines
12 KiB
Bash
Executable File
258 lines
12 KiB
Bash
Executable File
#!/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
|