# -*- coding: utf-8 -*- """ safety-eval-service 一键发布到开发环境 K8s 脚本 ================================================ 在本地 Windows 执行,自动完成: 本地 Maven 编译 (可选) -> 上传 JAR -> 远程 Docker build -> 推送 ACR -> 更新 K8s Deployment -> 滚动重启 -> 校验 Pod 状态 与 docs/build-tools/build_push.py 的区别: build_push.py 只构建并推送镜像,最后只"打印" kubectl 命令; 本脚本会真正执行 K8s 部署、滚动更新并校验结果。 前置条件: - Python 3 + paramiko (pip install paramiko) - 本地已编译好 JAR,或加 --build 让脚本自动 mvn 编译 用法: # 最简单: 自动查找项目 JAR 并发布 (推荐) python push_to_dev.py # 指定 JAR 路径 python push_to_dev.py --jar "D:\\workspace\\code\\safety-eval-service\\safety-eval-start\\target\\safety-eval-start-1.0-SNAPSHOT.jar" # 先本地编译再发布 python push_to_dev.py --build # 自定义版本序号 (默认自动递增当天序号) python push_to_dev.py --version 3 # 只更新 K8s (镜像已存在,只触发滚动重启) python push_to_dev.py --deploy-only --image <完整镜像地址> # 跳过 K8s 部署,只构建推送镜像 python push_to_dev.py --no-deploy 示例输出镜像: jjb-registry-registry.cn-hangzhou.cr.aliyuncs.com/ali_img_ns/prod-aly-ota-dragon-jjb-saas-safety-eval:ota-20260707-1 """ import argparse import os import subprocess import sys import time from datetime import datetime import paramiko # ==================== 配置区 ==================== # K8s Master (SSH) MASTER = "192.168.20.100" SSH_USER = "root" SSH_PASSWD = "Zcloud@zcloud100" # 杭州 ACR (K8s image-pull-secret 指向此处,必须用这个仓库) ACR_REGISTRY = "jjb-registry-registry.cn-hangzhou.cr.aliyuncs.com" ACR_NAMESPACE = "ali_img_ns" ACR_USER = "10952138@qq.com" ACR_PASS = "idurCT!rIq9EzISD" # K8s 部署信息 K8S_NAMESPACE = "jjb-dragon" DEPLOY_NAME = "jjb-saas-safety-eval-deploy" # Deployment 名称 CONTAINER_NAME = "jjb-saas-safety-eval-app" # 容器名称 KUBECTL = "/usr/bin/kubectl" # 镜像命名 APP_NAME = "jjb-saas-safety-eval" ENV_PREFIX = "ota" # 环境前缀 # 项目本地路径 (默认为脚本所在目录的上一级) PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # JAR 相对路径 JAR_REL = os.path.join("safety-eval-start", "target", "safety-eval-start-1.0-SNAPSHOT.jar") # 远程构建目录 REMOTE_BUILD_DIR = f"/tmp/docker-build-{APP_NAME}" # 脚本自带的 Dockerfile (与 docs/build-tools/Dockerfile 一致: CentOS7 + JDK1.8.0_202) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DOCKERFILE_PATH = os.path.join(SCRIPT_DIR, "Dockerfile") # 若 push 目录没有 Dockerfile,则回退到 docs/build-tools/Dockerfile if not os.path.exists(DOCKERFILE_PATH): DOCKERFILE_PATH = os.path.join( PROJECT_DIR, "docs", "build-tools", "Dockerfile" ) # ==================== 工具函数 ==================== def ssh_exec(client, cmd, timeout=600, quiet=False): """执行 SSH 命令并打印结果,返回 (stdout, stderr, exit_code)。""" if not quiet: print(f" $ {cmd[:150]}{'...' if len(cmd) > 150 else ''}") stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") exit_code = stdout.channel.recv_exit_status() if not quiet: if out.strip(): for line in out.strip().split("\n")[-15:]: print(f" {line}") if err.strip(): # 过滤掉 docker 下载进度噪音 important = [ l for l in err.strip().split("\n") if not any(x in l for x in ["Downloading", "Downloaded", "Extracting", "Progress", "WARNING! --password", "Login Succeeded"]) ] if important: print(f" [stderr] {'; '.join(important[:5])}") return out.strip(), err.strip(), exit_code def upload_with_progress(sftp, local, remote): """带进度条的上传。""" size_mb = os.path.getsize(local) / 1024 / 1024 start = time.time() last_pct = [-1] def progress(transferred, total): pct = int(transferred * 100 / total) if pct >= last_pct[0] + 10: last_pct[0] = pct mb = transferred / 1024 / 1024 elapsed = time.time() - start speed = mb / elapsed if elapsed > 0 else 0 print(f" {pct}% ({mb:.1f} MB) - {speed:.1f} MB/s") sftp.put(local, remote, callback=progress) elapsed = time.time() - start print(f" 上传完成: {elapsed:.1f}s ({size_mb / elapsed:.1f} MB/s)") def find_local_jar(project_dir): """自动查找项目 JAR 包。""" candidate = os.path.join(project_dir, JAR_REL) if os.path.exists(candidate): return os.path.abspath(candidate) # 兜底: 在 safety-eval-start/target 下找最大的 jar (非 *.original) target_dir = os.path.join(project_dir, "safety-eval-start", "target") if os.path.isdir(target_dir): jars = [os.path.join(target_dir, f) for f in os.listdir(target_dir) if f.endswith(".jar") and not f.endswith(".jar.original")] if jars: jars.sort(key=os.path.getsize, reverse=True) return os.path.abspath(jars[0]) return None def local_mvn_build(project_dir): """在本地执行 Maven 编译。""" print("[0/7] 本地 Maven 编译 (mvn clean package -DskipTests)...") cmd = ["mvn", "clean", "package", "-DskipTests"] print(f" $ {' '.join(cmd)}") print(" (编译日志较多,仅显示关键输出...)") try: proc = subprocess.run( cmd, cwd=project_dir, shell=(os.name == "nt"), capture_output=True, text=True, encoding="utf-8", errors="replace" ) except FileNotFoundError: print(" ERROR: 未找到 mvn 命令,请确认本地已安装 Maven 并加入 PATH。") sys.exit(1) # 打印最后的关键行 tail = (proc.stdout or "").strip().split("\n")[-25:] for line in tail: print(f" {line}") if proc.returncode != 0: print("\n ERROR: Maven 编译失败!") if proc.stderr: print(proc.stderr[-1000:]) sys.exit(1) print(" Maven 编译成功。") def next_version_suffix(client): """查询 master 上当天已存在的镜像 tag,自动递增序号。""" date_tag = datetime.now().strftime("%Y%m%d") prefix = f"{ENV_PREFIX}-{date_tag}-" # 查询本应用当天已构建的 tag (docker images 第二列为 Tag) repo_filter = f"prod-aly-{ENV_PREFIX}-dragon-{APP_NAME}" out, _, _ = ssh_exec( client, f"docker images --format '{{{{.Repository}}}}:{{{{.Tag}}}}' " f"| grep '{repo_filter}' | awk -F: '{{print $NF}}'", quiet=True, ) used = set() for line in out.split("\n"): line = line.strip() if line.startswith(prefix): num_part = line[len(prefix):] if num_part.isdigit(): used.add(int(num_part)) return max(used) + 1 if used else 1 # ==================== 主流程 ==================== def main(): parser = argparse.ArgumentParser( description="safety-eval-service 一键发布到开发环境 K8s", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument("--jar", default=None, help="本地 JAR 路径 (不指定则自动查找)") parser.add_argument("--build", action="store_true", help="发布前先执行本地 mvn 编译") parser.add_argument("--version", default=None, help="镜像版本序号 (默认自动递增)") parser.add_argument("--app", default=APP_NAME, help=f"应用名 (默认: {APP_NAME})") parser.add_argument("--prefix", default=ENV_PREFIX, help=f"环境前缀 (默认: {ENV_PREFIX})") parser.add_argument("--no-deploy", action="store_true", help="只构建推送镜像,不部署 K8s") parser.add_argument("--deploy-only", action="store_true", help="只更新 K8s (需配合 --image 指定已存在的镜像)") parser.add_argument("--image", default=None, help="指定完整镜像地址 (配合 --deploy-only)") parser.add_argument("--restart", action="store_true", help="只重启 K8s (不改镜像,等同 rollout restart)") parser.add_argument("--dry-run", action="store_true", help="只打印计划,不实际执行") args = parser.parse_args() global APP_NAME, ENV_PREFIX APP_NAME = args.app ENV_PREFIX = args.prefix # ---- 仅重启 ---- if args.restart: do_k8s_restart(args.dry_run) return # ---- 仅部署 (镜像已存在) ---- if args.deploy_only: if not args.image: print("ERROR: --deploy-only 需要配合 --image <完整镜像地址>") sys.exit(1) do_k8s_deploy(args.image, args.dry_run) return # ==================== 完整流程: 编译+构建+推送+部署 ==================== date_tag = datetime.now().strftime("%Y%m%d") # [0] 本地编译 (可选) if args.build: local_mvn_build(PROJECT_DIR) # [1] 定位 JAR jar_local = args.jar or find_local_jar(PROJECT_DIR) if not jar_local or not os.path.exists(jar_local): print(f"ERROR: 未找到 JAR 文件: {jar_local or '(未自动发现)'}") print(f" 可用 --jar 指定路径,或加 --build 自动编译。") sys.exit(1) jar_size_mb = os.path.getsize(jar_local) / 1024 / 1024 jar_name = os.path.basename(jar_local) # ---- 连接 master 以确定版本号 ---- print("\n连接 K8s Master 节点...") client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(MASTER, username=SSH_USER, password=SSH_PASSWD, timeout=30) print(f" SSH 已连接: {MASTER}") version_suffix = args.version or next_version_suffix(client) image_tag = f"{ENV_PREFIX}-{date_tag}-{version_suffix}" full_image = (f"{ACR_REGISTRY}/{ACR_NAMESPACE}/" f"prod-aly-{ENV_PREFIX}-dragon-{APP_NAME}:{image_tag}") # ---- 打印发布计划 ---- print("\n" + "=" * 66) print(" safety-eval-service 一键发布到开发环境") print("=" * 66) print(f" JAR: {jar_local} ({jar_size_mb:.1f} MB)") print(f" 应用: {APP_NAME}") print(f" 镜像 Tag: {image_tag}") print(f" 完整镜像: {full_image}") print(f" K8s: {K8S_NAMESPACE}/{DEPLOY_NAME}") print(f" Master: {MASTER}") print("=" * 66) if args.dry_run: print("\n[DRY RUN] 以上为发布计划,未实际执行。") client.close() return # [2] 准备远程目录 + 上传 JAR print(f"\n[1/6] 上传 JAR 到 master ({jar_size_mb:.1f} MB)...") ssh_exec(client, f"rm -rf {REMOTE_BUILD_DIR} && mkdir -p {REMOTE_BUILD_DIR}/target", quiet=True) sftp = client.open_sftp() remote_jar = f"{REMOTE_BUILD_DIR}/target/{jar_name}" upload_with_progress(sftp, jar_local, remote_jar) # 上传 Dockerfile print(" 上传 Dockerfile...") if os.path.exists(DOCKERFILE_PATH): sftp.put(DOCKERFILE_PATH, f"{REMOTE_BUILD_DIR}/Dockerfile") else: # 回退: 用 heredoc 写入标准模板 print(" (未找到本地 Dockerfile,使用 master 上的模板)") ssh_exec(client, f"cp /opt/docker-templates/Dockerfile {REMOTE_BUILD_DIR}/Dockerfile " f"2>/dev/null || true", quiet=True) sftp.close() # [3] 准备构建上下文 (复制 JDK) print("\n[2/6] 准备构建上下文 (复制 JDK)...") ssh_exec(client, f"cp -r /opt/jdk1.8.0_202 {REMOTE_BUILD_DIR}/", timeout=120) ssh_exec(client, f"du -sh {REMOTE_BUILD_DIR}/") # [4] Docker build print("\n[3/6] 构建 Docker 镜像...") out, err, code = ssh_exec( client, f"docker build -t '{full_image}' {REMOTE_BUILD_DIR}", timeout=600 ) if code != 0: print("\nERROR: Docker build 失败!") client.close() sys.exit(1) # [5] 登录 + 推送 ACR print("\n[4/6] 推送镜像到杭州 ACR...") ssh_exec(client, f"echo '{ACR_PASS}' | docker login --username={ACR_USER} " f"--password-stdin {ACR_REGISTRY}", quiet=True) out, err, code = ssh_exec(client, f"docker push '{full_image}'", timeout=900) if code != 0: print("\nERROR: Docker push 失败!") client.close() sys.exit(1) ssh_exec(client, f"docker images | grep '{APP_NAME}' | head -5") # 清理远程构建目录 ssh_exec(client, f"rm -rf {REMOTE_BUILD_DIR}", quiet=True) if args.no_deploy: client.close() print("\n" + "=" * 66) print(" 构建推送完成 (--no-deploy 已跳过 K8s 部署)") print("=" * 66) print(f"\n 镜像地址: {full_image}\n") return # [6] 部署到 K8s print("\n[5/6] 部署到 K8s...") do_k8s_deploy(full_image, dry_run=False, client=client) # [7] 校验 print("\n[6/6] 校验 Pod 状态...") verify_rollout(client) client.close() print("\n" + "=" * 66) print(" 发布完成!") print("=" * 66) print(f" 镜像: {full_image}") print(f" 服务: safety-eval-service (Nacos: {K8S_NAMESPACE})") print(f" 网关: http://192.168.20.100:30140/safety-eval/ (Header: filterGatewaName: cqanquan)") print("=" * 66) def do_k8s_deploy(full_image, dry_run, client=None): """更新 K8s Deployment 镜像并触发滚动更新。""" own_client = client is None if own_client: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(MASTER, username=SSH_USER, password=SSH_PASSWD, timeout=30) # 检查 Deployment 是否存在 out, _, code = ssh_exec( client, f"{KUBECTL} get deployment {DEPLOY_NAME} -n {K8S_NAMESPACE} --ignore-not-found", quiet=True, ) deploy_exists = code == 0 and DEPLOY_NAME in out if dry_run: print(f" [DRY RUN] 将更新镜像: {full_image}") if own_client: client.close() return if deploy_exists: # 更新镜像 print(f" 更新 Deployment 镜像...") out, err, code = ssh_exec( client, f"{KUBECTL} set image deployment/{DEPLOY_NAME} " f"{CONTAINER_NAME}={full_image} -n {K8S_NAMESPACE}" ) if code != 0: print(f" ERROR: kubectl set image 失败: {err}") if own_client: client.close() sys.exit(1) else: # 首次部署: 用 docs/deploy 下的 yaml 创建 print(f" Deployment 不存在,尝试 apply 部署 yaml...") deploy_yaml = f"/tmp/{DEPLOY_NAME}.yaml" local_yaml = os.path.join(PROJECT_DIR, "docs", "deploy", "safety-eval-deploy.yaml") if os.path.exists(local_yaml): sftp = client.open_sftp() sftp.put(local_yaml, deploy_yaml) sftp.close() # 先替换镜像为本次构建的镜像 ssh_exec(client, f"sed -i 's|image: .*|image: {full_image}|' {deploy_yaml}", quiet=True) ssh_exec(client, f"{KUBECTL} apply -f {deploy_yaml} -n {K8S_NAMESPACE}") ssh_exec(client, f"rm -f {deploy_yaml}", quiet=True) else: print(" ERROR: Deployment 不存在且找不到部署 yaml,请先手动创建 Deployment。") if own_client: client.close() sys.exit(1) if own_client: client.close() def do_k8s_restart(dry_run): """仅重启 K8s (不改镜像)。""" print("=" * 66) print(f" 重启 K8s Deployment: {K8S_NAMESPACE}/{DEPLOY_NAME}") print("=" * 66) if dry_run: print(" [DRY RUN] 将执行 rollout restart") return client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(MASTER, username=SSH_USER, password=SSH_PASSWD, timeout=30) print("\n触发滚动重启...") ssh_exec(client, f"{KUBECTL} rollout restart deployment/{DEPLOY_NAME} -n {K8S_NAMESPACE}") verify_rollout(client) client.close() def verify_rollout(client): """等待滚动更新完成并打印 Pod 状态与最近日志。""" print(" 等待滚动更新完成 (最多 300s)...") out, err, code = ssh_exec( client, f"{KUBECTL} rollout status deployment/{DEPLOY_NAME} " f"-n {K8S_NAMESPACE} --timeout=300s", timeout=320, ) if code != 0: print(f" ⚠ 滚动更新未完成或超时: {err}") print(" 最近事件:") ssh_exec(client, f"{KUBECTL} get events -n {K8S_NAMESPACE} --sort-by=.metadata.creationTimestamp " f"| grep -i 'safety-eval' | tail -15") # Pod 状态 print("\n Pod 状态:") ssh_exec(client, f"{KUBECTL} get pods -n {K8S_NAMESPACE} -o wide | grep 'safety-eval' | head -10") # 最近日志 print("\n 最近启动日志 (最后 40 行):") ssh_exec(client, f"{KUBECTL} logs deployment/{DEPLOY_NAME} -n {K8S_NAMESPACE} --tail=40") if __name__ == "__main__": main()