#!/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