Просмотр исходного кода

ci(jenkins): 四环境共用统一部署流水线,差异收敛到 .env.xxx

从 GIT_BRANCH 推断 dev/sit/uat/produ,端口与 produ SSH 目标均从 .env 读取;
dev/sit/uat 本机部署,produ 走 SSH 远程部署。

Co-authored-by: Cursor <cursoragent@cursor.com>
天空之城 10 часов назад
Родитель
Сommit
03af65aade
6 измененных файлов с 266 добавлено и 109 удалено
  1. 1 0
      .env.dev
  2. 9 0
      .env.example
  3. 7 0
      .env.produ
  4. 1 0
      .env.sit
  5. 1 0
      .env.uat
  6. 247 109
      Jenkinsfile

+ 1 - 0
.env.dev

@@ -20,6 +20,7 @@ REDIS_SENTINELS=
 
 # 服务端口
 GATEWAY_PORT=33333
+GATEWAY_HOST_PORT=33333
 STORE_PORT=8001
 CONTRACT_PORT=8002
 LAWYER_PORT=8004

+ 9 - 0
.env.example

@@ -40,10 +40,19 @@ REDIS_CONNECT_TIMEOUT=1.0
 
 # -------------------- 服务监听端口(容器内) --------------------
 GATEWAY_PORT=33333
+# 宿主机映射端口(默认与 GATEWAY_PORT 相同;produ 可为 33333 而容器内 43333)
+GATEWAY_HOST_PORT=33333
 STORE_PORT=8001
 CONTRACT_PORT=8002
+# contract 对外端口(留空则不映射;produ 可填 8002)
+CONTRACT_HOST_PORT=
 LAWYER_PORT=8004
 
+# -------------------- Jenkins 部署(仅 produ SSH 远程部署) --------------------
+# DEPLOY_SSH_TARGET=alien_store@39.105.153.68
+# DEPLOY_CODE_DIR=/alien_produ/python/alien_py_cloud
+# DEPLOY_LOG_ROOT=/alien_produ/python/alien_py_cloud/logs
+
 # -------------------- 下游服务地址(网关代理用) --------------------
 STORE_BASE_URL=http://127.0.0.1:8001
 CONTRACT_BASE_URL=http://127.0.0.1:8002

+ 7 - 0
.env.produ

@@ -26,10 +26,17 @@ REDIS_SENTINEL_PASSWORD=
 
 # 服务端口(生产 store 监听 48001;gateway 容器内 43333,宿主机映射 33333)
 GATEWAY_PORT=43333
+GATEWAY_HOST_PORT=33333
 STORE_PORT=48001
 CONTRACT_PORT=8002
+CONTRACT_HOST_PORT=8002
 LAWYER_PORT=8004
 
+# Jenkins 部署(produ 为 SSH 远程部署,其他环境留空)
+DEPLOY_SSH_TARGET=alien_store@39.105.153.68
+DEPLOY_CODE_DIR=/alien_produ/python/alien_py_cloud
+DEPLOY_LOG_ROOT=/alien_produ/python/alien_py_cloud/logs
+
 # 下游服务地址(生产 Jenkins 部署时用 -e 覆盖为容器名:端口)
 STORE_BASE_URL=http://127.0.0.1:48001
 CONTRACT_BASE_URL=http://127.0.0.1:8002

+ 1 - 0
.env.sit

@@ -20,6 +20,7 @@ REDIS_SENTINELS=
 
 # 服务端口
 GATEWAY_PORT=33333
+GATEWAY_HOST_PORT=33333
 STORE_PORT=8001
 CONTRACT_PORT=8002
 LAWYER_PORT=8004

+ 1 - 0
.env.uat

@@ -23,6 +23,7 @@ REDIS_SENTINEL_PASSWORD=
 
 # 服务端口(UAT 使用独立端口避开占用)
 GATEWAY_PORT=43333
+GATEWAY_HOST_PORT=43333
 STORE_PORT=8001
 CONTRACT_PORT=8002
 LAWYER_PORT=8004

