diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 000000000..9f47b07e9 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,154 @@ +def serviceModules = [ + 'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'event-bridge-service', 'audit-service' +] + +def serviceJars = [ + 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', + 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', + 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', + 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', + 'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar', + 'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar', + 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', + 'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar', + 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', + 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', + 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' +] + +def commonPaths = [ + 'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/', + 'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile', 'deploy/' +] + +pipeline { + agent { label 'docker-java17' } + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '30')) + } + + parameters { + choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 手工指定;ALL 构建全部服务') + string(name: 'SERVICE', defaultValue: 'gateway', description: 'SELECT 模式填写服务名,多个服务用英文逗号分隔') + choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') + booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署') + booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/your-group/baiyue-firstleg', description: 'GitLab Container Registry 项目地址') + } + + environment { + REGISTRY_CREDENTIALS = 'gitlab-registry-credentials' + } + + stages { + stage('确定构建范围') { + steps { + script { + List selected = [] + if (params.BUILD_MODE == 'ALL') { + selected = serviceModules + } else if (params.BUILD_MODE == 'SELECT') { + selected = params.SERVICE.split(',').collect { it.trim() }.findAll { it } + } else { + String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() + if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) { + selected = serviceModules + } else { + List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) + .trim().split('\\n').collect { it.trim() }.findAll { it } + boolean commonChanged = changed.any { path -> commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } } + selected = commonChanged ? serviceModules : serviceModules.findAll { module -> + changed.any { it == module || it.startsWith("${module}/") } + } + } + } + + List invalid = selected.findAll { !serviceModules.contains(it) } + if (invalid) { + error("未知服务: ${invalid.join(', ')};允许值: ${serviceModules.join(', ')}") + } + selected = selected.unique() + if (!selected) { + currentBuild.description = '没有需要构建的业务服务' + env.NO_SERVICE_CHANGE = 'true' + env.SELECTED_SERVICES = '' + } else { + env.NO_SERVICE_CHANGE = 'false' + env.SELECTED_SERVICES = selected.join(',') + env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}" + echo "本次构建服务: ${env.SELECTED_SERVICES}" + } + } + } + } + + stage('Maven 测试与打包') { + when { expression { env.NO_SERVICE_CHANGE != 'true' } } + steps { + script { + String testOption = params.SKIP_TESTS ? '-DskipTests' : '' + sh "mvn --batch-mode --errors --fail-at-end -pl ${env.SELECTED_SERVICES} -am clean package ${testOption}" + } + } + post { + always { + junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' + } + } + } + + stage('构建并推送镜像') { + when { expression { env.NO_SERVICE_CHANGE != 'true' } } + steps { + script { + String registryHost = params.IMAGE_REPOSITORY.tokenize('/')[0] + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' + env.SELECTED_SERVICES.split(',').each { service -> + String image = "${params.IMAGE_REPOSITORY}/${service}:${env.IMAGE_TAG}" + sh "docker build -f deploy/docker/Dockerfile --build-arg JAR_FILE=${serviceJars[service]} -t ${image} ." + sh "docker push ${image}" + } + sh "docker logout ${registryHost} || true" + } + } + } + } + + stage('生产部署确认') { + when { + allOf { + expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' } + } + } + steps { + input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 部署到生产环境?", ok: '确认部署' + } + } + + stage('部署') { + when { + allOf { + expression { params.DEPLOY && env.NO_SERVICE_CHANGE != 'true' } + } + } + steps { + script { + String credentialId = "firstleg-${params.ENVIRONMENT}-env" + withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { + sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${params.IMAGE_REPOSITORY}' '${env.IMAGE_TAG}' '${env.SELECTED_SERVICES}' \"\$DEPLOY_ENV_FILE\"" + } + } + } + } + } + + post { + always { cleanWs(deleteDirs: true, notFailBuild: true) } + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 000000000..e11104038 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,177 @@ +name: firstleg-${DEPLOY_ENV:-test} + +x-common: &common + restart: unless-stopped + networks: [firstleg-network] + environment: &common-environment + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn} + NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} + NACOS_USERNAME: ${NACOS_USERNAME} + NACOS_PASSWORD: ${NACOS_PASSWORD} + NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} + NACOS_NAMESPACE: ${NACOS_NAMESPACE:-public} + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_DATABASE: ${REDIS_DATABASE:-0} + JWT_SECRET: ${JWT_SECRET} + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + REGION_CODE: ${REGION_CODE:-CN} + DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} + DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} + TZ: Asia/Shanghai + logging: + driver: json-file + options: + max-size: 100m + max-file: "5" + +services: + gateway: + <<: *common + image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} + container_name: firstleg-gateway + ports: ["${GATEWAY_PORT:-9000}:9000"] + environment: + <<: *common-environment + SERVER_PORT: 9000 + SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} + + auth: + <<: *common + image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} + container_name: firstleg-auth + ports: ["${AUTH_PORT:-9100}:9100"] + environment: + <<: *common-environment + SERVER_PORT: 9100 + AUTH_DB_URL: ${AUTH_DB_URL} + AUTH_DB_USERNAME: ${AUTH_DB_USERNAME} + AUTH_DB_PASSWORD: ${AUTH_DB_PASSWORD} + + iam: + <<: *common + image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} + container_name: firstleg-iam + ports: ["${IAM_PORT:-9201}:9201"] + environment: + <<: *common-environment + SERVER_PORT: 9201 + IAM_DB_URL: ${IAM_DB_URL} + IAM_DB_USERNAME: ${IAM_DB_USERNAME} + IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} + + firstleg: + <<: *common + image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} + container_name: firstleg-business + ports: ["${FIRSTLEG_PORT:-9202}:9202"] + environment: + <<: *common-environment + SERVER_PORT: 9202 + FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} + FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} + FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} + + order: + <<: *common + image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} + container_name: firstleg-order + ports: ["${ORDER_PORT:-9204}:9204"] + environment: + <<: *common-environment + SERVER_PORT: 9204 + ORDER_DB_URL: ${ORDER_DB_URL} + ORDER_DB_USERNAME: ${ORDER_DB_USERNAME} + ORDER_DB_PASSWORD: ${ORDER_DB_PASSWORD} + + warehouse: + <<: *common + image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} + container_name: firstleg-warehouse + ports: ["${WAREHOUSE_PORT:-9205}:9205"] + environment: + <<: *common-environment + SERVER_PORT: 9205 + WAREHOUSE_DB_URL: ${WAREHOUSE_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_DB_PASSWORD} + + file: + <<: *common + image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} + container_name: firstleg-file + ports: ["${FILE_PORT:-9206}:9206"] + volumes: ["firstleg-file-data:/data/files"] + environment: + <<: *common-environment + SERVER_PORT: 9206 + FILE_DB_URL: ${FILE_DB_URL} + FILE_DB_USERNAME: ${FILE_DB_USERNAME} + FILE_DB_PASSWORD: ${FILE_DB_PASSWORD} + FILE_STORAGE_ROOT: /data/files + + print: + <<: *common + image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} + container_name: firstleg-print + ports: ["${PRINT_PORT:-9207}:9207"] + environment: + <<: *common-environment + SERVER_PORT: 9207 + PRINT_DB_URL: ${PRINT_DB_URL} + PRINT_DB_USERNAME: ${PRINT_DB_USERNAME} + PRINT_DB_PASSWORD: ${PRINT_DB_PASSWORD} + + integration-service: + <<: *common + image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} + container_name: firstleg-integration + ports: ["${INTEGRATION_PORT:-9210}:9210"] + environment: + <<: *common-environment + SERVER_PORT: 9210 + BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false} + BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-} + BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-} + AMAZON_SP_API_ENABLED: ${AMAZON_SP_API_ENABLED:-false} + AMAZON_SP_API_CLIENT_ID: ${AMAZON_SP_API_CLIENT_ID:-} + AMAZON_SP_API_CLIENT_SECRET: ${AMAZON_SP_API_CLIENT_SECRET:-} + AMAZON_SP_API_REFRESH_TOKEN: ${AMAZON_SP_API_REFRESH_TOKEN:-} + AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-} + AMAZON_SP_API_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-} + AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-} + + event-bridge-service: + <<: *common + image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} + container_name: firstleg-event-bridge + ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] + environment: + <<: *common-environment + SERVER_PORT: 9300 + EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} + EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} + EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} + + audit-service: + <<: *common + image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} + container_name: firstleg-audit + ports: ["${AUDIT_PORT:-9700}:9700"] + environment: + <<: *common-environment + SERVER_PORT: 9700 + AUDIT_DB_URL: ${AUDIT_DB_URL} + AUDIT_DB_USERNAME: ${AUDIT_DB_USERNAME} + AUDIT_DB_PASSWORD: ${AUDIT_DB_PASSWORD} + +networks: + firstleg-network: + name: firstleg-${DEPLOY_ENV:-test}-network + +volumes: + firstleg-file-data: + name: firstleg-${DEPLOY_ENV:-test}-file-data diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 000000000..1687968a2 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,22 @@ +FROM eclipse-temurin:17-jre-alpine + +RUN apk add --no-cache curl \ + && addgroup -S app \ + && adduser -S -G app -u 10001 app + +WORKDIR /app + +ARG JAR_FILE +COPY --chown=app:app ${JAR_FILE} /app/app.jar + +USER app + +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" \ + SERVER_PORT=8080 + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1 + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh new file mode 100644 index 000000000..465be68c3 --- /dev/null +++ b/deploy/scripts/deploy-services.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 5 ]; then + echo "用法: $0 <环境> <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 + exit 2 +fi + +DEPLOY_ENV="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +SERVICE_CSV="$4" +ENV_FILE="$5" + +if [ ! -f "$ENV_FILE" ]; then + echo "环境文件不存在: $ENV_FILE" >&2 + exit 2 +fi + +SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') +case " $SERVICES " in + *" gateway "*) SERVICES="$SERVICES" ;; + *) SERVICES="$SERVICES gateway" ;; +esac + +export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG +docker compose \ + --project-name "firstleg-${DEPLOY_ENV}" \ + --env-file "$ENV_FILE" \ + -f deploy/docker-compose.yml \ + pull $SERVICES + +docker compose \ + --project-name "firstleg-${DEPLOY_ENV}" \ + --env-file "$ENV_FILE" \ + -f deploy/docker-compose.yml \ + up -d --remove-orphans $SERVICES + +docker compose \ + --project-name "firstleg-${DEPLOY_ENV}" \ + --env-file "$ENV_FILE" \ + -f deploy/docker-compose.yml \ + ps $SERVICES diff --git a/deploy/service-map.yml b/deploy/service-map.yml new file mode 100644 index 000000000..6995fcb9d --- /dev/null +++ b/deploy/service-map.yml @@ -0,0 +1,45 @@ +services: + gateway: + artifactId: gateway + jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar + port: 9000 + auth: + artifactId: auth-service + jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar + port: 9100 + iam: + artifactId: iam-service + jar: iam/target/iam-service-1.0.0-SNAPSHOT.jar + port: 9201 + firstleg: + artifactId: firstleg-service + jar: firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar + port: 9202 + order: + artifactId: order-service + jar: order/target/order-service-1.0.0-SNAPSHOT.jar + port: 9204 + warehouse: + artifactId: warehouse-service + jar: warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar + port: 9205 + file: + artifactId: file-service + jar: file/target/file-service-1.0.0-SNAPSHOT.jar + port: 9206 + print: + artifactId: print-service + jar: print/target/print-service-1.0.0-SNAPSHOT.jar + port: 9207 + integration-service: + artifactId: integration-service + jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar + port: 9210 + event-bridge-service: + artifactId: event-bridge-service + jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar + port: 9300 + audit-service: + artifactId: audit-service + jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar + port: 9700 diff --git a/docs/jenkins-multi-service-deployment.md b/docs/jenkins-multi-service-deployment.md new file mode 100644 index 000000000..c27f47ff7 --- /dev/null +++ b/docs/jenkins-multi-service-deployment.md @@ -0,0 +1,86 @@ +# 头程单仓多服务构建部署说明 + +## 一、适用范围 + +当前 `baiyue-firstleg` 是一个 Git 仓库,包含 11 个可独立运行的服务和 7 个公共模块。部署时无需拆成多个仓库,Jenkins 使用 Maven `-pl ... -am` 只构建目标服务及其依赖,并为每个服务生成独立 Docker 镜像。 + +MySQL、Redis、Nacos 是外部基础设施,不包含在业务 Compose 文件中。各业务库仍使用独立数据库,往来单位数据继续归属 `baiyue_warehouse`。 + +## 二、文件说明 + +| 文件 | 用途 | +| --- | --- | +| `Jenkinsfile` | 自动识别变更、测试、打包、构建镜像、推送和部署 | +| `deploy/docker/Dockerfile` | 11 个服务共用的 Java 17 非 root 镜像模板 | +| `deploy/service-map.yml` | 模块、JAR 和端口的唯一映射清单 | +| `deploy/docker-compose.yml` | 11 个业务服务的统一编排文件 | +| `deploy/env/.env.test.example` | 测试环境变量模板,不含真实密码 | +| `deploy/scripts/deploy-services.sh` | Jenkins 节点上的增量部署脚本 | + +## 三、Jenkins 前置条件 + +Jenkins 新建“多分支流水线”或“流水线”,脚本路径填写 `Jenkinsfile`。执行节点标签为 `docker-java17`,节点需要安装: + +- JDK 17 +- Maven 3.8.8 以上 +- Docker Engine +- Docker Compose V2 +- Git + +Jenkins 执行用户必须有运行 Docker 的权限。部署脚本当前在 Jenkins 节点执行,因此该节点应位于目标服务器,或将同一标签绑定到对应环境的部署节点。 + +## 四、必须创建的 Jenkins 凭据 + +| Credentials ID | 类型 | 内容 | +| --- | --- | --- | +| `gitlab-registry-credentials` | Username with password | GitLab Deploy Token 用户名和令牌,至少有 `read_registry`、`write_registry` 权限 | +| `firstleg-test-env` | Secret file | 根据 `.env.test.example` 创建的测试环境 `.env` | +| `firstleg-staging-env` | Secret file | 预发布环境 `.env`,使用预发布基础设施地址和密钥 | +| `firstleg-production-env` | Secret file | 生产环境 `.env`,使用生产基础设施地址和密钥 | + +GitLab 仓库拉取如果不是由 Jenkins GitLab 集成统一授权,还需单独创建只读仓库凭据。任何 MySQL、Redis、Nacos、JWT 和第三方平台密钥都不得直接填写在 Jenkinsfile 或提交到 Git。 + +## 五、流水线参数 + +| 参数 | 推荐值 | 说明 | +| --- | --- | --- | +| `BUILD_MODE` | `AUTO` | 自动分析上次成功构建之后的 Git 变更 | +| `SERVICE` | 按需 | `SELECT` 时填写,如 `order,warehouse` | +| `ENVIRONMENT` | `test` | 可选 `test`、`staging`、`production` | +| `DEPLOY` | 测试环境勾选 | 是否在镜像推送后执行部署 | +| `SKIP_TESTS` | 不勾选 | 临时调试才跳过测试 | +| `IMAGE_REPOSITORY` | GitLab 项目镜像地址 | 示例 `gitlab.example.com/logistics/baiyue-firstleg` | + +公共模块、根 POM 或 Dockerfile 变化会构建全部 11 个服务。仅 `order/**` 变化时执行: + +```bash +mvn -pl order -am clean package +``` + +`order` 与 `warehouse` 同时变化时只调用一次 Maven: + +```bash +mvn -pl order,warehouse -am clean package +``` + +这样可避免多个并发 Maven 任务同时改写公共模块的 `target` 目录。 + +## 六、镜像与部署规则 + +每个镜像使用 Git 提交短 SHA 作为不可变标签,例如: + +```text +gitlab.example.com/logistics/baiyue-firstleg/order:a1b2c3d4e5f6 +``` + +测试和预发布环境勾选 `DEPLOY` 后自动部署。生产环境会额外出现人工确认,确认内容包含服务列表和镜像标签。增量部署会自动带上 `gateway`,保证网关配置与业务服务保持可用;不会重启未选择的其他业务服务。 + +首次部署建议使用 `BUILD_MODE=ALL`。基础设施必须先可用,并已创建以下数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_firstleg`、`baiyue_order`、`baiyue_warehouse`、`baiyue_file`、`baiyue_print`、`baiyue_event_bridge`、`baiyue_audit`。 + +## 七、GitLab CI 与 Jenkins 职责 + +现有 `.gitlab-ci.yml` 仍执行全量校验和打包,适合合并请求质量检查;Jenkins 负责镜像构建与部署。正式启用 Jenkins 后,建议把 GitLab CI 的“打包服务”任务限制为合并请求或删除,避免一次提交重复进行两次完整打包。 + +## 八、回滚 + +回滚不重新编译代码。在 Jenkins 用 `SELECT` 选中服务,将部署环境 `.env` 中的镜像标签临时改为已验证的历史 SHA,或在服务器执行同一 Compose 文件指定历史 `IMAGE_TAG` 后 `up -d`。生产回滚仍应保留人工确认和操作记录。