如何用JenkinsGithub实现Nginx配置代码化自动部署?

摘要:# 项目背景: 各个环境Nginx 配置变更不透明,团队内部和其他团队之间协作方式不够高效,因为采用项目迭代的方式进行配置代码化变更。 # 目录结构: ├── environments │ ├── dev │ │ ├── dev-mgt-m
# 项目背景: 各个环境Nginx 配置变更不透明,团队内部和其他团队之间协作方式不够高效,因为采用项目迭代的方式进行配置代码化变更。 # 目录结构: ├── environments │ ├── dev │ │ ├── dev-mgt-mcrio.icbc.trade │ │ │ └── Dev-connex-mgt-mcrio.conf │ │ ├── dev-ops-am.icbc.com │ │ │ └── Dev-website-ops.conf │ │ ├── dev-web.icbc.com │ │ │ └── Dev-website-client.conf │ │ ├── mgt-dev.icbc.trade │ │ │ └── Dev-mgt-mcrio.conf │ │ └── summit-dev.icbc.com │ │ └── Dev-website-summit.conf │ ├── fat │ │ ├── fat-mgt-mcrio.icbc.com │ │ │ └── Fat-mgt-mcrio.conf │ │ ├── fat-web.icbc.com │ │ │ └── Fat-website-client.conf │ │ └── summit-fat.icbc.com │ │ └── Fat-website-summit.conf │ ├── mirror │ ├── qa │ │ ├── qa-mgt-mcrio.icbc.trade │ │ │ └── QA-connex-mgt-mcrio.conf │ │ ├── qa-mgt-mcrio.icbc.com │ │ │ └── QA-mgt-mcrio.conf │ │ ├── qa-ops-am.icbc.com │ │ │ └── Qa-website-ops.conf │ │ ├── qa-web.icbc.trade │ │ │ └── connex-qa-website-client.conf │ │ ├── qa-web.icbc.com │ │ │ └── Qa-website-client.conf │ │ ├── qa-web2.icbc.com │ │ │ └── Qa2-website-client.conf │ │ └── summit-qa.icbc.com │ │ └── Qa-website-summit.conf │ ├── sit │ │ ├── sit-mgt-mcrio.icbc.trade │ │ │ └── Sit-connex-mgt-mcrio.conf │ │ ├── sit-mgt-mcrio.icbc.com │ │ │ └── Sit-mgt-mcrio.conf │ │ ├── sit-web.icbc.trade │ │ │ └── connex-sit-website-client.conf │ │ ├── sit-web.icbc.com │ │ │ └── Sit-website-client.conf │ │ └── summit-sit.icbc.com │ │ └── Sit-website-summit.conf │ └── uat │ ├── summit-uat.icbc.com │ │ └── Uat-website-summit.conf │ ├── uat-mgt-mcrio.icbc.trade │ │ └── Uat-connex-mcrio.conf │ ├── uat-mgt-mcrio.icbc.com │ │ └── Uat-mgt-mcrio.conf │ ├── uat-ops-am.icbc.com │ │ └── Uat-website-ops.conf │ ├── uat-web.icbc.trade │ │ └── connex-uat-website-client.conf │ └── uat.icbc.com │ └── Uat-website-client.conf ├── inventory.ini └── README.md --- Github workflows: name: Deploy Nginx on: push: branches: [off] paths: - 'environments/**' env: PKG_DIR: /data/pkg/devops-nginx-config COMMIT_ID: ${{ github.sha }} jobs: deploy: runs-on: [self-hosted, ltp] steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Deploy changed files only run: | CHANGED_FILES=$(git diff --diff-filter=AM --name-only ${{ github.event.before }} ${{ github.sha }} -- 'environments/' | grep '\.conf$' || true) echo "Changed files:" echo "$CHANGED_FILES" if [ -z "$CHANGED_FILES" ]; then echo "No .conf files changed. Nothing to deploy." exit 0 fi DEPLOY_FAILED=false SERVERS=$(echo "$CHANGED_FILES" | awk -F'/' '{print $2"/"$3}' | sort -u) for TARGET in $SERVERS; do ENV=$(echo "$TARGET" | cut -d'/' -f1) SERVER=$(echo "$TARGET" | cut -d'/' -f2) HOST=$(grep -A1 "^\[$ENV:$SERVER\]" inventory.ini | tail -1 | tr -d '[:space:]') echo "" echo "==========================================" echo "🚀 Processing: $SERVER" echo " ENV: $ENV" echo " HOST: $HOST" echo "==========================================" if [ -z "$HOST" ]; then echo "❌ Error: No host found for [$ENV:$SERVER] in inventory.ini" echo " Skipping this server..." continue fi SERVER_FILES=$(echo "$CHANGED_FILES" | grep "^environments/$ENV/$SERVER/" || true) if [ -z "$SERVER_FILES" ]; then echo "⚠️ No files to deploy for $SERVER, skipping..." continue fi STAGING_DIR="/tmp/nginx-staging-${{ env.COMMIT_ID }}" BACKUP_DIR="/data/pkg/devops-nginx-config/$ENV/$SERVER/${{ env.COMMIT_ID }}" ssh root@$HOST "mkdir -p $STAGING_DIR $BACKUP_DIR" # Step 1: 上传到临时目录 echo "📤 Uploading to staging..." for FILE in $SERVER_FILES; do FILENAME=$(basename "$FILE") scp "$FILE" root@$HOST:$STAGING_DIR/$FILENAME echo " ↳ $FILENAME" done # Step 2: 备份当前配置并验证 echo "📦 Backing up current config..." BACKUP_OK=true for FILE in $SERVER_FILES; do FILENAME=$(basename "$FILE") if [[ "$FILENAME" == "nginx.conf" ]]; then DEST="/etc/nginx/nginx.conf" else DEST="/etc/nginx/conf.d/$FILENAME" fi # 记录文件是否为新增(服务器上不存在) if ssh root@$HOST "test -f $DEST"; then if ! ssh root@$HOST "cp $DEST $BACKUP_DIR/$FILENAME"; then echo " ❌ Failed to backup: $FILENAME" BACKUP_OK=false else echo " ↳ Backed up: $FILENAME" fi else # 新文件,写标记以便回滚时知道要删除 ssh root@$HOST "echo 'NEW_FILE' > $BACKUP_DIR/$FILENAME.new_flag" echo " ↳ New file (no backup needed): $FILENAME" fi done # 备份失败则跳过该服务器 if [ "$BACKUP_OK" = false ]; then echo "❌ Backup failed, skipping $SERVER to avoid data loss" ssh root@$HOST "rm -rf $STAGING_DIR" DEPLOY_FAILED=true continue fi # Step 3: 替换为新配置 echo "📤 Deploying new config..." for FILE in $SERVER_FILES; do FILENAME=$(basename "$FILE") if [[ "$FILENAME" == "nginx.conf" ]]; then ssh root@$HOST "cp $STAGING_DIR/$FILENAME /etc/nginx/nginx.conf" else ssh root@$HOST "cp $STAGING_DIR/$FILENAME /etc/nginx/conf.d/$FILENAME" fi echo " ↳ $FILENAME" done # Step 4: 检测语法 echo "🔍 Testing nginx config..." if ssh root@$HOST "nginx -t" 2>&1; then echo "✅ Syntax check passed" ssh root@$HOST "nginx -s reload" echo "✅ Nginx reloaded successfully" else echo "❌ Syntax check FAILED! Rolling back..." ROLLBACK_FAILED=false for FILE in $SERVER_FILES; do FILENAME=$(basename "$FILE") if [[ "$FILENAME" == "nginx.conf" ]]; then DEST="/etc/nginx/nginx.conf" else DEST="/etc/nginx/conf.d/$FILENAME" fi # 有备份则恢复,新增文件则删除 if ssh root@$HOST "test -f $BACKUP_DIR/$FILENAME.new_flag"; then ssh root@$HOST "rm -f $DEST" && echo " ↳ Removed (new file): $FILENAME" || ROLLBACK_FAILED=true elif ssh root@$HOST "test -f $BACKUP_DIR/$FILENAME"; then ssh root@$HOST "cp $BACKUP_DIR/$FILENAME $DEST" && echo " ↳ Restored: $FILENAME" || ROLLBACK_FAILED=true fi done # 验证回滚后 nginx 配置是否正常 if ssh root@$HOST "nginx -t" 2>&1; then echo "✅ Rollback completed, nginx config is valid" else echo "⚠️ WARNING: Rollback done but nginx -t still fails!" echo " Backup dir preserved at: $BACKUP_DIR" echo " Please check server $HOST manually!" fi if [ "$ROLLBACK_FAILED" = true ]; then echo "⚠️ WARNING: Some files failed to rollback on $HOST!" echo " Backup dir: $BACKUP_DIR" fi ssh root@$HOST "rm -rf $STAGING_DIR" DEPLOY_FAILED=true continue fi # 清理临时目录 ssh root@$HOST "rm -rf $STAGING_DIR" # 清理旧备份,保留最近10次 ssh root@$HOST "ls -dt ${{ env.PKG_DIR }}/$ENV/$SERVER/*/ 2>/dev/null | tail -n +11 | xargs rm -rf 2>/dev/null || true" echo "✓ Completed: $SERVER" done if [ "$DEPLOY_FAILED" = true ]; then echo "❌ Some servers failed to deploy, check logs above." exit 1 fi --- Jenkins piplines: pipeline { agent any environment { ANSIBLE_KEY = '/data/runner.key' INVENTORY_FILE = "${WORKSPACE}/inventory.ini" GIT_REPO_URL = "git@github.com:icbc/${JOB_BASE_NAME}.git" } parameters { choice( name: 'DEPLOY_ENV', choices: ['dev', 'fat', 'sit', 'qa', 'uat', 'mirror'], description: '选择要部署的环境' ) choice( name: 'DEPLOY_MODE', choices: ['changed_only', 'full_sync'], description: '部署模式:changed_only=仅部署有变更的文件(默认),full_sync=全量同步' ) string( name: 'BASE_COMMIT', defaultValue: '', description: '[仅 changed_only 模式有效] 对比基准 commit(留空则自动使用 HEAD~1)' ) } stages { stage('Pull Latest') { steps { checkout([ $class: 'GitSCM', branches: [[name: 'nonprod']], extensions: [ [$class: 'CleanCheckout'], [$class: 'CloneOption', depth: 0, shallow: false] ], userRemoteConfigs: [[ url: env.GIT_REPO_URL ]] ]) script { env.GIT_AUTHOR = sh(script: 'git log -1 --pretty=format:%an', returnStdout: true).trim() env.GIT_MSG = sh(script: 'git log -1 --pretty=format:%s', returnStdout: true).trim() env.GIT_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() currentBuild.description = "环境: ${params.DEPLOY_ENV} | 模式: ${params.DEPLOY_MODE} | 分支: nonprod | ${env.GIT_AUTHOR}: ${env.GIT_MSG}" } } } stage('Detect Files') { steps { script { def changedFiles = '' if (params.DEPLOY_MODE == 'changed_only') { def baseCommit = params.BASE_COMMIT?.trim() if (!baseCommit) { baseCommit = sh(script: 'git rev-parse HEAD~1', returnStdout: true).trim() } def headCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() echo "📌 对比范围: ${baseCommit} → ${headCommit}" changedFiles = sh( script: """ git diff --diff-filter=AM --name-only ${baseCommit} ${headCommit} \ -- "environments/${params.DEPLOY_ENV}/" \ | grep -i '\\.conf\$' || true """, returnStdout: true ).trim() if (!changedFiles) { echo "ℹ️ [${params.DEPLOY_ENV}] 在 ${baseCommit.take(8)}..${headCommit.take(8)} 范围内没有变更的 .conf 文件。" echo "💡 如需部署全部文件,请选择 full_sync 模式;或在 BASE_COMMIT 中指定更早的基准 commit。" currentBuild.result = 'NOT_BUILT' return } } else { changedFiles = sh( script: "find environments/${params.DEPLOY_ENV}/ -name '*.conf' | sort || true", returnStdout: true ).trim() } if (!changedFiles) { error("❌ 环境 [${params.DEPLOY_ENV}] 下未找到任何 .conf 文件,请检查 environments/${params.DEPLOY_ENV}/ 目录。") } echo "📄 待部署文件列表:\n${changedFiles}" def servers = changedFiles.split('\n').collect { line -> line.trim().split('/')[2] }.unique() echo "🖥️ 涉及服务器: ${servers.join(', ')}" env.CHANGED_FILES = changedFiles env.TARGET_SERVERS = servers.join(',') } } } stage('Deploy') { when { expression { env.CHANGED_FILES?.trim() } } steps { script { def files = env.CHANGED_FILES.split('\n') def servers = env.TARGET_SERVERS.split(',') servers.each { server -> def serverIP = sh( script: """ awk '/^\\[${params.DEPLOY_ENV}:${server}\\]/{found=1; next} \ found && /^[0-9]/{print; exit} \ found && /^\\[/{exit}' ${INVENTORY_FILE} """, returnStdout: true ).trim() if (!serverIP) { error("❌ inventory.ini 中未找到 [${params.DEPLOY_ENV}:${server}],请检查 inventory.ini。") } echo "🚀 开始部署到: ${server} (${serverIP})" def serverFiles = files.findAll { it.contains("/${server}/") } serverFiles.each { confFile -> def fileName = confFile.split('/').last() def remotePath = "/etc/nginx/conf.d/${fileName}" def backupPath = "/etc/nginx/conf.d/${fileName}.bak" sh """ ansible all -i '${serverIP},' \ -u root --private-key ${ANSIBLE_KEY} \ -m shell \ -a 'test -f ${remotePath} && cp ${remotePath} ${backupPath} || true' """ sh """ ansible all -i '${serverIP},' \ -u root --private-key ${ANSIBLE_KEY} \ -m copy \ -a 'src=${WORKSPACE}/${confFile} dest=${remotePath} mode=0644' """ } def testResult = sh( script: """ ansible all -i '${serverIP},' \ -u root --private-key ${ANSIBLE_KEY} \ -m shell \ -a 'nginx -t 2>&1' """, returnStatus: true ) if (testResult != 0) { echo "❌ nginx -t 校验失败,回滚 ${server}(${serverIP}) 上的配置..." serverFiles.each { confFile -> def fileName = confFile.split('/').last() def remotePath = "/etc/nginx/conf.d/${fileName}" def backupPath = "/etc/nginx/conf.d/${fileName}.bak" sh """ ansible all -i '${serverIP},' \ -u root --private-key ${ANSIBLE_KEY} \ -m shell \ -a 'test -f ${backupPath} && mv ${backupPath} ${remotePath} || rm -f ${remotePath}' """ } error("❌ ${server} nginx 配置校验失败,已回滚,请检查配置文件。") } sh """ ansible all -i '${serverIP},' \ -u root --private-key ${ANSIBLE_KEY} \ -m shell \ -a 'nginx -s reload' """ echo "✅ ${server} (${serverIP}) 部署完成" } } } } } post { success { wrap([$class: 'BuildUser']) { script { sh """ python3 /data/notify_lark.py \ "${env.JOB_NAME}" "${env.BUILD_NUMBER}" "${env.BUILD_URL}" \ "${env.GIT_AUTHOR}" "${env.GIT_COMMIT}" \ "${env.GIT_MSG}" "${env.BUILD_USER}" "${params.DEPLOY_ENV}" "nonprod" "success" """ } } } failure { wrap([$class: 'BuildUser']) { script { sh """ python3 /data/notify_lark.py \ "${env.JOB_NAME}" "${env.BUILD_NUMBER}" "${env.BUILD_URL}" \ "${env.GIT_AUTHOR ?: 'unknown'}" "${env.GIT_COMMIT ?: 'unknown'}" \ "${env.GIT_MSG ?: 'unknown'}" "${env.BUILD_USER ?: 'unknown'}" "${params.DEPLOY_ENV}" "nonprod" "fail" """ } } } notBuilt { echo "⏭️ 无需部署(没有变更的文件)" } always { script { echo "================================" echo "Nginx 配置部署完成" echo " 环境: ${params.DEPLOY_ENV}" echo " 模式: ${params.DEPLOY_MODE}" echo " 状态: ${currentBuild.currentResult}" echo " 分支: nonprod" echo "================================" } } } } 目录结构: ## 快速开始 ### 1. 修改配置文件 ```bash vim environments/dev/dev-web.icbc.com/Dev-website-client.conf ``` ### 2. 提交并推送 ```bash git add . git commit -m "update dev-web nginx config" git push origin nonprod ``` ### 3. 自动部署 推送后 GitHub Actions 自动执行: 1. 检测变更的 `.conf` 文件 2. 从 `inventory.ini` 读取目标主机 IP 3. 上传配置到临时目录 4. 备份当前配置 5. 应用新配置并执行 `nginx -t` 6. 测试通过后 `nginx -s reload` 7. 测试失败自动回滚 ## 主机映射 (inventory.ini) `inventory.ini` 定义了每个域名目录对应的服务器 IP 地址。CI/CD 流程通过此文件确定配置文件应该部署到哪台服务器。 ### 格式说明 ```ini [环境:域名目录] 服务器IP ``` - **环境**: 必须与 `environments/` 下的目录名一致 (dev/fat/sit/qa/uat) - **域名目录**: 必须与 `environments/{env}/` 下的目录名完全一致 - **服务器IP**: 目标 Nginx 服务器的 IP 地址,下一行紧跟 `[env:domain]` ### 示例 ```ini # ========== DEV ========== [dev:mgt-dev.icbc.com] 10.16.137.5 [dev:dev-mgt-mcrio.icbc.com] 10.16.137.5 [dev:dev-web.icbc.com] 10.16.76.31 [dev:dev-ops-am.icbc.com] 10.16.76.31 [dev:summit-dev.icbc.com] 10.16.76.31 # ========== QA ========== [qa:qa-mgt-mcrio.icbc.com] 10.16.60.103 [qa:qa-web.icbc.com] 10.16.76.31 ``` ### 添加新域名 1. 创建目录: `environments/{env}/{domain}/` 2. 添加配置文件: `{Name}.conf` 3. **在 `inventory.ini` 中添加映射** (必须): ```ini [{env}:{domain}] {server_ip} ``` > 如果 `inventory.ini` 中缺少对应配置,部署时会报错 `No host found for [env:domain]` 并跳过该域名。 ## 回滚操作 在 GitHub Actions 页面手动触发 `Rollback Nginx Config` 工作流: | 参数 | 说明 | 示例 | |------|------|------| | env | 环境名称 | dev / fat / sit / qa / uat | | domain | 域名目录 | dev-web.icbc.com | | commit_id | 回滚到的版本 (可选) | 留空使用最近备份 | 备份存储位置: `/data/pkg/devops-nginx-config/{env}/{domain}/{commit_id}/` ## 配置文件部署规则 | 文件名 | 部署位置 | |--------|----------| | `nginx.conf` | `/etc/nginx/nginx.conf` | | `*.conf` (其他) | `/etc/nginx/conf.d/` | ## 注意事项 - 只有 `.conf` 文件的变更会触发部署 - 删除的文件不会触发部署操作 - 每次部署前会自动备份当前配置 - `nginx -t` 失败会自动回滚到部署前状态 Github actions: Jenkins piplines: