From 2fa613fd44895479d64cee5d0f22697732e7d36e Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 28 Apr 2026 14:40:47 +0800 Subject: [PATCH] =?UTF-8?q?build(ci):=20=E6=96=B0=E5=A2=9E=20CI/CD=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20=E4=BF=AE=E5=A4=8D=203=20=E4=B8=AA?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - Jenkinsfile:master→PROD(172.17.16.14)、release/next→STAGING(172.17.16.7) 镜像推 172.17.16.7:5000/aiot-iot-ui,宿主机暴露 90 - docker-compose.frontend.yml:nginx 容器加入 1panel-network - 根 Dockerfile + apps/web-antd/nginx.conf:与 vben 前端结构对齐 nginx 反代 /admin-api → http://aiot-gateway:48080 修复: - .env.production VITE_GLOB_API_URL 硬编码 127.0.0.1 → /admin-api(走 nginx 反代) - scripts/deploy/Dockerfile 引用不存在的 playground/dist → apps/web-antd/dist 并改用 turbo 单 filter 构建(与根 Dockerfile 一致) --- Dockerfile | 60 ++++++ Jenkinsfile | 338 ++++++++++++++++++++++++++++++++++ apps/web-antd/.env.production | 6 +- apps/web-antd/nginx.conf | 64 +++++++ docker-compose.frontend.yml | 35 ++++ scripts/deploy/Dockerfile | 6 +- 6 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 Dockerfile create mode 100644 Jenkinsfile create mode 100644 apps/web-antd/nginx.conf create mode 100644 docker-compose.frontend.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..28c980511 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# ============================== +# 1️⃣ 构建阶段 +# ============================== +FROM node:20-alpine AS builder + +WORKDIR /app + +# ------------------------------ +# npm / pnpm registry 配置 +# ------------------------------ +RUN npm config set registry https://registry.npmmirror.com && \ + echo "registry=https://registry.npmmirror.com" > ~/.npmrc + +# pnpm registry 默认指向 1Panel 内部的 Verdaccio(与 vben 前端共用) +# 如果 Jenkins 容器不在 1panel-network 网络里,可以通过 build-arg 覆盖: +# docker build --build-arg PNPM_REGISTRY=https://registry.npmmirror.com ... +ARG PNPM_REGISTRY=http://1Panel-verdaccio-Ynee:4873/ +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@10.22.0 --activate +RUN pnpm config set registry $PNPM_REGISTRY +RUN pnpm config set store-dir /pnpm/store + +# ------------------------------ +# 复制源码 +# ------------------------------ +COPY package.json pnpm-lock.yaml turbo.json pnpm-workspace.yaml ./ +COPY packages packages +COPY apps apps +COPY internal internal + +# ------------------------------ +# 安装依赖 + 构建 +# network-concurrency 限制并发下载,降低 CI 带宽占用 +# ------------------------------ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --network-concurrency 2 + +# 限制 turbo 并发与 Node 内存,避免在 8GB 服务器上 OOM +ENV NODE_OPTIONS="--max-old-space-size=1024 --expose-gc" +ENV UV_THREADPOOL_SIZE=2 +RUN pnpm exec turbo build --filter=@vben/web-antd --concurrency=1 + +# ============================== +# 2️⃣ 运行阶段 +# ============================== +FROM nginx:alpine + +# 移除默认配置 +RUN rm /etc/nginx/conf.d/default.conf + +# 复制自定义 Nginx 配置(含 /admin-api 反代到后端 gateway) +COPY apps/web-antd/nginx.conf /etc/nginx/conf.d/default.conf + +# 复制构建产物 +COPY --from=builder /app/apps/web-antd/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 000000000..398d42bc0 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,338 @@ +// ============================================ +// 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}%,部署后建议清理" + } +} diff --git a/apps/web-antd/.env.production b/apps/web-antd/.env.production index 13efa0ac1..cb37293de 100644 --- a/apps/web-antd/.env.production +++ b/apps/web-antd/.env.production @@ -1,9 +1,9 @@ VITE_BASE=/ -# 请求路径 -VITE_BASE_URL=http://127.0.0.1:48080 +# 请求路径(容器部署时走 nginx 反代到后端 gateway,写相对路径即可) +VITE_BASE_URL= # 接口地址 -VITE_GLOB_API_URL=http://127.0.0.1:48080/admin-api +VITE_GLOB_API_URL=/admin-api # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务 VITE_UPLOAD_TYPE=server diff --git a/apps/web-antd/nginx.conf b/apps/web-antd/nginx.conf new file mode 100644 index 000000000..0fb9a4a23 --- /dev/null +++ b/apps/web-antd/nginx.conf @@ -0,0 +1,64 @@ +# 配置 DNS 解析器(使用 Docker 内置 DNS 解析容器名) +resolver 127.0.0.11 valid=30s; + +server { + listen 80; + server_name localhost; + + # 开启 gzip 压缩 + gzip on; + gzip_min_length 1k; + gzip_comp_level 6; + gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml; + gzip_vary on; + gzip_disable "MSIE [1-6]\."; + + root /usr/share/nginx/html; + index index.html; + + # API 反向代理到后端服务 + # 后端容器名为 aiot-gateway,与本前端容器需在同一 Docker 网络(1panel-network) + location /admin-api { + client_max_body_size 100M; + + proxy_pass http://aiot-gateway:48080/admin-api; + + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 后端短暂不可达时尝试一次重试 + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; + proxy_next_upstream_tries 2; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 跨域响应头(仅作兜底,正常同域不会触发) + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always; + add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + + if ($request_method = 'OPTIONS') { + return 204; + } + } + + # SPA 路由刷新兜底 + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/docker-compose.frontend.yml b/docker-compose.frontend.yml new file mode 100644 index 000000000..4d3ad1f1a --- /dev/null +++ b/docker-compose.frontend.yml @@ -0,0 +1,35 @@ +# ============================================ +# AIoT IoT UI - 部署 Compose +# 由 Jenkins 在部署阶段同步到 ${DEPLOY_PATH} +# 通过环境变量注入: +# IMAGE_TAG 镜像版本号 +# REGISTRY_HOST 镜像仓库地址(含端口) +# HOST_PORT 宿主机暴露端口 +# ============================================ +version: '3.8' + +networks: + default: + name: 1panel-network + external: true + +services: + aiot-iot-ui: + image: ${REGISTRY_HOST:-172.17.16.7:5000}/aiot-iot-ui:${IMAGE_TAG:-latest} + container_name: aiot-iot-ui + restart: on-failure:5 + ports: + - "${HOST_PORT:-90}:80" + environment: + TZ: Asia/Shanghai + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost/ >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 15s + deploy: + resources: + limits: + memory: 256m + cpus: '0.5' diff --git a/scripts/deploy/Dockerfile b/scripts/deploy/Dockerfile index 86f439f24..d966b804e 100644 --- a/scripts/deploy/Dockerfile +++ b/scripts/deploy/Dockerfile @@ -15,7 +15,7 @@ COPY . /app # 安装依赖 RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -RUN pnpm run build --filter=\!./docs +RUN pnpm exec turbo build --filter=@vben/web-antd --concurrency=1 RUN echo "Builder Success 🎉" @@ -25,8 +25,8 @@ FROM nginx:stable-alpine AS production RUN echo "types { application/javascript js mjs; }" > /etc/nginx/conf.d/mjs.conf \ && rm -rf /etc/nginx/conf.d/default.conf -# 复制构建产物 -COPY --from=builder /app/playground/dist /usr/share/nginx/html +# 复制构建产物(vben monorepo 主入口为 apps/web-antd) +COPY --from=builder /app/apps/web-antd/dist /usr/share/nginx/html # 复制 nginx 配置 COPY --from=builder /app/scripts/deploy/nginx.conf /etc/nginx/nginx.conf