safety-eval-service/push/push_to_dev.py

465 lines
17 KiB
Python
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.

# -*- 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()