unis_manager/deploy.sh

271 lines
12 KiB
Bash
Executable File
Raw 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 源)并写进 .env
# 环境变量: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; }
# 覆盖式写回 .env(没有这个键就追加)
env_set() {
awk -v k="$1" -v v="$2" 'BEGIN{hit=0} index($0,k"=")==1{print k"="v; hit=1; next} {print} END{if(!hit)print k"="v}' .env > .env.tmp && mv .env.tmp .env
}
# 站点可达?能握手且不是 5xx 就算数(401/403 只是不允许匿名列表)
http_ok() {
local code
code="$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time "${2:-8}" "$1" 2>/dev/null || true)"
case "$code" in 000|"") return 1 ;; 5*) return 1 ;; *) return 0 ;; esac
}
# with_timeout 秒 命令…(macOS 没有 timeout 命令,自己搭一个,防止探测卡死)
with_timeout() {
local t="$1" rc=0 pid wpid; shift
"$@" >/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
return "$rc"
}
# ---------- 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}" # 想换加速站就写进 .env,例:docker.1ms.run/library/python:3.12-slim
BASE_TAG="${BASE_TAG:-${BASE_IMAGE##*/}}" # 加速站上的固定路径是 library/<BASE_TAG>
MIRROR_HOSTS="${MIRROR_HOSTS:-docker.1ms.run docker.m.daocloud.io docker.1panel.live docker.xuanyuan.me}"
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:-}" host url ref tag pip_picked=0 base_picked=0
PIP_INDEX_URL="${PIP_INDEX_URL:-$(env_get PIP_INDEX_URL)}"
if [ "$force" = "--force" ]; then
PIP_INDEX_URL=""
# 手工钉死过的基础镜像不冲掉,只重探 pip 源
if [ -z "$(env_get BASE_IMAGE)" ]; then BASE_IMAGE="python:3.12-slim"; fi # .env 里钉死过的基础镜像不冲掉
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
# 基础镜像的判据是「Docker 守护进程能不能拿到它」,不是宿主机 curl 能不能通:
# 宿主机 curl 走 HTTP_PROXY,守护进程和构建容器都不认 shell 的代理变量。
tag="${BASE_IMAGE##*/}"
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
echo "==> 基础镜像本地已有,不联网:$BASE_IMAGE"
elif with_timeout 25 docker pull --quiet "$BASE_IMAGE"; then
echo "==> 基础镜像可从当前配置源拉取:$BASE_IMAGE"
else
echo " $BASE_IMAGE 守护进程侧拉不到,开始找加速站"
for host in $MIRROR_HOSTS; do
ref="$host/library/$tag"
echo -n "==> 测试加速站 $host "
if with_timeout 120 docker pull --quiet "$ref"; then
BASE_IMAGE="$ref"; base_picked=1; echo " 可用"; break
else
echo " 不可用"
fi
done
if [ "$base_picked" = 0 ]; then
echo "!! 所有加速站都拉不到 $tag;可以先在服务器上手拉,再 ./deploy.sh up(本地有就不会再联网):" >&2
echo " docker pull docker.1ms.run/library/$tag && docker tag docker.1ms.run/library/$tag $BASE_IMAGE" >&2
fi
fi
# pip 源:装依赖是在构建容器里发生的,容器不继承 shell 的代理,
# 所以拿刚选好的基础镜像真跑一次访问探测,比在宿主机上 curl 靠谱。
if [ -n "${PIP_INDEX_URL:-}" ]; then
echo "==> pip 源(沿用已有配置):$PIP_INDEX_URL"
else
docker image inspect "$BASE_IMAGE" >/dev/null 2>&1 \
|| with_timeout 120 docker pull --quiet "$BASE_IMAGE" \
|| BASE_IMAGE="$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -m1 '^python:3' || true)"
for url in $PIP_URLS; do
if [ -n "${BASE_IMAGE:-}" ] && 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"; pip_picked=1; 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
# 探出来的结果落盘,下次(以及裸 compose 命令)直接复用
if [ "$pip_picked" = 1 ]; then env_set PIP_INDEX_URL "$PIP_INDEX_URL"; fi
if [ "$base_picked" = 1 ]; then env_set BASE_IMAGE "$BASE_IMAGE"; fi
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)
./deploy.sh mirror # 重跑自动探测
BASE_IMAGE=docker.1ms.run/library/python:3.12-slim ./deploy.sh up # 手工指定加速站
也可以给 Docker 守护进程配 registry-mirrors(Docker Desktop → Settings → Docker Engine):
{ "registry-mirrors": ["https://docker.1ms.run"] }
保存并重启 Docker 后,把 .env 里的 BASE_IMAGE 那行删掉再 ./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 的配置:"
grep -E '^(BASE_IMAGE|PIP_INDEX_URL)=' .env 2>/dev/null || echo " (无:说明用的是默认源或 CN_MIRROR=0)"
;;
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