main
mula.liu 2026-09-24 16:49:22 +08:00
parent 871a52ddb0
commit ac6d641f17
17 changed files with 217 additions and 15 deletions

2
.env
View File

@ -10,3 +10,5 @@ AI_MODEL=deepseek-chat
AI_TEMPERATURE=0.2
PORT=8770
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
BASE_IMAGE=docker.1ms.run/library/python:3.12-slim

View File

@ -25,3 +25,11 @@ SESSION_DAYS=30
HOST_PORT=8770
# 周期/周次/入库时间等日期按此时区计算,别留 UTC,否则整体差 8 小时
TZ=Asia/Shanghai
# ---- 国内网络加速(构建镜像用,deploy.sh 首次 up 时自动探测并写回这里)----
# 国内直连 docker.io / pypi.org 基本超时,deploy.sh 会先探一个能真拉下镜像的加速站。
# 手工钉死(推荐内网环境写死,省掉每次探测):
# BASE_IMAGE=docker.1ms.run/library/python:3.12-slim
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 关掉自动探测(源已在上面配好,或机器能直连官方源):CN_MIRROR=0 ./deploy.sh up
# 跳过构建、直接用本地已有镜像(离线机器上 docker load 完之后用):NO_BUILD=1 ./deploy.sh up

View File

@ -5,7 +5,12 @@
#
# 镜像里只有依赖和代码,数据库/附件都在 data/,靠 bind mount 挂进来。
# data/ 已被 .dockerignore 排除,不会把本机 board.db 打进镜像。
FROM python:3.12-slim
#
# 基础镜像。国内直连 docker.io 会在 load metadata 阶段 TLS 超时,所以支持换仓库前缀:
# docker build --build-arg BASE_IMAGE=docker.1ms.run/library/python:3.12-slim .
# ./deploy.sh up 会自动探测可用加速站并把结果写进 .env,正常情况下不用手工传。
ARG BASE_IMAGE=python:3.12-slim
FROM ${BASE_IMAGE}
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
@ -20,9 +25,11 @@ WORKDIR /app
# 依赖单独一层:改业务代码不触发重装依赖
COPY requirements.txt ./
# 国内网络直连 PyPI 容易超时,构建时换源即可:
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up
ARG PIP_INDEX_URL=https://pypi.org/simple
# pip 源:默认清华源(国内直连 pypi.org 基本超时,而且换基础镜像/代理都救不了它,
# 构建容器里的请求只能靠这里换源)。境外机器或内网自建源就覆盖:
# docker build --build-arg PIP_INDEX_URL=https://pypi.org/simple .
# ./deploy.sh up 会自动探测可用源并写进 .env,正常不用手工传。
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
RUN pip install --retries 5 --timeout 60 -i "$PIP_INDEX_URL" -r requirements.txt
# 用 MySQL 部署时放开下面这行(与 requirements.txt 的注释一致)
# RUN pip install "pymysql>=1.1" "cryptography>=42.0"

View File