+ 247 - 109
Jenkinsfile

@@ -1,3 +1,24 @@
+/**
+ * alien_py_cloud 统一部署流水线(dev / sit / uat / produ 共用)
+ *
+ * 设计原则:
+ * - 无 parameters,Build Now 直接跑
+ * - APP_ENV 由 Jenkins SCM 的 GIT_BRANCH 自动推断(*/dev、*/sit、*/uat、*/produ)
+ * - 端口、SSH 目标等环境差异全部来自 .env.${APP_ENV},代码与 Jenkinsfile 四分支一致
+ * - dev/sit/uat:Jenkins 本机 docker build + run
+ * - produ:SSH 到 DEPLOY_SSH_TARGET 远程 build + run(目标见 .env.produ)
+ */
+
+def PROD_SSH_CREDENTIALS_ID = 'e611a045-2fdc-4613-babd-a72d69bf9814'
+
+def readEnvVar(String envFile, String key, String defaultValue = '') {
+  def val = sh(
+    script: "grep -E '^${key}=' ${envFile} | head -n1 | cut -d= -f2- | tr -d '\"' | tr -d \"'\" | xargs || true",
+    returnStdout: true
+  ).trim()
+  return val ?: defaultValue
+}
+
 pipeline {
   agent any
 
@@ -7,42 +28,46 @@ pipeline {
     disableConcurrentBuilds()
   }
 
-  // 设计原则:
-  // - 无 parameters 块,所以"Build Now"直接跑、不弹窗
-  // - 环境(APP_ENV) 与 分支(BRANCH) 都从 Jenkins 自动注入的 GIT_BRANCH 推断
-  //   GIT_BRANCH 来自每个 Job 的 SCM 配置(Branch Specifier *​/dev、*​/sit、*​/uat 之一)
-  // - 因此每台机器上 Job 唯一要配的就是 Branch Specifier,三台机器的 Jenkinsfile 内容完全一致
-
   stages {
 
     stage('Resolve Environment') {
-      // 从 Jenkins 注入的 GIT_BRANCH 推断 APP_ENV
-      // GIT_BRANCH 形如 "origin/uat" / "uat" / "refs/heads/uat",统一去前缀
       steps {
         script {
           def raw = env.GIT_BRANCH ?: env.BRANCH_NAME ?: ''
           def branch = raw.replaceFirst('^origin/', '').replaceFirst('^refs/heads/', '').trim()
 
-          if (!(branch in ['dev', 'sit', 'uat'])) {
+          if (!(branch in ['dev', 'sit', 'uat', 'produ'])) {
             error """
               ============ 无法识别当前部署环境 ============
               Jenkins 注入的 GIT_BRANCH = '${env.GIT_BRANCH}'
               解析后的分支名             = '${branch}'
-              期望分支必须是 dev / sit / uat 之一。
+              期望分支必须是 dev / sit / uat / produ 之一。
 
-              请检查 Job 配置中的 Branch Specifier 是否正确:
-                DEV 服务器  → */dev
-                SIT 服务器  → */sit
-                UAT 服务器  → */uat
+              请检查 Job 配置中的 Branch Specifier:
+                DEV   → */dev
+                SIT   → */sit
+                UAT   → */uat
+                PRODU → */produ
               =============================================
             """.stripIndent()
           }
 
-          // 把推断结果写回 env,后续 stage 用 env.APP_ENV 而不是 params.APP_ENV
           env.APP_ENV = branch
           env.BRANCH  = branch
+          def envFile = ".env.${env.APP_ENV}"
+
+          if (!fileExists(envFile)) {
+            error "缺少环境配置文件: ${envFile}"
+          }
+
+          env.STORE_PORT         = readEnvVar(envFile, 'STORE_PORT', '8001')
+          env.GATEWAY_PORT       = readEnvVar(envFile, 'GATEWAY_PORT', '33333')
+          env.CONTRACT_PORT      = readEnvVar(envFile, 'CONTRACT_PORT', '8002')
+          env.GATEWAY_HOST_PORT  = readEnvVar(envFile, 'GATEWAY_HOST_PORT', env.GATEWAY_PORT)
+          env.CONTRACT_HOST_PORT = readEnvVar(envFile, 'CONTRACT_HOST_PORT', '')
+
+          env.DEPLOY_MODE = (env.APP_ENV == 'produ') ? 'ssh' : 'local'
 
-          // 派生命名规则(容器名/网络/日志/镜像 TAG 全部以 APP_ENV 区分)
           env.IMAGE_TAG               = "${env.APP_ENV}-${env.BUILD_NUMBER}"
           env.IMAGE_STORE             = "alien_store:${env.IMAGE_TAG}"
           env.IMAGE_GATEWAY           = "alien_gateway:${env.IMAGE_TAG}"
@@ -51,7 +76,24 @@ pipeline {
           env.CONTAINER_NAME_GATEWAY  = "alien_gateway_py-${env.APP_ENV}"
           env.CONTAINER_NAME_CONTRACT = "alien_contract_py-${env.APP_ENV}"
           env.DOCKER_NET              = "alien_net_${env.APP_ENV}"
-          env.LOG_ROOT                = "/docker/python-${env.APP_ENV}/logs"
+
+          if (env.DEPLOY_MODE == 'ssh') {
+            env.SSH_TARGET      = readEnvVar(envFile, 'DEPLOY_SSH_TARGET')
+            env.CODE_DIR_REMOTE = readEnvVar(envFile, 'DEPLOY_CODE_DIR')
+            env.LOG_ROOT        = readEnvVar(envFile, 'DEPLOY_LOG_ROOT')
+            env.ENV_FILE_REMOTE = "${env.CODE_DIR_REMOTE}/.env.${env.APP_ENV}"
+            if (!env.SSH_TARGET || !env.CODE_DIR_REMOTE) {
+              error ".env.produ 缺少 DEPLOY_SSH_TARGET 或 DEPLOY_CODE_DIR"
+            }
+            if (!env.LOG_ROOT) {
+              env.LOG_ROOT = "${env.CODE_DIR_REMOTE}/logs"
+            }
+          } else {
+            env.LOG_ROOT = "/docker/python-${env.APP_ENV}/logs"
+          }
+
+          echo "APP_ENV=${env.APP_ENV}  DEPLOY_MODE=${env.DEPLOY_MODE}"
+          echo "端口 store=${env.STORE_PORT} gateway=${env.GATEWAY_PORT} host=${env.GATEWAY_HOST_PORT} contract=${env.CONTRACT_PORT}"
         }
       }
     }
@@ -59,32 +101,56 @@ pipeline {
     stage('Show Build Info') {
       steps {
         echo "============================================================"
-        echo " 部署环境  : ${env.APP_ENV}    (来自 GIT_BRANCH=${env.GIT_BRANCH})"
-        echo " 部署分支  : ${env.BRANCH}"
+        echo " 部署环境  : ${env.APP_ENV}    (GIT_BRANCH=${env.GIT_BRANCH})"
+        echo " 部署模式  : ${env.DEPLOY_MODE}"
         echo " 镜像 TAG  : ${env.IMAGE_TAG}"
         echo " 容器网络  : ${env.DOCKER_NET}"
-        echo " 日志根目录: ${env.LOG_ROOT}"
+        echo " 日志目录  : ${env.LOG_ROOT}"
+        if (env.DEPLOY_MODE == 'ssh') {
+          echo " SSH 目标  : ${env.SSH_TARGET}"
+          echo " 远程目录  : ${env.CODE_DIR_REMOTE}"
+        }
         echo "============================================================"
       }
     }
 
-    stage('Load Env Port Mapping') {
-      // 从 .env.${APP_ENV} 解析 GATEWAY_PORT,作为 docker -p 宿主端口映射用
+    stage('Verify SSH') {
+      when {
+        expression { env.DEPLOY_MODE == 'ssh' }
+      }
       steps {
-        script {
-          def envFile = ".env.${env.APP_ENV}"
-          if (!fileExists(envFile)) {
-            error "缺少环境配置文件: ${envFile}(仓库里应该有这份;请检查 .gitignore 是否误屏蔽)"
-          }
-          def gatewayPort = sh(
-            script: "grep -E '^GATEWAY_PORT=' ${envFile} | head -n1 | cut -d= -f2 | tr -d '\"' | tr -d \"'\"",
-            returnStdout: true
-          ).trim()
-          if (!gatewayPort) {
-            gatewayPort = "33333"
-          }
-          env.GATEWAY_PORT = gatewayPort
-          echo "从 ${envFile} 解析到 GATEWAY_PORT=${env.GATEWAY_PORT}"
+        sshagent(credentials: [PROD_SSH_CREDENTIALS_ID]) {
+          sh """
+            set -e
+            ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${env.SSH_TARGET}' \\
+              'test -f ${env.ENV_FILE_REMOTE} && sudo docker info >/dev/null'
+            echo ">>> SSH OK: ${env.SSH_TARGET}"
+          """
+        }
+      }
+    }
+
+    stage('Sync Code') {
+      when {
+        expression { env.DEPLOY_MODE == 'ssh' }
+      }
+      steps {
+        sshagent(credentials: [PROD_SSH_CREDENTIALS_ID]) {
+          sh """
+            set -e
+            ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${env.SSH_TARGET}' bash -s <<'REMOTE_EOF'
+set -e
+cd '${env.CODE_DIR_REMOTE}'
+if [ ! -d .git ]; then
+  echo "ERROR: ${env.CODE_DIR_REMOTE} is not a git repo"
+  exit 1
+fi
+git fetch origin
+git checkout '${env.BRANCH}'
+git pull origin '${env.BRANCH}'
+echo ">>> git at \$(git rev-parse --short HEAD) on \$(hostname)"
+REMOTE_EOF
+          """
         }
       }
     }
@@ -92,10 +158,38 @@ pipeline {
     stage('Build Images') {
       steps {
         script {
-          def buildArgs = "--build-arg APP_ENV=${env.APP_ENV}"
-          sh "docker build ${buildArgs} -f alien_store/Dockerfile    -t ${env.IMAGE_STORE}    ."
-          sh "docker build ${buildArgs} -f alien_gateway/Dockerfile  -t ${env.IMAGE_GATEWAY}  ."
-          sh "docker build ${buildArgs} -f alien_contract/Dockerfile -t ${env.IMAGE_CONTRACT} ."
+          def buildCmds = """
+sudo docker build --build-arg APP_ENV=${env.APP_ENV} --build-arg STORE_PORT=${env.STORE_PORT} \\
+  -f alien_store/Dockerfile -t ${env.IMAGE_STORE} .
+sudo docker build --build-arg APP_ENV=${env.APP_ENV} --build-arg GATEWAY_PORT=${env.GATEWAY_PORT} \\
+  -f alien_gateway/Dockerfile -t ${env.IMAGE_GATEWAY} .
+sudo docker build --build-arg APP_ENV=${env.APP_ENV} --build-arg CONTRACT_PORT=${env.CONTRACT_PORT} \\
+  -f alien_contract/Dockerfile -t ${env.IMAGE_CONTRACT} .
+""".stripIndent()
+
+          if (env.DEPLOY_MODE == 'ssh') {
+            sshagent(credentials: [PROD_SSH_CREDENTIALS_ID]) {
+              sh """
+                set -e
+                ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${env.SSH_TARGET}' bash -s <<'REMOTE_EOF'
+set -e
+cd '${env.CODE_DIR_REMOTE}'
+${buildCmds}
+echo ">>> images built on \$(hostname)"
+REMOTE_EOF
+              """
+            }
+          } else {
+            sh """
+              set -e
+              docker build --build-arg APP_ENV=${env.APP_ENV} --build-arg STORE_PORT=${env.STORE_PORT} \\
+                -f alien_store/Dockerfile -t ${env.IMAGE_STORE} .
+              docker build --build-arg APP_ENV=${env.APP_ENV} --build-arg GATEWAY_PORT=${env.GATEWAY_PORT} \\
+                -f alien_gateway/Dockerfile -t ${env.IMAGE_GATEWAY} .
+              docker build --build-arg APP_ENV=${env.APP_ENV} --build-arg CONTRACT_PORT=${env.CONTRACT_PORT} \\
+                -f alien_contract/Dockerfile -t ${env.IMAGE_CONTRACT} .
+            """
+          }
         }
       }
     }
@@ -103,58 +197,75 @@ pipeline {
     stage('Deploy') {
       steps {
         script {
-          echo ">>> [${env.APP_ENV}] 部署镜像: ${env.IMAGE_STORE} / ${env.IMAGE_GATEWAY} / ${env.IMAGE_CONTRACT}"
-          sh """
-            set -e
+          def dockerBin = (env.DEPLOY_MODE == 'ssh') ? 'sudo docker' : 'docker'
+          def envFileArgs = (env.DEPLOY_MODE == 'ssh') ? """
+  --env-file ${env.ENV_FILE_REMOTE} \\
+  -v ${env.ENV_FILE_REMOTE}:/app/.env.${env.APP_ENV}:ro \\""" : ''
 
-            docker network create ${env.DOCKER_NET} 2>/dev/null || true
-            mkdir -p ${env.LOG_ROOT}/store ${env.LOG_ROOT}/gateway ${env.LOG_ROOT}/contract
-
-            docker rm -f ${env.CONTAINER_NAME_STORE} ${env.CONTAINER_NAME_GATEWAY} ${env.CONTAINER_NAME_CONTRACT} 2>/dev/null || true
-
-            # 1) 下游:store
-            #    APP_ENV=${env.APP_ENV} 让 config.py 加载镜像内的 .env.${env.APP_ENV}
-            docker run -d --name ${env.CONTAINER_NAME_STORE} \\
-              --network ${env.DOCKER_NET} \\
-              -e APP_ENV=${env.APP_ENV} \\
-              -v ${env.LOG_ROOT}/store:/app/common/logs/alien_store \\
-              --restart unless-stopped \\
-              ${env.IMAGE_STORE}
-
-            # 2) 下游:contract
-            docker run -d --name ${env.CONTAINER_NAME_CONTRACT} \\
-              --network ${env.DOCKER_NET} \\
-              -e APP_ENV=${env.APP_ENV} \\
-              -v ${env.LOG_ROOT}/contract:/app/common/logs/alien_contract \\
-              --restart unless-stopped \\
-              ${env.IMAGE_CONTRACT}
-
-            # 3) 网关:gateway(唯一对外端口)
-            #    -e GATEWAY_PORT=... 是关键:必须覆盖 Dockerfile 的默认 33333,
-            #    否则容器内 uvicorn 会监听默认端口,导致与宿主机映射端口对不上
-            #    pydantic-settings 也是环境变量优先于 .env 文件
-            docker run -d --name ${env.CONTAINER_NAME_GATEWAY} \\
-              --network ${env.DOCKER_NET} \\
-              -p ${env.GATEWAY_PORT}:${env.GATEWAY_PORT} \\
-              -e APP_ENV=${env.APP_ENV} \\
-              -e GATEWAY_PORT=${env.GATEWAY_PORT} \\
-              -e STORE_BASE_URL=http://${env.CONTAINER_NAME_STORE}:8001 \\
-              -e CONTRACT_BASE_URL=http://${env.CONTAINER_NAME_CONTRACT}:8002 \\
-              -v ${env.LOG_ROOT}/gateway:/app/common/logs/alien_gateway \\
-              --restart unless-stopped \\
-              ${env.IMAGE_GATEWAY}
-          """
+          def contractPortArgs = env.CONTRACT_HOST_PORT?.trim() ?
+            "-p ${env.CONTRACT_HOST_PORT}:${env.CONTRACT_PORT} \\" : ''
+
+          def legacyCleanup = (env.APP_ENV == 'produ') ? """
+${dockerBin} rm -f py_esign_produ py_gateway_produ py_contract_produ esign alien_gateway_py alien_contract_py \\
+  alien_store_produ alien_gateway_produ alien_contract_produ 2>/dev/null || true
+""" : ''
+
+          def deployScript = """
+set -e
+${dockerBin} network create ${env.DOCKER_NET} 2>/dev/null || true
+mkdir -p ${env.LOG_ROOT}/store ${env.LOG_ROOT}/gateway ${env.LOG_ROOT}/contract
+
+${dockerBin} rm -f ${env.CONTAINER_NAME_STORE} ${env.CONTAINER_NAME_GATEWAY} ${env.CONTAINER_NAME_CONTRACT} 2>/dev/null || true
+${legacyCleanup}
+${dockerBin} run -d --name ${env.CONTAINER_NAME_STORE} \\
+  --network ${env.DOCKER_NET} \\
+${envFileArgs}
+  -e APP_ENV=${env.APP_ENV} \\
+  -e STORE_PORT=${env.STORE_PORT} \\
+  -v ${env.LOG_ROOT}/store:/app/common/logs/alien_store \\
+  --restart unless-stopped \\
+  ${env.IMAGE_STORE}
+
+${dockerBin} run -d --name ${env.CONTAINER_NAME_CONTRACT} \\
+  --network ${env.DOCKER_NET} \\
+  ${contractPortArgs}
+${envFileArgs}
+  -e APP_ENV=${env.APP_ENV} \\
+  -e CONTRACT_PORT=${env.CONTRACT_PORT} \\
+  -v ${env.LOG_ROOT}/contract:/app/common/logs/alien_contract \\
+  --restart unless-stopped \\
+  ${env.IMAGE_CONTRACT}
+
+${dockerBin} run -d --name ${env.CONTAINER_NAME_GATEWAY} \\
+  --network ${env.DOCKER_NET} \\
+  -p ${env.GATEWAY_HOST_PORT}:${env.GATEWAY_PORT} \\
+${envFileArgs}
+  -e APP_ENV=${env.APP_ENV} \\
+  -e GATEWAY_PORT=${env.GATEWAY_PORT} \\
+  -e STORE_BASE_URL=http://${env.CONTAINER_NAME_STORE}:${env.STORE_PORT} \\
+  -e CONTRACT_BASE_URL=http://${env.CONTAINER_NAME_CONTRACT}:${env.CONTRACT_PORT} \\
+  -v ${env.LOG_ROOT}/gateway:/app/common/logs/alien_gateway \\
+  --restart unless-stopped \\
+  ${env.IMAGE_GATEWAY}
+"""
+
+          if (env.DEPLOY_MODE == 'ssh') {
+            sshagent(credentials: [PROD_SSH_CREDENTIALS_ID]) {
+              sh """
+                set -e
+                ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${env.SSH_TARGET}' bash -s <<'REMOTE_EOF'
+${deployScript}
+REMOTE_EOF
+              """
+            }
+          } else {
+            sh deployScript
+          }
         }
       }
     }
 
     stage('Smoke Test') {
-      // 注意:Jenkins 本身可能跑在 Docker 容器里,无法直接 curl 宿主机端口。
-      // 这里采取两层验证:
-      //   1) 硬验证:3 个业务容器必须都在 running 状态(不通过则 fail)
-      //   2) 软验证:通过 docker exec 进入 gateway 容器内部跑 /health 自检
-      //      (HTTP 失败只 warn,不让 Pipeline 失败,因为这只代表 Jenkins 的网络位置看不到,
-      //       不代表服务对外不可用,需要人工从其他位置验证)
       steps {
         script {
           sleep(time: 5, unit: 'SECONDS')
@@ -164,29 +275,56 @@ pipeline {
             env.CONTAINER_NAME_CONTRACT,
             env.CONTAINER_NAME_GATEWAY
           ]
-          def allRunning = true
-          containers.each { c ->
-            def status = sh(
-              returnStdout: true,
-              script: "docker inspect -f '{{.State.Status}}' ${c} 2>/dev/null || echo missing"
-            ).trim()
-            echo "  ${c}: ${status}"
-            if (status != 'running') {
-              allRunning = false
-              sh "docker logs --tail 50 ${c} || true"
-            }
-          }
-          if (!allRunning) {
-            error "存在容器未处于 running 状态,部署失败"
-          }
-          echo "✓ 3 个业务容器均在 running 状态"
 
-          def healthCmd = """docker exec ${env.CONTAINER_NAME_GATEWAY} python -c 'import urllib.request as u; r=u.urlopen("http://localhost:${env.GATEWAY_PORT}/health", timeout=3); print("HTTP", r.status, r.read(200).decode())'"""
-          def rc = sh(returnStatus: true, script: healthCmd)
-          if (rc == 0) {
-            echo "✓ gateway /health 通过"
+          if (env.DEPLOY_MODE == 'ssh') {
+            sshagent(credentials: [PROD_SSH_CREDENTIALS_ID]) {
+              def allRunning = true
+              containers.each { c ->
+                def status = sh(
+                  returnStdout: true,
+                  script: """
+                    ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${env.SSH_TARGET}' \\
+                      "sudo docker inspect -f '{{.State.Status}}' ${c} 2>/dev/null || echo missing"
+                  """
+                ).trim()
+                echo "  ${c}: ${status}"
+                if (status != 'running') { allRunning = false }
+              }
+              if (!allRunning) {
+                error "存在容器未处于 running 状态,部署失败"
+              }
+              def rc = sh(returnStatus: true, script: """
+                ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${env.SSH_TARGET}' \\
+                  "sudo docker exec ${env.CONTAINER_NAME_GATEWAY} python -c \\"import urllib.request as u; r=u.urlopen('http://localhost:${env.GATEWAY_PORT}/health', timeout=3); print('HTTP', r.status)\\""
+              """)
+              if (rc == 0) {
+                echo "✓ gateway /health 通过"
+              } else {
+                echo "⚠️ gateway /health 未通过,请手动验证:curl http://<host>:${env.GATEWAY_HOST_PORT}/health"
+              }
+            }
           } else {
-            echo "⚠️ gateway /health 未通过(容器在跑但 HTTP 自检失败,请从外部手动验证:curl http://<host>:${env.GATEWAY_PORT}/health)"
+            def allRunning = true
+            containers.each { c ->
+              def status = sh(
+                returnStdout: true,
+                script: "docker inspect -f '{{.State.Status}}' ${c} 2>/dev/null || echo missing"
+              ).trim()
+              echo "  ${c}: ${status}"
+              if (status != 'running') { allRunning = false }
+            }
+            if (!allRunning) {
+              error "存在容器未处于 running 状态,部署失败"
+            }
+            echo "✓ 3 个业务容器均在 running 状态"
+            def rc = sh(returnStatus: true, script: """
+              docker exec ${env.CONTAINER_NAME_GATEWAY} python -c 'import urllib.request as u; r=u.urlopen("http://localhost:${env.GATEWAY_PORT}/health", timeout=3); print("HTTP", r.status, r.read(200).decode())'
+            """)
+            if (rc == 0) {
+              echo "✓ gateway /health 通过"
+            } else {
+              echo "⚠️ gateway /health 未通过,请手动验证:curl http://<host>:${env.GATEWAY_HOST_PORT}/health"
+            }
           }
         }
       }
@@ -195,10 +333,10 @@ pipeline {
 
   post {
     success {
-      echo "[${env.APP_ENV}] 环境部署成功!请从外部访问 http://<host>:${env.GATEWAY_PORT}/health 验证"
+      echo "[${env.APP_ENV}] 部署成功!访问 http://<host>:${env.GATEWAY_HOST_PORT}/health"
     }
     failure {
-      echo "[${env.APP_ENV}] 环境部署失败,请检查上面日志。"
+      echo "[${env.APP_ENV}] 部署失败,请检查日志。"
     }
     always {
       cleanWs()