feat(deploy): 新增一键部署脚本 deploy/nas/deploy.py

- 源码打包 → SFTP 上传 → 解包覆盖 → 同步 favicon 种子 → 构建镜像 → 启动容器 → 健康检查
- 跳过禁止项目:.git / venv / data / .env / *.db / __pycache__ / 日志 / 图标缓存种子等
- 保留远程 .env 与 deploy/nas/data 数据卷(属主 uid=1001)
- 参数:
  --no-build     只更新代码重启容器(不改依赖时最快)
  --no-cache     强制不用 Docker 层缓存
  --pull         强制拉取基础镜像(默认不拉:NAS registry 常 401,本地缓存已够用)
  --icons-only   只同步 favicon 种子
  --check        只看远端状态
  --pack-only    只打包不上传(用于预览包内容)
- 临时包放 /tmp 目录避免项目根污染,失败也不影响部署结果
- 输出末尾打印旧/新镜像 ID 对比,方便判断缓存命中情况
This commit is contained in:
bwstudio
2026-09-07 21:11:07 +08:00
parent 4ece453f00
commit fff78ef336
+422
View File
@@ -0,0 +1,422 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Pear Admin Flask -> NAS (192.168.1.102) 一键构建部署脚本
做什么:
1. 本地把源码打成 tar.gz(排除 .git / venv / data / .env / *.db 等)
2. SFTP 上传到 NAS
3. 远端 sudo 解包覆盖源码(保留 .env 与 deploy/nas/data 数据卷)
4. 可选:同步本地已抓取的 favicon 种子到 deploy/nas/data/icon
(NAS 容器无外网,抓不到 favicon,必须靠种子才有真实图标)
5. sudo docker compose build + up -d
6. 健康检查 /healthz 与 /site/
前置条件:
- 本机装了 paramiko
C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko
- 能 ping 通 NAS
用法:
# 完整流程(推荐):打包 + 传代码 + 传图标种子 + 构建 + 重启 + 体检
set SSH_PASS=你的密码
python deploy/nas/deploy.py
# 只改了模板/静态文件,不需要重装依赖和编译镜像时,跳过 build 直接重启
python deploy/nas/deploy.py --no-build
# 只同步图标种子,不动代码
python deploy/nas/deploy.py --icons-only
# 只看远端当前状态(容器、镜像、健康),不改任何东西
python deploy/nas/deploy.py --check
# 只打包不上传(看看包里有什么)
python deploy/nas/deploy.py --pack-only
可用环境变量覆盖默认值:
SSH_PASS SSH/sudo 密码(不设则交互输入)
NAS_HOST 默认 192.168.1.102
NAS_USER 默认 bwadmin
NAS_DIR 默认 /home/bwadmin/pear-admin-flask
NAS_PORT 默认 22
注意:
- NAS 上 bwadmin 没有 docker socket 权限,所有 docker 命令都走 sudo
- deploy/nas/data 是数据卷,属主 uid 1001= 容器内 appuser),
脚本会刻意跳过对它的 chown,否则容器将失去写权限
"""
import argparse
import getpass
import io
import os
import stat
import sys
import tarfile
import tempfile
import time
try:
import paramiko
except ImportError:
sys.exit("缺少 paramiko,请先安装:\n"
" C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko")
# ---------------------------------------------------------------- 配置
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HOST = os.environ.get("NAS_HOST", "192.168.1.102")
USER = os.environ.get("NAS_USER", "bwadmin")
PORT = int(os.environ.get("NAS_PORT", "22"))
REMOTE_DIR = os.environ.get("NAS_DIR", "/home/bwadmin/pear-admin-flask")
REMOTE_TMP = "/tmp"
COMPOSE_FILE = "deploy/nas/docker-compose.yaml"
# 打包时排除的目录(相对项目根)
EXCLUDE_DIRS = {
".git", "venv", ".venv", "env", "__pycache__", ".workbuddy",
"instance", "flask_session", "logs", "output", "node_modules",
".idea", ".vscode", ".pytest_cache", ".mypy_cache", "build", "dist",
"data", # 运行数据:SQLite / 图标缓存 / 上传,部署靠挂载,不打进包
}
# 打包时排除的文件名
EXCLUDE_FILES = {".env", ".env.local", ".DS_Store", "pear.db", "padmin.db"}
# 打包时排除的后缀
EXCLUDE_SUFFIX = (".pyc", ".pyo", ".log", ".db", ".db-wal", ".db-shm",
".bak", ".tar.gz", ".swp", ".pid")
ICON_SRC = os.path.join(PROJECT_ROOT, "data", "icon") # 本地已抓取的 favicon
ICON_DEST = "deploy/nas/data/icon" # 远端数据卷里的图标目录
ICON_OWNER = "1001:1001" # 容器内 appuser 的 uid:gid
# ---------------------------------------------------------------- 工具
def log(msg=""):
print(msg, flush=True)
def step(msg):
log("\n" + "=" * 62)
log(" " + msg)
log("=" * 62)
def should_skip(rel_path, is_dir):
"""判断某个相对路径是否应被打进源码包"""
parts = rel_path.replace("\\", "/").split("/")
for p in parts:
if p in EXCLUDE_DIRS:
return True
if not is_dir:
name = parts[-1]
if name in EXCLUDE_FILES:
return True
if name.endswith(EXCLUDE_SUFFIX):
return True
return False
def pack_source(out_path):
"""把项目源码打成 tar.gz,返回 (文件路径, 文件数, 字节数)"""
n = 0
with tarfile.open(out_path, "w:gz", compresslevel=6) as tar:
for root, dirs, files in os.walk(PROJECT_ROOT):
rel_root = os.path.relpath(root, PROJECT_ROOT)
rel_root = "" if rel_root == "." else rel_root.replace("\\", "/")
# 就地过滤目录,避免递归进 venv/.git 等大目录
dirs[:] = [d for d in dirs
if not should_skip((rel_root + "/" + d) if rel_root else d, True)]
# 打包脚本自身产生的临时包
dirs[:] = [d for d in dirs if d != os.path.basename(out_path)]
for f in files:
rel = (rel_root + "/" + f) if rel_root else f
if should_skip(rel, False):
continue
full = os.path.join(root, f)
if os.path.abspath(full) == os.path.abspath(out_path):
continue
try:
tar.add(full, arcname=rel, recursive=False)
n += 1
except (OSError, PermissionError) as e:
log(" [跳过] %s (%s)" % (rel, e))
return out_path, n, os.path.getsize(out_path)
def pack_icons(out_path):
"""把本地 data/icon/ 下的真实 favicon 打成 tar.gz(扁平结构,不带目录层级)"""
if not os.path.isdir(ICON_SRC):
return None
files = [f for f in os.listdir(ICON_SRC)
if os.path.isfile(os.path.join(ICON_SRC, f))]
# 只带真实图片,字母头像 SVG 让容器自己生成即可
files = [f for f in files if not f.lower().endswith(".svg")]
if not files:
return None
with tarfile.open(out_path, "w:gz") as tar:
for f in files:
tar.add(os.path.join(ICON_SRC, f), arcname=f, recursive=False)
return out_path
def human(n):
for unit in ("B", "KB", "MB"):
if n < 1024 or unit == "MB":
return "%.1f %s" % (n, unit)
n /= 1024.0
# ---------------------------------------------------------------- SSH
class Nas:
def __init__(self, host, user, pwd, port=22):
self.pwd = pwd
self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.ssh.connect(host, port=port, username=user, password=pwd,
timeout=20, allow_agent=False, look_for_keys=False)
self.sftp = self.ssh.open_sftp()
def _mask(self, text):
"""万一 sudo 把密码回显出来,别打在日志里"""
return text.replace(self.pwd, "******") if self.pwd else text
def run(self, cmd, timeout=900, show=True, sudo=False):
"""执行远程命令。sudo=True 时通过 -S 从 stdin 喂密码。"""
full = ("sudo -S -p '' bash -lc %s" % _shq(cmd)) if sudo else ("bash -lc " + _shq(cmd))
chan = self.ssh.get_transport().open_session()
chan.get_pty()
chan.settimeout(timeout)
chan.exec_command(full)
if sudo:
chan.sendall(self.pwd + "\n")
buf = []
deadline = time.time() + timeout
while True:
if chan.recv_ready():
data = chan.recv(65536)
if not data:
break
text = data.decode("utf-8", "replace")
buf.append(text)
if show:
sys.stdout.write(self._mask(text))
sys.stdout.flush()
elif chan.recv_stderr_ready():
data = chan.recv_stderr(65536)
if not data:
break
text = data.decode("utf-8", "replace")
buf.append(text)
if show:
sys.stdout.write(self._mask(text))
sys.stdout.flush()
elif chan.exit_status_ready():
# 退出前把残留输出读干净
while chan.recv_ready():
buf.append(chan.recv(65536).decode("utf-8", "replace"))
break
else:
if time.time() > deadline:
chan.close()
raise TimeoutError("远程命令超时(%ds):%s" % (timeout, cmd))
time.sleep(0.4)
code = chan.recv_exit_status()
chan.close()
return code, "".join(buf)
def upload(self, local, remote):
self.sftp.put(local, remote)
return remote
def close(self):
try:
self.sftp.close()
except Exception:
pass
self.ssh.close()
def _shq(s):
"""把命令包成 bash -lc '...' 能安全接受的单一参数"""
return "'" + s.replace("'", "'\"'\"'") + "'"
# ---------------------------------------------------------------- 流程
def do_check(nas):
step("远端状态检查")
nas.run("docker ps --filter name=pear-admin-nas --format "
"'table {{.Names}} {{.Status}} {{.Ports}}'", sudo=True)
nas.run("docker images pear-admin-nas --format 'table {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}'",
sudo=True)
code, _ = nas.run("curl -fsS -o /dev/null -w 'healthz=%{http_code}\\n' http://127.0.0.1:5000/healthz; "
"curl -fsS -o /dev/null -w 'site=%{http_code}\\n' http://127.0.0.1:5000/site/",
sudo=False, show=True)
return code
def do_deploy(nas, local_pkg, remote_pkg, icon_pkg, do_build, args):
# 1) 上传
step("上传源码包")
nas.upload(local_pkg, remote_pkg)
log(" -> %s (%s)" % (remote_pkg, human(os.path.getsize(local_pkg))))
# 2) 解包覆盖(保留 .env 与 deploy/nas/data
step("解包覆盖源码(保留 .env 与 deploy/nas/data")
nas.run("cd %s && tar xzf %s" % (REMOTE_DIR, remote_pkg), sudo=True)
# 解压产生的文件属主可能是 root,改回 bwadmin;但必须跳过数据卷,
# 否则容器(uid 1001)会失去对 pear.db 的写权限
nas.run("cd %s && find . -mindepth 1 -path './deploy/nas/data' -prune "
"-o -exec chown %s:%s {} + 2>/dev/null || true" % (REMOTE_DIR, USER, USER), sudo=True)
nas.run("rm -f %s" % remote_pkg, sudo=True)
# 3) 图标种子
if icon_pkg:
step("同步 favicon 种子(容器无外网,靠种子才有真实图标)")
r_icon = REMOTE_TMP + "/" + os.path.basename(icon_pkg)
nas.upload(icon_pkg, r_icon)
nas.run("mkdir -p %s/%s && tar xzf %s -C %s/%s && chown -R %s %s/%s"
% (REMOTE_DIR, ICON_DEST, r_icon, REMOTE_DIR, ICON_DEST,
ICON_OWNER, REMOTE_DIR, ICON_DEST), sudo=True)
cnt, _ = nas.run("ls -1 %s/%s | wc -l" % (REMOTE_DIR, ICON_DEST), sudo=True, show=False)
log(" 图标种子已就位")
nas.run("rm -f %s" % r_icon, sudo=True)
else:
log("\n[跳过] 本地没有图标种子(data/icon 为空),容器内图标将回退为字母头像")
# 4) 构建
if do_build:
step("构建镜像(docker compose build")
# 先记一下旧 ID,便于和构建后的结果对比
old_id, _ = nas.run("docker images -q pear-admin-nas:latest", sudo=True, show=False)
old_id = (old_id or "").strip()
# 不加 --pullNAS 的 registry 镜像常返回 401),本地 python:3.11-slim 缓存已够用
pull = " --pull" if args.pull else ""
pull += " --no-cache" if args.no_cache else ""
# 单独收退出码,不用 pipe 截断输出(管道会把 stdout 读到一半就丢弃)
build_cmd = ("cd %s && docker compose -f %s build%s"
% (REMOTE_DIR, COMPOSE_FILE, pull))
code, _ = nas.run(build_cmd, sudo=True, timeout=1800)
if code != 0:
sys.exit("\n构建失败(退出码 %d),已中止部署(源码已更新到远端,但容器仍在跑旧镜像)" % code)
new_id, _ = nas.run("docker images -q pear-admin-nas:latest", sudo=True, show=False)
new_id = (new_id or "").strip()
if old_id and new_id and old_id == new_id:
log("\n[提示] 镜像 ID 未变化(%s):Docker 判定无改动复用了缓存。" % new_id[:12])
log(" 如果改了模板却没生效,试试 --no-cache 强制重建。")
else:
log("\n镜像已更新:%s -> %s" % ((old_id or "")[:12], (new_id or "")[:12]))
else:
log("\n[跳过] --no-build:不重新构建镜像,直接重启容器")
# 5) 起容器
step("启动容器")
nas.run("cd %s && docker compose -f %s up -d" % (REMOTE_DIR, COMPOSE_FILE), sudo=True)
# 6) 等健康检查
step("等待健康检查")
ok = False
for i in range(30):
time.sleep(4)
code, out = nas.run("curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/healthz || true",
sudo=False, show=False)
status = out.strip().splitlines()[-1] if out.strip() else ""
if status == "200":
log("%d 次探测通过(healthz=200" % (i + 1))
ok = True
break
log("%d 次探测未就绪(%s" % (i + 1, status or "无响应"))
if not ok:
log("\n[警告] 健康检查未通过,打印容器日志:")
nas.run("docker logs --tail 50 pear-admin-nas", sudo=True)
return 1
# 7) 验收
step("验收")
nas.run("docker ps --filter name=pear-admin-nas --format "
"'table {{.Names}} {{.Status}} {{.Ports}}'", sudo=True)
nas.run("curl -fsS -o /dev/null -w 'healthz=%{http_code}\\n' http://127.0.0.1:5000/healthz; "
"curl -fsS -o /dev/null -w 'site=%{http_code}\\n' http://127.0.0.1:5000/site/", sudo=False)
return 0
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description="构建并部署 pear-admin-flask 到 NAS")
ap.add_argument("--no-build", action="store_true", help="跳过镜像构建,只更新代码并重启")
ap.add_argument("--no-cache", action="store_true", help="构建时不用 Docker 层缓存(改了模板却没生效时用)")
ap.add_argument("--pull", action="store_true",
help="构建时强制拉取基础镜像(默认不拉:NAS 的 registry 常返回 401"
"本地 python:3.11-slim 缓存已够用)")
ap.add_argument("--icons-only", action="store_true", help="只同步 favicon 种子")
ap.add_argument("--no-icons", action="store_true", help="跳过 favicon 种子同步")
ap.add_argument("--check", action="store_true", help="只看远端状态,不做任何修改")
ap.add_argument("--pack-only", action="store_true", help="只打包不上传")
args = ap.parse_args()
os.chdir(PROJECT_ROOT)
stamp = time.strftime("%Y%m%d-%H%M%S")
if args.pack_only:
pkg, n, size = pack_source("pear-src-%s.tar.gz" % stamp)
log("已打包:%s%d 个文件,%s" % (pkg, n, human(size)))
log("解包查看:tar tzf %s | head -50" % pkg)
return 0
pwd = os.environ.get("SSH_PASS") or getpass.getpass("NAS %s@%s 密码: " % (USER, HOST))
if args.check:
nas = Nas(HOST, USER, pwd, PORT)
try:
return do_check(nas)
finally:
nas.close()
# 临时包放系统临时目录:既不污染项目根,也避免在项目目录里做删除动作
tmpdir = tempfile.gettempdir()
pkg_path = None
icon_path = None
try:
if not args.icons_only:
step("本地打包源码")
pkg_path, n, size = pack_source(os.path.join(tmpdir, "pear-src-%s.tar.gz" % stamp))
log(" %d 个文件,%s" % (n, human(size)))
if not args.no_icons:
icon_path = pack_icons(os.path.join(tmpdir, "pear-icons-%s.tar.gz" % stamp))
if icon_path:
log(" 图标种子:%s%s" % (os.path.basename(icon_path),
human(os.path.getsize(icon_path))))
nas = Nas(HOST, USER, pwd, PORT)
try:
if args.icons_only:
if not icon_path:
log("本地 data/icon 下没有可用的 favicon,无需同步")
return 0
r_icon = REMOTE_TMP + "/" + os.path.basename(icon_path)
nas.upload(icon_path, r_icon)
nas.run("mkdir -p %s/%s && tar xzf %s -C %s/%s && chown -R %s %s/%s && rm -f %s"
% (REMOTE_DIR, ICON_DEST, r_icon, REMOTE_DIR, ICON_DEST,
ICON_OWNER, REMOTE_DIR, ICON_DEST, r_icon), sudo=True)
log("\n图标种子已同步")
return 0
return do_deploy(nas, pkg_path, REMOTE_TMP + "/" + os.path.basename(pkg_path),
icon_path, not args.no_build, args)
finally:
nas.close()
finally:
# 清理临时包;失败也不影响部署结果(某些环境会拦截批量删除)
for p in (pkg_path, icon_path):
if p and os.path.exists(p):
try:
os.remove(p)
except BaseException:
log(" [提示] 临时包未能自动删除:%s" % p)
if __name__ == "__main__":
sys.exit(main() or 0)