@ -110,7 +110,7 @@ cd /path/to/unis_manager
|---|---|
| `Dockerfile` | 运行镜像:装依赖 + 拷 `app/ web/ scripts/`;启动时先跑 `init_db.py`(幂等)再起 uvicorn |
| `docker-compose.yml` | 编排:`./data` 挂进容器存数据库、`HOST_PORT` 控对外端口、`restart: unless-stopped` 随 Docker 自启 |
| `deploy.sh` | 命令封装:`up` / `update` / `restart` / `down` / `logs` / `status`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在 |
| `deploy.sh` | 命令封装:`up` / `update` / `restart` / `down` / `logs` / `status` / `backup` / `mirror`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在,并在构建前探测国内可用源 |
| `.dockerignore` | 保证 `data/`、`.env`、`.git` 不会被烤进镜像 |
| 场景 | 命令 |
@ -122,9 +122,42 @@ cd /path/to/unis_manager
| 停止(数据保留) | `./deploy.sh down` |
| 换对外端口 | `.env` 里 `HOST_PORT=8080`,再 `./deploy.sh up` |
| 裸 compose 命令 | `docker compose up -d --build` / `docker compose down` |
| 构建时 pip 下载超时 | `PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up`(也可写进 `.env`) |
| 构建时基础镜像 / pip 下载超时 | `./deploy.sh mirror` 重探,或手工指定 `PIP_INDEX_URL=... BASE_IMAGE=... ./deploy.sh up`(见下「国内网络加速」) |
| 把现有数据带到新机器 | `./deploy.sh backup` → 拷到目标机 `data/board.db` → `./deploy.sh up` |
#### 国内网络加速(docker.io / pypi.org 连不上时)
国内构建会撞两道**互不相干**的墙,得分开治,只治一道必然还剩一道:
| 卡在哪 | 典型报错 | 治法 |
|---|---|---|
| ① 守护进程拉基础镜像 | `failed to resolve source metadata for docker.io/library/python:3.12-slim: ... TLS handshake timeout` | 换仓库前缀 `BASE_IMAGE`(走加速站,或给守护进程配 registry-mirrors) |
| ② 构建容器里装依赖 | `[4/8] RUN pip install ... -i https://pypi.org/simple` 非零退出 | 换 pip 源 `PIP_INDEX_URL`(清华 / 阿里 / 腾讯) |
`./deploy.sh up` 在构建前会自动把两道源都探出来(判据是真的 `docker pull`、是在容器里 `urlopen`,不信宿主机 curl,也不信 `docker manifest inspect` 的假 `denied`),结果写进 `.env`,下次以及裸跑 compose 都复用:
```bash
./deploy.sh up # 自动探测 → 写 .env → 构建 → 等健康检查
./deploy.sh mirror # 只想重探一遍源(不会冲掉 .env 里手工钉死的 BASE_IMAGE)
```
不想用脚本,等价的两条 build-arg:
```bash
docker compose build \
--build-arg BASE_IMAGE=docker.1ms.run/library/python:3.12-slim \
--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
docker compose up -d
```
几个必须知道的点:
- **基础镜像拉成功 ≠ 构建能过**:`docker pull` 只治①,容器里的 `pip install` 照旧撞 pypi.org,必须另外给②换源。
- **shell 里的 `HTTP(S)_PROXY` 对构建没用**:Docker 守护进程和构建容器都不读宿主机的代理变量。要走代理得在 Docker Desktop → Settings → Resources → Proxies 里配 `http://host.docker.internal:7897`(端口换成你本机代理的)。
- **境外机器 / 内网有自建源**:`CN_MIRROR=0 ./deploy.sh up` 关掉探测,源完全由 `.env` 说了算。
- **目标机完全不能上网**:走离线路。`docker save -o unis-board.tar unis-board:latest` → 拷过去 `docker load -i unis-board.tar` → `NO_BUILD=1 ./deploy.sh up`(跳过构建直接用本地镜像)。
- 构建失败时脚本会自己打印一份排查清单(`deploy.sh` 的 `build_hint`),照抄命令即可。
几个坑:
- **数据只落在 `./data`**:重建镜像、`down` 再 `up` 都不动数据;备份依旧是拷走 `data/board.db`。

Binary file not shown.

164
deploy.sh
View File

@ -7,6 +7,8 @@
# ./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")"
@ -32,7 +34,135 @@ fi
HOST_PORT="${HOST_PORT:-8770}"
URL="http://127.0.0.1:${HOST_PORT}"
# ---------- 1. 准备 .env 与 data 目录 ----------
# ---------- 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
@ -42,7 +172,7 @@ prepare() {
mkdir -p data/uploads data/exports
}
# ---------- 2. 等服务就绪 ----------
# ---------- 3. 等服务就绪 ----------
wait_ready() {
echo -n "==> 等待服务就绪 "
for _ in $(seq 1 30); do
@ -58,13 +188,26 @@ wait_ready() {
return 1
}
# ---------- 3. 分发命令 ----------
# ---------- 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
"${COMPOSE[@]}" up -d --build
configure_mirror
up_or_exit || exit 1
wait_ready
echo " API 文档:$URL/api/docs"
if [ "$first_run" = "1" ]; then
@ -73,7 +216,10 @@ case "${1:-up}" in
;;
update)
prepare
"${COMPOSE[@]}" up -d --build --force-recreate
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)
@ -104,6 +250,12 @@ 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:"
@ -112,7 +264,7 @@ PYEOF
echo "==> 访问地址:$URL"
;;
*)
sed -n '3,9p' "$0"
sed -n '3,11p' "$0"
exit 1
;;
esac

View File

@ -13,9 +13,9 @@ services:
build:
context: .
args:
# 传给 Dockerfile 的 pip 源,默认官方;国内网络可在 .env 里设
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.org/simple}"
# 基础镜像仓库前缀 + pip 源,都由 deploy.sh 探测后写进 .env(也可手工在 .env 里钉死)
BASE_IMAGE: "${BASE_IMAGE:-python:3.12-slim}"
PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}"
image: unis-board:latest
container_name: unis-board
restart: unless-stopped