Compare commits

...

3 Commits

Author SHA1 Message Date
luotaiqian 9ebdafe5a2 Merge remote-tracking branch 'origin/dev' into dev 2026-07-07 09:41:26 +08:00
luotaiqian f32b7150ab route 2026-07-07 09:41:19 +08:00
luotaiqian d39d8dd0bb sms 2026-07-07 09:27:47 +08:00
3 changed files with 488 additions and 0 deletions

464
push/push_to_dev.py Normal file
View File

@ -0,0 +1,464 @@
# -*- 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()

View File

@ -16,6 +16,18 @@
strip-prefix: 0 strip-prefix: 0
uri: http://${application.name} uri: http://${application.name}
path: /${application.gateway}/** path: /${application.gateway}/**
- client:
system-code: ${application.name}
name: ${application.cn-name}-驾驶舱去壳
group-code: public_api
service:
system-code: ${application.name}
name: ${application.cn-name}-驾驶舱去壳
group-code: public_api
strip-prefix: 0
uri: http://${application.name}
path: /${application.gateway}/container/driver
order: -10
- client: - client:
system-code: ${application.name}-container system-code: ${application.name}-container
name: ${application.cn-name}-前端 name: ${application.cn-name}-前端

View File

@ -16,6 +16,18 @@ sdk:
strip-prefix: 0 strip-prefix: 0
uri: http://${application.name} uri: http://${application.name}
path: /${application.gateway}/** path: /${application.gateway}/**
- client:
system-code: ${application.name}
name: ${application.cn-name}-驾驶舱去壳
group-code: public_api
service:
system-code: ${application.name}
name: ${application.cn-name}-驾驶舱去壳
group-code: public_api
strip-prefix: 0
uri: http://${application.name}
path: /${application.gateway}/container/driver
order: -10
- client: - client:
system-code: ${application.name}-container system-code: ${application.name}-container
name: ${application.cn-name}-前端 name: ${application.cn-name}-前端