// ============================================ // AIoT IoT UI - Jenkins Pipeline // ============================================ // 双分支部署: // master -> PROD (172.17.16.14:90) // release/next -> STAGING (172.17.16.7:90) // 镜像仓库:172.17.16.7:5000 // ============================================ pipeline { agent any options { buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '5')) disableConcurrentBuilds() timeout(time: 45, unit: 'MINUTES') timestamps() retry(1) } environment { // ===== 应用标识 ===== APP_NAME = 'aiot-iot-ui' CONTAINER_NAME = 'aiot-iot-ui' HOST_PORT = '90' // ===== 镜像仓库 ===== REGISTRY = '172.17.16.7:5000' REGISTRY_HOST = '172.17.16.7' IMAGE_NAME = "${REGISTRY}/${APP_NAME}" // ===== 部署目标(默认 PROD,release/next 在 Initialize 阶段覆盖) ===== DEPLOY_HOST = '172.17.16.14' DEPLOY_PATH = '/opt/aiot-iot-ui' STAGING_DEPLOY_HOST = '172.17.16.7' STAGING_DEPLOY_PATH = '/opt/aiot-iot-ui' SSH_KEY = '/var/jenkins_home/.ssh/id_rsa' // ===== 健康检查 ===== HEALTH_CHECK_TIMEOUT = '60' HEALTH_CHECK_INTERVAL = '5' // ===== 镜像保留份数 ===== IMAGE_KEEP_COUNT = '3' // ===== 磁盘守护阈值(%) ===== DISK_FREE_MIN_PCT = '5' DISK_FREE_WARN_PCT = '10' } stages { stage('Initialize') { steps { script { env.PIPELINE_START_TIME = System.currentTimeMillis() echo "==========================================" echo " ${env.APP_NAME} - CI/CD Pipeline" echo "==========================================" echo "Branch: ${env.BRANCH_NAME}" echo "Build: #${env.BUILD_NUMBER}" echo "==========================================" if (env.BRANCH_NAME == 'release/next') { env.DEPLOY_HOST = env.STAGING_DEPLOY_HOST env.DEPLOY_PATH = env.STAGING_DEPLOY_PATH echo "📦 Deploy target: STAGING (${env.DEPLOY_HOST}:${env.HOST_PORT})" } else { echo "📦 Deploy target: PRODUCTION (${env.DEPLOY_HOST}:${env.HOST_PORT})" } } } } stage('Checkout') { steps { script { retry(3) { checkout scm } def shortCommit = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() def sanitized = env.BRANCH_NAME.replaceAll('/', '-') env.IMAGE_TAG = "${sanitized}-${env.BUILD_NUMBER}-${shortCommit}" env.PREVIOUS_IMAGE_TAG = getPreviousImageTag() echo "🏷️ Tag: ${env.IMAGE_TAG}" echo "🔖 Previous Tag (rollback to): ${env.PREVIOUS_IMAGE_TAG}" } } } stage('Pre-build Check') { steps { script { sh "docker version >/dev/null 2>&1 || { echo '❌ Docker not available'; exit 1; }" sh """ curl -f http://${env.REGISTRY}/v2/ >/dev/null 2>&1 || \ echo '⚠️ Registry not accessible, will continue (build still produces local image)' """ def diskUsage = sh( script: "df ${env.WORKSPACE} | tail -1 | awk '{print \$5}' | sed 's/%//'", returnStdout: true ).trim() as int if (diskUsage > 80) { echo "⚠️ Local disk usage ${diskUsage}%, pruning Docker..." sh "docker system prune -f || true" } } } } stage('Build & Push Image') { steps { script { def buildStart = System.currentTimeMillis() echo "🔨 Building ${env.IMAGE_NAME}:${env.IMAGE_TAG}" sh """ set -e docker build \\ --network=1panel-network \\ -t ${env.IMAGE_NAME}:${env.IMAGE_TAG} \\ . docker push ${env.IMAGE_NAME}:${env.IMAGE_TAG} if [ "${env.BRANCH_NAME}" = "master" ]; then docker tag ${env.IMAGE_NAME}:${env.IMAGE_TAG} ${env.IMAGE_NAME}:latest docker push ${env.IMAGE_NAME}:latest fi """ def imageSize = sh( script: "docker images ${env.IMAGE_NAME}:${env.IMAGE_TAG} --format '{{.Size}}'", returnStdout: true ).trim() def duration = ((System.currentTimeMillis() - buildStart) / 1000) as int echo "✅ Built and pushed in ${duration}s, size=${imageSize}" } } } stage('Pre-deploy Check') { when { anyOf { branch 'master'; branch 'release/next' } } steps { script { echo "🛡️ Pre-deploy: SSH + 磁盘可达性检查" checkRemoteDiskOrFail(env.DEPLOY_HOST, 'Deploy') } } } stage('Deploy') { when { anyOf { branch 'master'; branch 'release/next' } } steps { script { def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" echo "📂 同步 docker-compose.frontend.yml 到 ${env.DEPLOY_HOST}:${env.DEPLOY_PATH}" sh """ ssh ${sshOpts} root@${env.DEPLOY_HOST} 'mkdir -p ${env.DEPLOY_PATH}' scp ${sshOpts} docker-compose.frontend.yml root@${env.DEPLOY_HOST}:${env.DEPLOY_PATH}/docker-compose.frontend.yml """ try { echo "🚀 Deploy ${env.APP_NAME}:${env.IMAGE_TAG}" sh """ ssh ${sshOpts} root@${env.DEPLOY_HOST} ' set -e cd ${env.DEPLOY_PATH} export IMAGE_TAG=${env.IMAGE_TAG} export REGISTRY_HOST=${env.REGISTRY} export HOST_PORT=${env.HOST_PORT} docker compose -f docker-compose.frontend.yml pull docker compose -f docker-compose.frontend.yml up -d --force-recreate sleep 3 ' """ waitForHealthy(sshOpts) echo "✅ ${env.APP_NAME} deployed to ${env.DEPLOY_HOST}:${env.HOST_PORT}" } catch (Exception e) { echo "❌ Deploy failed: ${e.message}" if (env.PREVIOUS_IMAGE_TAG && env.PREVIOUS_IMAGE_TAG != 'latest') { echo "🔄 Rolling back to ${env.PREVIOUS_IMAGE_TAG}" rollback(sshOpts) } throw e } } } } stage('Cleanup Old Images') { when { anyOf { branch 'master'; branch 'release/next' } } steps { script { def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" echo "🧹 清理 ${env.DEPLOY_HOST} 上 ${env.APP_NAME} 的旧镜像(保留 ${env.IMAGE_KEEP_COUNT} 份)" sh """ ssh ${sshOpts} root@${env.DEPLOY_HOST} ' docker images ${env.IMAGE_NAME} \\ --format "{{.CreatedAt}}|{{.ID}}|{{.Tag}}" \\ | grep -v "|latest\$" \\ | sort -r \\ | awk -F"|" -v k=${env.IMAGE_KEEP_COUNT} "NR > k {print \\\$2}" \\ | sort -u \\ | xargs -r docker rmi -f 2>/dev/null || true docker image prune -f --filter "dangling=true" || true ' """ } } } } post { success { script { def total = ((System.currentTimeMillis() - env.PIPELINE_START_TIME.toLong()) / 1000) as int echo """ ========================================== ✅ BUILD SUCCESS ========================================== App: ${env.APP_NAME} Tag: ${env.IMAGE_TAG} Target: ${env.DEPLOY_HOST}:${env.HOST_PORT} Total: ${total}s ========================================== """ } } failure { script { echo "❌ BUILD FAILED — tag=${env.IMAGE_TAG ?: 'unknown'}, rollback_tag=${env.PREVIOUS_IMAGE_TAG ?: 'N/A'}" } } always { sh "docker image prune -f --filter 'dangling=true' || true" } } } // ============================================ // helpers // ============================================ def getPreviousImageTag() { try { def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" def tag = sh( script: """ ssh ${sshOpts} root@${env.DEPLOY_HOST} ' docker inspect --format="{{.Config.Image}}" ${env.CONTAINER_NAME} 2>/dev/null \ | sed "s|.*:||" \ || echo "latest" ' 2>/dev/null || echo "latest" """, returnStdout: true ).trim() return tag ?: 'latest' } catch (Exception e) { return 'latest' } } def waitForHealthy(String sshOpts) { def maxAttempts = (env.HEALTH_CHECK_TIMEOUT.toInteger() / env.HEALTH_CHECK_INTERVAL.toInteger()) as int echo "⏳ 等待 ${env.APP_NAME} 健康(最多 ${env.HEALTH_CHECK_TIMEOUT}s)..." sh """ ssh ${sshOpts} root@${env.DEPLOY_HOST} ' set -e for i in \$(seq 1 ${maxAttempts}); do STATUS=\$(docker inspect --format="{{.State.Health.Status}}" ${env.CONTAINER_NAME} 2>/dev/null || echo "no-healthcheck") case "\$STATUS" in healthy) echo "✅ ${env.APP_NAME} healthy"; exit 0;; unhealthy) echo "❌ ${env.APP_NAME} unhealthy" docker logs --tail 100 ${env.CONTAINER_NAME} exit 1;; no-healthcheck) if curl -fsS http://localhost:${env.HOST_PORT}/ -o /dev/null; then echo "✅ ${env.APP_NAME} responds 200"; exit 0 fi;; esac ELAPSED=\$((i * ${env.HEALTH_CHECK_INTERVAL})) echo "⏳ \${ELAPSED}s/${env.HEALTH_CHECK_TIMEOUT}s ..." sleep ${env.HEALTH_CHECK_INTERVAL} done echo "❌ Health check timeout" docker logs --tail 100 ${env.CONTAINER_NAME} || true exit 1 ' """ } def rollback(String sshOpts) { sh """ ssh ${sshOpts} root@${env.DEPLOY_HOST} ' set -e cd ${env.DEPLOY_PATH} export IMAGE_TAG=${env.PREVIOUS_IMAGE_TAG} export REGISTRY_HOST=${env.REGISTRY} export HOST_PORT=${env.HOST_PORT} docker compose -f docker-compose.frontend.yml pull docker compose -f docker-compose.frontend.yml up -d --force-recreate ' """ echo "✅ Rolled back ${env.APP_NAME} to ${env.PREVIOUS_IMAGE_TAG}" } def checkRemoteDiskOrFail(String host, String role) { def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" def minPct = Integer.parseInt(env.DISK_FREE_MIN_PCT) def warnPct = Integer.parseInt(env.DISK_FREE_WARN_PCT) def freePct try { freePct = sh( script: "ssh ${sshOpts} root@${host} \"df -P / | awk 'NR==2 { print 100 - \\\$5+0 }'\"", returnStdout: true ).trim() as int } catch (Exception e) { error("❌ [${role}] SSH 到 ${host} 失败:${e.message}") } echo " ${role}@${host}: 根分区空闲 ${freePct}%" if (freePct < minPct) { error("❌ [${role}] ${host} 空闲仅 ${freePct}% < ${minPct}%,终止部署") } else if (freePct < warnPct) { echo "⚠️ [${role}] ${host} 空闲 ${freePct}% < ${warnPct}%,部署后建议清理" } }