AWS 磁盘缩容切换方案及实践

  • 方案评估

    # 目标服务器磁盘更换/迁移 — 方案评估
    
    # **目标服务器磁盘迁移 — 方案评估**
    
    
    
    |项目|内容|
    |---|---|
    |文档版本|v1\.3|
    |状态|待审批|
    |撰写日期|2026\-08\-06|
    |适用范围|目标应用服务器(C\+\+ / Java / Node 部署目标机)数据盘迁移|
    |不适用|Jenkins Agent 本地磁盘迁移(需单独评估)|
    |操作手册|\[方案A\]\(\./cicd\-disk\-runbook\-a\.md\) · \[方案B/C\]\(\./cicd\-disk\-runbook\-b\.md\) · \[方案D\]\(\./cicd\-disk\-runbook\-d\.md\)|
    
    
    
    **变更摘要**:新数据盘挂载点为 \`/data\-app\`,旧盘路径为 \`/data\`。本文提供方案决策依据(背景、成本、对比、风险);具体执行步骤、Playbook、回滚命令见各方案操作手册。
    
    
    
    **\-\-\-**
    
    
    
    ## **目录**
    
    
    
    1. \[背景与动因\]\(\#1\-背景与动因\)
    
    2. \[受影响范围\]\(\#2\-受影响范围\)
    
    3. \[迁移方案概述与对比\]\(\#3\-迁移方案概述与对比\)
    
        - \[方案横向对比\]\(\#30\-方案横向对比\)
    
        - \[方案 A:软链\]\(\#方案\-a软链零代码改动短期过渡\)
    
        - \[方案 B:变量化\]\(\#方案\-b\-ansible\-group\_vars\-变量化推荐长期\)
    
        - \[方案 C:混合\]\(\#方案\-c混合方案分阶段推进\)
    
        - **\[方案 D:EBS 挂载点替换\]\(\#方案\-debs\-挂载点直接替换生产首选\)**
    
    4. \[风险与待确认项\]\(\#4\-风险与待确认项\)
    
    5. \[架构注意事项\]\(\#5\-架构注意事项\)
    
    6. \[磁盘现状与执行规划\]\(\#6\-磁盘现状与执行规划\)
    
    7. \[运维维护指引\]\(\#7\-运维维护指引\)
    
    **\-\-\-**
    
    
    
    ## **1\. 背景与动因**
    
    
    
    ### **1\.1 迁移背景**
    
    
    
    各环境目标服务器(C\+\+ / Java / Node 部署目标机)已完成新 EBS 数据盘挂载,新盘挂载点为 `/data-app`,旧盘路径 `/data` 仍在运行中。本次迁移需要将应用数据从旧盘切换到新盘,同时借助换盘机会对过度配置的 EBS 卷进行缩容降本。
    
    
    
    **迁移规模:**
    
    
    
    \- 目标服务器:**117 台**(跨 7 个环境,IP 去重后)
    
    - 旧盘路径:`/data`;新盘路径:`/data-app`
    
    \- 涉及 CI/CD 代码中约 **\*\*40 处 \`/data\` 硬编码\*\***(Ansible 模板、Jenkins pipeline、GHA workflow)
    
    
    
    ### **1\.2 当前磁盘使用概览**
    
    
    
    > 数据来源:`bash get_instance_type.sh`(2026\-08\-06);费用基准:AWS ap\-northeast\-1,gp3 $0\.096/GB/月。
    > 
    > 「月省」= 方案D换盘时同步缩容(目标盘 = 已用 × 1\.5,向上取整至 10 GB),仅统计使用率 ≤ 50% 的服务器。
    > 
    > 
    
    
    
    \| 环境     \| 台数    \| 总磁盘 \(GB\)   \| 已用 \(GB\)   \| 平均使用率 \| 高风险台数           \| **方案D缩容月省** \|
    
    \| :\-\-\-\-\-\-\- \| :\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-: \| :\-\-\-\-\-\-\-\-: \| :\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-: \|
    
    \| prod     \| 71      \| 9,882         \| 3,157       \| 32%        \| 1 台 ≥83%           \| **$478\.5**        \|
    
    \| dev      \| 10      \| 1,734         \| 883         \| 51%        \| 1 台 ≥80%           \| $20\.2             \|
    
    \| qa       \| 12      \| 1,348         \| 651         \| 48%        \| 1 台 ≥89%           \| $31\.7             \|
    
    \| uat      \| 9       \| 1,139         \| 575         \| 50%        \| —                   \| $13\.6             \|
    
    \| sit      \| 5       \| 600           \| 272         \| 45%        \| —                   \| $17\.3             \|
    
    \| mirror   \| 6       \| 600           \| 289         \| 48%        \| **1100% 🔴**    \| $19\.2             \|
    
    \| fat      \| 4       \| 350           \| 203         \| 58%        \| 1 台 ≥83%           \| $5\.8              \|
    
    \| **合计** \| **117** \| **15,653**    \| **6,030**   \| **39%**    \| **5 台 ≥80%**       \| **$586\.2/月**     \|
    
    
    
    ### **1\.3 紧急告警(须优先处理)**
    
    
    
    |优先级|服务器|环境|使用率|处置建议|
    |---|---|---|---|---|
    
    \| 🔴 P0  \| 10\.19\.8\.162     \| mirror \| **100%** \| 磁盘已满,写入失败,**本周内必须换盘** \|
    
    \| 🔴 P1  \| 10\.15\.96\.95     \| qa     \| 89%      \| 近饱和,本迭代换盘,新盘 ≥ 100 GB   \|
    
    \| 🔴 P1  \| 10\.18\.33\.111    \| fat    \| 83%      \| 近饱和,本迭代换盘                   \|
    
    \| 🔴 P1  \| 10\.20\.8\.71      \| prod   \| 83%      \| 近饱和,本迭代换盘                   \|
    
    \| 🟡 P2  \| 10\.18\.85\.182    \| dev    \| 80%      \| 本迭代处理                           \|
    
    
    
    ### **1\.4 换盘缩容机会(方案 D)**
    
    
    
    方案 D(EBS 挂载点替换)支持换盘时同步缩容——**一次操作同时完成迁移与降本**:
    
    
    
    \- 117 台中 **78 台**使用率 ≤ 50%,存在明显过度配置
    
    \- 缩容后每月可节省 **$586 / 年省 $7,034**
    
    \- 最大单台:\`10\.19\.5\.56\`(prod,1 TB 盘仅使用 3%,纳入日志配额后缩容至 300 GB,月省 **$67\.2**- 详细台账及执行优先级见 \[§6 磁盘现状与执行规划\]\(\#6\-磁盘现状与执行规划\)
    
    ### **1\.5 日志空间扫描分析(LOG\_SIZE\_GB)**
    
    
    
    \> **数据来源**:\`LOG\_SIZE\_GB\` 扫描(2026\-08\-07)。prod / rapidtrade\-prod、uat / uat\-shard\-\* 等存在重复 IP(同一主机出现在多个 inventory),以下为 **inventory 文件维度**统计,非去重主机数。
    
    
    
    #### **扫描命令**
    
    
    
    ```Bash
    # 各环境日志占用快速统计(在 Ansible 控制机执行)
    for env in prod dev qa uat sit fat mirror; do
      echo "=== ${env} ==="
      ansible all -i /data/devops-ansible/inventory/${env} \
        -m shell -a "
          TOTAL=\$(du -s /data 2>/dev/null | awk '{print \$1}')
          LOGS=\$(du -s /data/logs 2>/dev/null | awk '{print \$1}' || echo 0)
          echo \"\$(hostname) total_gb=\$(echo \"scale=1;\$TOTAL/1024/1024\" | bc) log_gb=\$(echo \"scale=1;\$LOGS/1024/1024\" | bc) log_pct=\$(echo \"scale=0;\$LOGS*100/(\$TOTAL+1)\" | bc)%\"
        " --become --forks 20 2>/dev/null | grep -v '^$\|CHANGED\|SUCCESS\|WARNING'
    done
    ```
    
    
    
    #### **各环境日志统计(单位 GB)**
    
    
    
    \| 环境 \| 机器数 \| 中位数 \| P75 \| P90 \| 最大值 \| **建议日志配额** \|
    
    \| :\-\-\- \| :\-\-\-: \| \-\-\-: \| \-\-\-: \| \-\-\-: \| \-\-\-: \| \-\-\-: \|
    
    \| prod \| 65 \| 20\.5 \| 43\.6 \| **134** \| **214\.8** \| **200 GB**(6 台长尾 250 GB)\|
    
    \| rapidtrade\-prod \| 19 \| 21\.9 \| 42\.9 \| **54\.2** \| **58\.9 \| 80 GB** \|
    
    \| uat \| 12 \| 13\.8 \| 37\.7 \| **93\.4 \| 101\.1 \| 150 GB \|**
    
    \| qa \| 13 \| 0\.4 \| 9\.1 \| **61\.9 \| 119\.3 \| 100 GB**(长尾 2150 GB)\|
    
    \| fat \| 6 \| 3\.1 \| 49\.9 \| **67\.1 \| 67\.1 \| 100 GB \|**
    
    \| dev \| 10 \| 2\.8 \| 13\.0 \| **47\.6 \| 47\.6 \| 75 GB \|**
    
    \| sit \| 8 \| 11\.2 \| 16\.8 \| **24\.4 \| 24\.4 \| 50 GB \|**
    
    \| rapidtrade\-uat \| 3 \| 12\.5 \| — \| — \|** 15\.0 \| 50 GB **\|
    
    \| mirror1/2/3 \| 2 each \| 17\.623\.7 \| — \| — \| **36\.3 \| 50 GB \|**
    
    \| stress\_testing \| 2 \| 不可达(SSH 超时)\| — \| — \| — \| 待补充 \|
    
    
    
    **建议日志配额 = P90 × 1\.5,向上取整至 50 GB**(覆盖 \~90% 机器,含约 50% 增长余量)。
    
    
    
    **关键观察:**
    
    
    
    \- **prod 长尾极重**:P90 \~134 GB,最大 215 GB(\`10\.19\.7\.228\` / \`10\.19\.15\.125\`,m7i\.2xlarge,日志占 /data 的 71%)。这 2 台对任何缩容建议均需单独评估,不可套用环境标准规格。
    
    \- **qa 分布双峰**:中位数仅 0\.4 GB(约一半机器日志近零),但 \`10\.17\.11\.179\`(119 GB)/ \`10\.18\.59\.246\`(62 GB)把 P90 拉到 62 GB——这 2 台单独按长尾规格扩容,其余 qa 机器 100 GB 即可。
    
    \- **日志是主要占用项**:几乎所有环境 LOG\_PCT 高水位机器均 \>50%。**换盘和缩容方案必须将日志配额纳入磁盘大小计算**,否则缩容后新盘将很快再次填满(见 §6\.6)。
    
    
    
    **\-\-\-**
    
    
    
    ## **2\. 受影响范围**
    
    
    
    ### **2\.1 目标服务器路径(本次改造核心范围)**
    
    
    
    |路径|用途|服务类型|
    |---|---|---|
    |`/data/api/<程序名>`|当前运行版本符号链接入口|Java / C\+\+|
    |`/data/jar/<host>/<commitid>/<xxx>.jar`|Java JAR 按版本存储|Java|
    |`/data/pkg/<job>/<commit>/`|C\+\+ / Node 制品暂存|C\+\+ / Node|
    |`/data/scripts/<host>.sh`|进程管理启动脚本(Ansible 模板生成)|Java / C\+\+|
    |`/data/scripts/conf/<host>.conf`|部署历史(commitid 记录,回滚依赖)|Java|
    |`/data/www/`|Web 静态资源|Node|
    |`/data/logs/<service>/`|应用运行日志(stdout/stderr 重定向)|Java / C\+\+ / Node|
    
    
    
    ### **2\.2 涉及的 CI/CD 文件**
    
    
    
    #### **仓库内文件(devops\-cicd)**
    
    
    
    |文件|硬编码路径|说明|
    |---|---|---|
    |`ansible/templates/start_script.j2`|`/data/scripts/conf/`、`/data/api/`、`/data/jar/`、`/data/scripts/`|直接硬编码,生成目标服务器启动脚本|
    |`ansible/javacd.yml`|`directory`、`start_script` 变量接收 pipeline 传入的 `/data/...` 路径|Java 部署 playbook|
    |`jenkins/nonprod/java/java-pipeline.groovy`|`directory=/data/jar/...`、`start_script=/data/scripts/...`|extra\-vars 拼接后传 Ansible|
    |`jenkins/nonprod/cpp/cpp-pipeline.groovy`|`/data/api/`、`/data/pkg/`|清理旧版本 \+ 部署|
    |`jenkins/nonprod/cpp/cpp-mds-pipeline.groovy`|`/data/api/`、`/data/pkg/`|同上|
    |`jenkins/nonprod/all/node-static-pipeline.groovy`|`directory=/data/www/...`|extra\-vars 拼接|
    |`.github/workflows/javacd.yaml`|`/data/jar/`、`/data/scripts/`|GHA Java CD|
    |`.github/workflows/c++ci.yaml`|`/data/pkg/`|GHA C\+\+ CD|
    
    
    
    #### **Jenkins Agent 上的脚本**
    
    
    
    |文件|涉及目标服务器的 `/data` 路径|说明|
    |---|---|---|
    
    \| \`java\_deploy\-nonprod\-dev\.sh\` \| \`directory=/data/jar/\{JOB\_NAME\}/\{GIT\_COMMIT\}\`   \| **在改造范围**:通过 Ansible extra\-vars 写到目标机 \|
    
    \| `maven_build-nonprod.sh`     \| 无目标服务器路径                                   \| 不在改造范围(均为 Jenkins Agent 本地路径)        \|
    
    
    
    ### **2\.3 路径归属速查**
    
    
    
    ```Plain Text
    目标服务器(本次改造范围)          Jenkins Agent(不在改造范围)
    ────────────────────────────    ──────────────────────────────────
    /data/api/                        /data/runner.key
    /data/jar/                        /data/devops-ansible/
    /data/pkg/                        /data/ansible/ 及 /data/ansible-nonprod/
    /data/scripts/                    /data/notify_lark.py
    /data/scripts/conf/               /data/settings.xml
    /data/www/                        /data/check_base_image.sh
    /data/logs/                       /data/maven_build-nonprod.sh
                                      /data/java_deploy-nonprod-dev.sh
                                      /data/mnt/LiquidityTech(NFS,另行评估)
    ```
    
    
    
    **\-\-\-**
    
    
    
    ## **3\. 迁移方案概述与对比**
    
    
    
    ### **3\.0 方案横向对比**
    
    
    
    > 评估维度说明:停机时长 = 应用不可用窗口;实施工时 = 含测试验证的总投入人时。
    > 
    > 
    
    
    
    |对比维度|方案 A:软链|方案 B:变量化|方案 C:混合|方案 D:EBS 替换|
    |---|---|---|---|---|
    
    \| **实施工时**         \| \~2 h                 \| \~2 天                \| \~3 天                \| \~3 h                  \|
    
    \| **应用停机时长**     \| ≤ 4 min †            \| \~5 min               \| \~5 min               \| ≤ 3 min †             \|
    
    \| **代码改动量**       \| 0 处                 \| \~40 处               \| \~40 处               \| 0 处                  \|
    
    \| **重启安全性**       \| ⚠️ 需正确处理 fstab   \| ✅ 完全安全           \| ✅ 完全安全           \| ✅ 完全安全            \|
    
    \| **软链永久依赖**     \| 是(长期存在)        \| 否                   \| 否                   \| 否                    \|
    
    \| **回滚难度**         \| 低(unlink \+ mv)    \| 高(回退代码\+重部署) \| 分阶段中等            \| 极低(重新 attach 旧卷)\|
    
    \| **数据风险**         \| 中(fstab 处理不当) \| 中(改动多易漏)      \| 低(分阶段)          \| 低(快照保护)         \|
    
    \| **AWS 原生支持**     \| 否                   \| 否                   \| 否                   \| ✅ 是                 \|
    
    \| **长期可维护性**     \| 差                   \| 好                   \| 好                   \| 最好(路径透明)       \|
    
    \| **适用场景**         \| 临时过渡(≤1周)     \| 搭配 A/D 代码清理    \| 原定标准路径          \| ⭐ **生产首选**        \|
    
    
    
    \> † 两阶段执行(维护窗口前 ≥1h 预同步 \`pre\_sync\_only=true\`,窗口内仅同步增量)下的实际停机窗口。未预同步时停机 = 全量 rsync 时长(最长 55 min),**强烈建议所有主机均先预同步再切换****推荐决策路径:**
    
    
    
    ```Plain Text
    换盘后 /data 路径是否继续使用(CI/CD 和服务脚本不改)?
    │
    ├── 是(策略一:保持 /data,上层透明)
    │   ├── 有 AWS EBS 权限 → 方案 D(无软链,重启安全)[生产首选]
    │   └── 无 AWS 权限 / 今天紧急 → 方案 A(软链 /data → /data-app,临时过渡)
    │
    └── 否(策略二:显式切换到新路径,如 /data-app)
        ├── 今天必须完成 → 方案 A 或 D(紧急落地)+ 下迭代方案 B = 方案 C
        └── 有规划窗口  → 方案 B(引入 data_root 变量,推荐)
    ```
    
    
    
    > 详细分类说明见 \[§3\.0\.1 换盘路径策略\]\(\#301\-换盘路径策略\)。
    > 
    > 
    
    
    
    **\-\-\-**
    
    
    
    ### **3\.0\.1 换盘路径策略**
    
    
    
    换盘方案的核心决策点:**换盘完成后,应用层使用的路径是否改变?**
    
    
    
    **\-\-\-**
    
    
    
    #### **策略一:保持\`/data\` 路径(上层完全透明)**
    
    
    
    换盘后 \`/data\` 路径继续有效,**CI/CD 代码、Ansible 模板、目标机启动脚本、日志路径一行不改**|方案|实现机制|是否遗留软链|需要 AWS 权限|
    |---|---|---|---|
    
    \| **方案 D(首选)** \| fstab UUID 换成新盘,\`/data\` 挂载点物理替换,路径对上层完全透明 \| 否 \| 是(EC2/EBS) \|
    
    \| **方案 A(备选)** \| 新盘挂 \`/data\-app\`,OS 层创建软链 \`/data → /data\-app\` \| 是(长期存在) \| 否 \|
    
    
    
    **无需修改的内容:**
    
    - CI/CD 代码(Jenkins pipeline、GHA workflow、Agent 脚本)
    
    - Ansible 模板 `start_script.j2`
    
    - 目标机启动脚本(`/data/scripts/*.sh`)
    
    - 日志存储路径(`/data/logs/<service>/`):挂载点物理替换后自动使用新盘,路径不变
    
    - 日志采集 Agent 配置(filebeat\.yml / fluent\-bit\.conf / promtail\.yaml):`paths:` 中 `/data/logs/**` 无需改动
    
    **\-\-\-**
    
    
    
    #### **策略二:切换到新路径(如\`/data\-app\`)**
    
    
    
    换盘后应用**显式使用新路径**(\`/data\-app\`、\`/data\-app\` 等),彻底废弃旧路径引用,CI/CD 和服务脚本随之更新。
    
    
    
    > 当前操作手册以 `/data-app` 作为新盘挂载点示例。若希望使用语义更明确的名称(如 `/data-app`),将手册中所有 `/data-app` 和 `data_root` 初始值替换为 `/data-app` 即可,逻辑完全一致。
    > 
    > 
    
    
    
    **必须修改的 11 处位置:**
    
    
    
    |文件|修改内容|
    |---|---|
    |`ansible/inventory/*/group_vars/all.yaml`|新增 `data_root: /data-app`|
    |`ansible/templates/start_script.j2`|`/data/` → `{{ data_root }}/`|
    |`ansible/javacd.yml`|`directory`、`start_script` 路径通过 `data_root` 拼接|
    |`jenkins/.../java-pipeline.groovy`|移除 extra\-vars 路径硬编码|
    |`jenkins/.../cpp-pipeline.groovy`|移除 extra\-vars 路径硬编码|
    |`jenkins/.../cpp-mds-pipeline.groovy`|移除 extra\-vars 路径硬编码|
    |`jenkins/.../node-static-pipeline.groovy`|移除 extra\-vars 路径硬编码|
    |`.github/workflows/javacd.yaml`|`DATA_ROOT: /data-app`|
    |`.github/workflows/c++ci.yaml`|`DATA_ROOT: /data-app`|
    |`java_deploy-nonprod-dev.sh`(Agent)|`directory=` 路径改用 `DATA_ROOT`|
    |日志采集 Agent 配置(filebeat\.yml / fluent\-bit\.conf / promtail\.yaml)|`paths: [/data/logs/**]` → `[/data-app/logs/**]`|
    
    
    
    **目标机必须执行的额外步骤:**
    
    
    
    删除存量启动脚本后触发重新部署(原因:`javacd.yml` 中 `force: no`,模板不会覆盖已有文件):
    
    
    
    ```Bash
    ansible all -i /data/devops-ansible/inventory/<env> \
      -m shell -a "rm -f /data/scripts/*.sh" --become
    # 然后触发一次 Jenkins/GHA 完整部署,使模板以新路径重新生成启动脚本
    ```
    
    
    
    **无需修改的内容:**
    
    - `/etc/fstab`(旧盘 `/data` 和新盘 `/data-app` 挂载条目各自保持不变)
    
    - Jenkins Agent 本地路径相关脚本(不涉及目标机路径的部分)
    
    **实现方式选择:**
    
    
    
    |方式|做法|可维护性|
    |---|---|---|
    
    \| **变量化(推荐,即方案 B)** \| 引入 \`data\_root\` 变量,改一处值影响全部路径 \| 高——未来再换盘只改变量值 \|
    
    \| **直接替换** \| 所有文件中 \`/data\` 逐处替换为 \`/data\-app\`(\~40 处) \| 低——未来换盘仍需逐处修改 \|
    
    
    
    **\-\-\-**
    
    
    
    ### **方案 A:软链(零代码改动,短期过渡)**
    
    
    
    **路径切换机制**
    
    
    
    ```Plain Text
    迁移前:应用/CI/CD  →  /data/*  →  旧 EBS 盘(挂载在 /data)
    
    迁移后:应用/CI/CD  →  /data/*  →  软链 /data → /data-app  →  新 EBS 盘(挂载在 /data-app)
                              ↑
                     CI/CD 代码零改动,OS 层透明重定向
    ```
    
    
    
    应用和 CI/CD 代码感知不到任何变化,仍使用 `/data` 路径,OS 层通过软链将 `/data` 透明转发到 `/data-app`。
    
    
    
    **换盘步骤**
    
    
    
    1\. **Phase 1 预同步**(在线,不停应用):\`rsync /data/ /data\-app/\`,将旧盘数据全量同步到新盘,应用持续写入 \`/data\`
    
    2\. **停止受管服务**:\`fuser \-m /data\` 确认无进程残留
    
    3\. **Phase 2 增量同步**:再次 \`rsync /data/ /data\-app/\`,追同步 Phase 1 期间的新增写入(约 1\-2 min)
    
    4\. **\*\*更新 \`/etc/fstab\`\*\***:① 删除旧盘 UUID 的 \`/data\` 挂载条目;② 新增新盘 UUID 的 \`/data\-app\` 挂载条目(同时备份原 fstab)
    
    5\. **卸载旧盘**:\`umount /data\`,释放挂载点
    
    6\. **备份旧目录**:\`mv /data /data\_backup\_\<timestamp\>\`,释放 \`/data\` 路径名
    
    7\. **挂载新盘**:\`mount /data\-app\`(按 fstab 新条目挂载)
    
    8\. **创建软链**:\`ln \-s /data\-app /data\`
    
    9\. **启动服务,验证**:\`readlink /data\` → \`/data\-app\`;\`df \-h /data\` → 新盘容量
    
    
    
    **各层变更一览**
    
    
    
    |层次|迁移前|迁移后|
    |---|---|---|
    |OS 挂载|旧盘挂载在 `/data`|旧盘卸载;新盘挂载在 `/data-app`|
    |`/data` 路径|挂载目录(旧盘)|软链,指向 `/data-app`|
    |`/etc/fstab`|`UUID=旧 /data ...`|删除旧条目;添加 `UUID=新 /data-app ...`|
    
    \| CI/CD 代码 \| 硬编码 \`/data/\.\.\.\` \| **无变更** \|
    
    \| 目标机启动脚本 \| 路径 \`/data/\.\.\.\` \| **无变更**(软链透明转发) \|
    
    
    
    \> ⚠️ **fstab 易错点**:不能只把 fstab 中的 \`/data\` 挂载点改名为 \`/data\-app\`。那样旧盘 UUID 会挂到 \`/data\-app\`,软链 \`/data → /data\-app\` 实际仍指向旧盘,数据未切换。正确做法:删旧 \`/data\` 条目,新增 \`/data\-app\` 条目(新盘 UUID)。
    
    
    
    **优点**:代码改动 0,实施工时最短(\~2h),回滚只需 \`unlink /data \&\& mv /data\_backup\_\* /data\`
    
    
    
    **缺点**:软链长期存在,隐藏物理挂载点;重启安全依赖 fstab 配置正确
    
    
    
    > 完整 Ansible Playbook、执行命令及回滚步骤见 \[《方案A操作手册》\]\(\./cicd\-disk\-runbook\-a\.md\)。
    > 
    > 
    
    
    
    **\-\-\-**
    
    
    
    ### **方案 B:Ansible group\_vars 变量化(推荐长期)**
    
    
    
    **路径切换机制**
    
    
    
    ```Plain Text
    迁移前:应用/CI/CD  →  硬编码 /data/*  →  旧 EBS 盘(挂载在 /data)
    
    迁移后:应用/CI/CD  →  data_root=/data-app  →  /data-app/*  →  新 EBS 盘(挂载在 /data-app)
                                 ↑
                   group_vars 中改一个变量值影响全部路径
    ```
    
    
    
    切换在 **CI/CD 配置层**完成,OS 挂载点不变(新盘仍在 \`/data\-app\`,旧盘仍在 \`/data\`)。改完 \`data\_root\` 变量后,CI/CD 停止向 \`/data\` 写入,旧盘可后续单独卸载。
    
    
    
    **换盘步骤(分两阶段)**
    
    
    
    **第一阶段:代码变量化**(不停机,不改现有行为)
    
    
    
    1. `group_vars/all.yaml` 新增 `data_root: /data`——先维持值为 `/data`,现有行为完全不变
    
    2. `start_script.j2` 所有 `/data/` 改为 `{{ data_root }}/`
    
    3. Jenkins pipeline 移除 `directory=/data/...` 等 extra\-vars,路径改由 Playbook 通过 `data_root` 拼接
    
    4. GHA workflow `env:` 块仅保留 `DATA_ROOT: /data`;`run:` 步骤改用 `${DATA_ROOT}` 拼接路径(注意:同级 `env:` 块不可互相引用变量,否则展开为空)
    
    5. 合并 PR,触发一次部署验证:`data_root=/data`,效果与原来完全一致
    
    6. 删除目标机旧启动脚本 `rm /data/scripts/*.sh` 并重新部署——解决 `force: no` 导致模板不覆盖问题
    
    **第二阶段:切换到新盘**(停机约 5 分钟)
    
    
    
    1\. **Phase 1 \+ Phase 2 rsync**:\`/data/ → /data\-app/\`(同方案 A 步骤 1\-3)
    
    2\. **停止受管服务**
    
    3\. **修改变量值**:各环境 \`group\_vars/all\.yaml\` 改为 \`data\_root: /data\-app\`——**这是唯一触发路径切换的操作**
    
    4\. **删除目标机旧启动脚本**:\`rm /data/scripts/\*\.sh\`(路径已变,强制重新生成)
    
    5\. **触发一次完整部署**:Ansible 以 \`data\_root=/data\-app\` 重新生成启动脚本,路径指向 \`/data\-app/\.\.\.\`
    
    6\. **启动服务,验证**:\`grep /data\-app /data\-app/scripts/\*\.sh\` 确认新路径
    
    
    
    **各层变更一览**
    
    
    
    |层次|迁移前|迁移后|
    |---|---|---|
    
    \| OS 挂载 \| 旧盘在 \`/data\`;新盘在 \`/data\-app\` \| **两盘挂载不变**(旧盘后续单独卸载) \|
    
    \| `/data` 路径 \| 挂载目录(旧盘) \| 不变(旧盘仍在,CI/CD 不再写入) \|
    
    \| \`/etc/fstab\` \| 两盘条目均存在 \| **无变更** \|
    
    \| CI/CD 代码 \| 硬编码 `/data/...`(\~40 处) \| 改为 `{{ data_root }}/`、`${DATA_ROOT}/` \|
    
    \| 目标机启动脚本 \| 路径 `/data/...` \| 删除重建,路径改为 `/data-app/...` \|
    
    
    
    **变更文件汇总**(约 40 处,覆盖 9 个文件):
    
    
    
    |层次|文件|变更内容|
    |---|---|---|
    |Ansible 模板|`start_script.j2`|`/data/` → `{{ data_root }}/`|
    |Ansible|`javacd.yml`|`directory`、`start_script` 路径|
    |Jenkins|`java/cpp/node-pipeline.groovy`(4 文件)|extra\-vars 路径硬编码|
    |GHA|`javacd.yaml`、`c++ci.yaml`|`SRC_DIR`、`BASE_DIR` 路径|
    |Agent 脚本|`java_deploy-nonprod-dev.sh`|`directory=` 路径|
    
    
    
    **优点**:路径配置化,换盘只改一个变量;长期可维护,各环境独立管理
    
    
    
    **缺点**:改动文件多(\~40 处),存量启动脚本须强制重新生成;旧盘需单独卸载
    
    
    
    > 完整变更步骤和批量命令见 \[《方案B/C操作手册》\]\(\./cicd\-disk\-runbook\-b\.md\)。
    > 
    > 
    
    
    
    **\-\-\-**
    
    
    
    ### **方案 C:混合方案(分阶段推进)**
    
    
    
    **路径切换机制**
    
    
    
    方案 C 无独立机制,是方案 A \+ 方案 B 的时序拆分:
    
    
    
    ```Plain Text
    今天(紧急,零代码改动)        下个迭代(计划内,代码清理)
              ↓                               ↓
         执行方案 A                      执行方案 B
      OS 层软链透明切换               CI/CD 变量化消除硬编码
      停机 < 5 min                    停机 < 5 min
      /data → 软链 → /data-app          data_root 值改为 /data-app
    ```
    
    
    
    \- **第一阶段**:按 \[《方案A操作手册》\]\(\./cicd\-disk\-runbook\-a\.md\) 完成软链迁移,当天落地,代码零改动
    
    \- **第二阶段**:按 \[《方案B/C操作手册》\]\(\./cicd\-disk\-runbook\-b\.md\) 完成 \`data\_root\` 变量化,消除 CI/CD 中 \`/data\` 硬编码
    
    
    
    方案 B 完成后,软链 `/data → /data-app` 可保留(兼容存量脚本)或安全拆除(`unlink /data`)。
    
    
    
    \> ⚠️ 若 EC2 可操作 EBS,建议用**方案 D** 替代方案 A 作为第一阶段,彻底避免软链风险。
    
    
    
    **\-\-\-**
    
    
    
    ### **方案 D:EBS 挂载点直接替换(生产首选)**
    
    
    
    **路径切换机制**
    
    
    
    ```Plain Text
    迁移前:应用/CI/CD  →  /data/*  →  旧 EBS 盘 vol-old(挂载在 /data)
    
    迁移后:应用/CI/CD  →  /data/*  →  新 EBS 盘 vol-new(挂载在 /data)
                              ↑
                  同一挂载点,底层物理磁盘已替换,路径对外完全透明
    ```
    
    
    
    `/data` 始终是真实挂载点(非软链),重启 100% 安全。应用层、CI/CD 代码、启动脚本**完全不感知变化**。
    
    
    
    **换盘步骤**
    
    
    
    1\. **拍快照**:\`aws ec2 create\-snapshot\` 对旧盘打快照,等待状态变为 \`completed\`(回滚保障)
    
    2\. **Phase 1 预同步**(在线,不停应用):\`rsync /data/ /data\-app/\`,全量同步到新盘
    
    3\. **停止受管服务**:\`fuser \-m /data\` 确认无进程残留
    
    4\. **Phase 2 增量同步**:\`rsync /data/ /data\-app/ \-\-delete\`,追同步 Phase 1 期间写入,确保两盘完全一致
    
    5\. **卸载旧盘**:\`umount /data\`
    
    6\. **卸载新盘**:\`umount /data\-app\`(为以 \`/data\` 重新挂载做准备)
    
    7\. **\*\*更新 \`/etc/fstab\`\*\***:① 将 \`/data\` 条目的 UUID 替换为新盘 UUID;② 删除 \`/data\-app\` 条目
    
    8\. **\*\*挂载新盘到 \`/data\`\*\***:\`mount /data\`(新盘以原挂载点挂入,路径对上层完全透明)
    
    9\. **启动服务,验证**:\`df \-h /data\` 显示新盘容量;\`ls /data\` 确认数据完整
    
    
    
    **各层变更一览**
    
    
    
    |层次|迁移前|迁移后|
    |---|---|---|
    
    \| OS 挂载 \| 旧盘在 \`/data\`;新盘在 \`/data\-app\` \| **\*\*新盘挂载在 \`/data\`\*\***;旧盘待后续卸载 \|
    
    \| \`/data\` 路径 \| 挂载目录(旧盘) \| 挂载目录(**新盘**,路径不变) \|
    
    \| `/etc/fstab` \| `UUID=旧 /data ...`;`UUID=新 /data-app ...` \| `UUID=新 /data ...`(旧条目和 `/data-app` 条目均删除) \|
    
    \| CI/CD 代码 \| 硬编码 \`/data/\.\.\.\` \| **无变更** \|
    
    \| 目标机启动脚本 \| 路径 \`/data/\.\.\.\` \| **无变更** \|
    
    
    
    **缩容机会**:换盘时可同步缩容——新盘按实际用量 × 1\.5 创建,一次操作同时完成迁移和降本,可节省 **$586/月**(见 §6)。
    
    
    
    **回滚策略**:
    
    - 快速回滚(旧卷未删除):重新 attach 旧卷 → 恢复 fstab UUID → `mount /data`,全程 \< 5 min
    
    - 安全回滚(有快照):从快照创建新卷 → attach → 更新 fstab → `mount /data`,数据与快照时刻完全一致
    
    **优点**:路径透明、重启安全、无软链、AWS 原生操作、回滚有快照保护
    
    
    
    **缺点**:需要 AWS EC2/EBS 操作权限;停应用约 10 分钟
    
    
    
    > 完整 Ansible Playbook、执行命令、EBS 清理及回滚步骤见 \[《方案D操作手册》\]\(\./cicd\-disk\-runbook\-d\.md\)。
    > 
    > 
    
    
    
    **\-\-\-**
    
    
    
    ## **4\. 风险与待确认项**
    
    
    
    ### **4\.1\`c\+\+cd\.yml\` / \`nodecd\.yml\` 内容未确认**
    
    
    
    `deployc++.yml` 和 `deploynode.yml` 均通过 `import_playbook` 引用 `c++cd.yml` 和 `nodecd.yml`,这两个文件中可能含有目标服务器路径(`/data/pkg/`、`/data/api/` 等),需要补充读取确认后再完善到第 2 节清单中。
    
    
    
    ### **4\.2\`start\_script\.j2\` 回滚路径依赖存量 commitid**
    
    
    
    回滚逻辑读取 `/data/scripts/conf/<host>.conf` 中历史 commitid,再执行:
    
    
    
    ```Plain Text
    ln -sf /data/jar/<host>/<prev_commitid>/<JAR> /data/api/<JAR>
    ```
    
    
    
    |方案|回滚兼容性|
    |---|---|
    |方案 A 软链|透明兼容,无问题|
    |方案 B 变量化|切换 `data_root=/data-app` 后,旧 commitid 的 JAR 必须已通过 rsync 迁移到 `/data-app`,否则回滚失败|
    
    
    
    **建议**:换盘前确保 rsync 包含完整的 \`/data/jar/\` 历史版本目录(\`\-\-delete\` 选项视情况使用)。
    
    
    
    **\-\-\-**
    
    
    
    ## **5\. 架构注意事项**
    
    
    
    ### **Docker 卷挂载(cpp\-mds\-pipeline)**
    
    
    
    `cpp-mds-pipeline.groovy` 中 \`\-v /data:/data\` 映射的是 **\*\*Jenkins Agent 本地 \`/data\`\*\***,不是目标服务器路径,**不在改造范围**。
    
    
    
    ### **NFS 挂载路径**
    
    
    
    `/data/mnt/LiquidityTech` 是 NFS 挂载点(Jenkins Agent \+ 代码覆盖率用途),不在目标服务器本地磁盘上,**不受本次换盘影响**,如需变更需单独评估 NFS 配置。
    
    
    
    ### **存量已部署的启动脚本**
    
    
    
    换盘完成后,目标服务器上已有的 `/data/scripts/<host>.sh` 启动脚本内嵌有 `/data/api/<JAR>` 路径。
    
    
    
    \- **软链方案**:完全兼容,无需处理
    
    \- **变量化方案**:需在 \`data\_root\` 切换后删除旧脚本,并触发一次重新部署使模板重新生成
    
    
    
    ### **/etc/fstab 挂载项**
    
    
    
    若目标服务器的 `/etc/fstab` 中有 `/data` 的挂载条目,执行 `mv /data /data_backup_*` 后重启会导致旧盘重新挂载到 `/data`,软链被覆盖。
    
    
    
    方案 A Playbook(v1\.3\+)的正确处理流程为:
    
    1\. **删除** \`/etc/fstab\` 中旧的 \`/data\` 条目(旧盘 UUID,软链方案下旧盘不再直接挂载)
    
    2\. **添加** 新盘 UUID 的 \`/data\-app\` 持久挂载条目
    
    3. 备份原文件至 `/etc/fstab.bak_disk_migration`(回滚时使用)
    
    4. 若删除后仍有残留条目则报错要求人工处理
    
    > ⚠️ 常见错误:将 fstab 中 `/data` 挂载点改为 `/data-app`——这会把旧盘 UUID 挂到 `/data-app`,重启后软链 `/data → /data-app` 实际指向旧盘数据。
    > 
    > 
    
    
    
    ### **HPA 兼容性(方案 A 不涉及,方案 B 需注意)**
    
    
    
    若目标 Deployment 有 HPA 绑定,切换 `data_root` 后需确认 HPA 的 `minReplicas` 设置,避免恢复时被 HPA 覆盖为非预期副本数。
    
    
    
    **\-\-\-**
    
    
    
    ## **6\. 磁盘现状与执行规划**
    
    
    
    \> **数据来源**:\`bash get\_instance\_type\.sh\` 输出,共 117 台(IP 去重后)。
    
    \> **费用基准**:AWS ap\-northeast\-1,gp3 $0\.096/GB/月。
    
    \> **同步速度估算**:按实例代际(t3: 80 MB/s、c5/m5/r5: 100 MB/s、c6a/m6a/r6a/r6i: 120 MB/s、c7i/m7i/m7a/r7i: 150 MB/s);排除约 40% 日志文件。
    
    
    
    **\-\-\-**
    
    
    
    ### **6\.1 各环境同步时间与窗口**
    
    
    
    \> **Phase1**:在线预同步(不停应用),耗时取决于最大单台服务器。
    
    \> **建议停机窗口** = Phase2 增量(\~3 min)\+ 切换(\~1 min)\+ 服务启动(\~2 min)≈ **6\-9 min**(与盘大小无关,Phase1 已做完大头)。
    
    
    
    \| 环境     \| 台数    \| 总磁盘        \| 总已用       \| 最长 Phase1   \| **建议停机窗口** \| 高风险服务器                       \|
    
    \| :\-\-\-\-\-\-\- \| :\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-\-: \| :\-\-\-\-\-\-\-\-\-\-\-: \| :\-\-\-\-\-\-\-\-\-\-\-\-\-: \| :\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\- \|
    
    \| dev      \| 10      \| 1,734 GB      \| 883 GB       \| **55 min** ⚠️ \| 9 min           \| 10\.18\.17\.2131 TB,54%)          \|
    
    \| fat      \| 4       \| 350 GB        \| 203 GB       \| 7 min         \| 6 min           \| 10\.18\.33\.11183%)                \|
    
    \| qa       \| 12      \| 1,348 GB      \| 651 GB       \| 17 min        \| 6 min           \| 10\.15\.96\.9589%)                 \|
    
    \| sit      \| 5       \| 600 GB        \| 272 GB       \| 7 min         \| 6 min           \| —                                  \|
    
    \| uat      \| 9       \| 1,139 GB      \| 575 GB       \| 18 min        \| 6 min           \| —                                  \|
    
    \| mirror   \| 6       \| 600 GB        \| 289 GB       \| 9 min         \| 6 min           \| **10\.19\.8\.162100% FULL 🔴)**    \|
    
    \| prod     \| 71      \| 9,882 GB      \| 3,157 GB     \| 15 min        \| 6 min           \| 10\.20\.8\.7183%)                  \|
    
    \| **合计** \| **117** \| **15,653 GB** \| **6,030 GB** \| —             \| —               \| —                                  \|
    
    
    
    \> ⚠️ **dev 的 55 min Phase1 完全来自 10\.18\.17\.2131 TB 盘)**,其余 dev 服务器均 ≤8 min,可单独排期处理。
    
    
    
    **\-\-\-**
    
    
    
    ### **6\.2 按环境汇总可节省费用(方案 D 换盘时同步缩容)**
    
    
    
    \> 仅统计**使用率 ≤ 50%** 的服务器。缩容目标 = \`ceil\(已用 × 1\.5 / 10\) × 10\`,最小 20 GB。
    
    > 
    > 
    > 
    
    \> ⚠️ **日志配额修正**:此处缩容目标基于当前总使用量,**未单独计算日志增长空间**。执行前须与 §6\.6「基准磁盘规格」做二次校验——若原缩容目标 \< 基准磁盘,以 §6\.6 为准,否则换盘后新盘将很快再次填满。
    
    
    
    \| 环境     \| 总台数  \| 总磁盘 \(GB\) \| 总已用 \(GB\) \| 可优化台数 \| 可缩容 \(GB\) \| **月省 $**  \| **年省 $**  \|
    
    \| :\-\-\-\-\-\-\- \| :\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-: \| :\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-: \| \-\-\-\-\-\-\-\-\-\-: \|
    
    \| prod     \| 71      \| 9,882       \| 3,157       \| 53        \| 4,984       \| **$478\.5**  \| **$5,742**  \|
    
    \| qa       \| 12      \| 1,348       \| 651         \| 7         \| 330         \| $31\.7       \| $380        \|
    
    \| mirror   \| 6       \| 600         \| 289         \| 5         \| 200         \| $19\.2       \| $230        \|
    
    \| dev      \| 10      \| 1,734       \| 883         \| 5         \| 210         \| $20\.2       \| $242        \|
    
    \| sit      \| 5       \| 600         \| 272         \| 3         \| 180         \| $17\.3       \| $207        \|
    
    \| uat      \| 9       \| 1,139       \| 575         \| 3         \| 142         \| $13\.6       \| $164        \|
    
    \| fat      \| 4       \| 350         \| 203         \| 2         \| 60          \| $5\.8        \| $69         \|
    
    \| **合计** \| **117** \| **15,653**  \| **6,030**   \| **78**    \| **6,106**   \| **$586\.2**  \| **$7,034**  \|
    
    
    
    **\-\-\-**
    
    
    
    ### **6\.3 Top 节省机会(单台 ≥ $10/月)**
    
    
    
    |IP|环境|实例类型|现有 \(GB\)|使用率|目标 \(GB\)|缩容 \(GB\)|月省 $|
    |---|---|---|---|---|---|---|---|
    
    \| 10\.19\.5\.56 †  \| prod \| m7a\.2xlarge  \| 1,000     \| 3%     \| **300**   \| 700       \| **$67\.2**              \|
    
    \| 10\.20\.13\.154  \| prod \| m6a\.xlarge   \| 200       \| 8%     \| 30        \| 170       \| $16\.3                  \|
    
    \| 10\.19\.12\.179  \| prod \| c7i\.4xlarge  \| 200       \| 7%     \| 30        \| 170       \| $16\.3                  \|
    
    \| 10\.19\.11\.169  \| prod \| m5zn\.2xlarge \| 197       \| 7%     \| 30        \| 167       \| $16\.0                  \|
    
    \| 10\.19\.11\.106  \| prod \| m5zn\.2xlarge \| 197       \| 8%     \| 30        \| 167       \| $16\.0                  \|
    
    \| 10\.19\.4\.208   \| prod \| c7i\.4xlarge  \| 200       \| 11%    \| 40        \| 160       \| $15\.4                  \|
    
    \| 10\.19\.12\.26   \| prod \| c7i\.4xlarge  \| 200       \| 11%    \| 40        \| 160       \| $15\.4                  \|
    
    \| 10\.20\.14\.56   \| prod \| m6a\.xlarge   \| 200       \| 16%    \| 50        \| 150       \| $14\.4                  \|
    
    \| 10\.19\.14\.129  \| prod \| m7a\.xlarge   \| 196       \| 17%    \| 50        \| 146       \| $14\.0                  \|
    
    \| 43\.199\.154\.51 \| prod \| c7i\.2xlarge  \| 200       \| 19%    \| 60        \| 140       \| $13\.4                  \|
    
    \| 10\.19\.8\.142   \| prod \| c7i\.xlarge   \| 200       \| 23%    \| 70        \| 130       \| $12\.5                  \|
    
    \| 10\.17\.10\.107  \| qa   \| m6a\.2xlarge  \| 300       \| 38%    \| 180       \| 120       \| $11\.5                  \|
    
    \| 10\.18\.102\.112 \| sit  \| r6i\.2xlarge  \| 200       \| 36%    \| 110       \| 90        \| $8\.6                   \|
    
    \| *\(14 台批量,见 P4\)* ‡ \| prod \| c7i/m7i xlarge \| 100 \| 212% \| **≥ 100 待查** \| — \| **待查**(原估 $107\.8)\|
    
    
    
    \> † \`10\.19\.5\.56\`:原目标 50 GB 未计入 prod 日志配额(P90 \~134 GB),按 §6\.6 基准修正为 300 GB,月省 $91\.2 → **$67\.2**(仍为最大单台)。
    
    > 
    > 
    > ‡ 14 台批量:原目标 20 GB 低于 prod 基准磁盘(100 GB)。需先对每台执行 LOG\_SIZE\_GB 扫描(参考 §1\.5 命令),确认日志占用后按 §6\.6 公式重算缩容目标,切勿直接缩至 20 GB。
    > 
    > 
    
    
    
    **\-\-\-**
    
    
    
    ### **6\.4 高风险服务器(使用率 ≥ 70%,需优先处理)**
    
    
    
    |IP|环境|实例类型|磁盘|使用率|Phase1|风险|
    |---|---|---|---|---|---|---|
    
    \| 10\.19\.8\.162  \| mirror \| r6a\.xlarge   \| 100G  \| **100%** \| 9 min   \| 🔴 **立即处理,磁盘已满无法写入**        \|
    
    \| 10\.15\.96\.95  \| qa     \| —            \| 50G   \| 89%      \| 5 min   \| 🔴 HIGH                                 \|
    
    \| 10\.18\.33\.111 \| fat    \| c6a\.2xlarge  \| 100G  \| 83%      \| 7 min   \| 🔴 HIGH                                 \|
    
    \| 10\.20\.8\.71   \| prod   \| c7i\.2xlarge  \| 100G  \| 83%      \| 6 min   \| 🔴 HIGH                                 \|
    
    \| 10\.18\.85\.182 \| dev    \| m5a\.2xlarge  \| 100G  \| 80%      \| 8 min   \| 🔴 HIGH                                 \|
    
    \| 10\.16\.112\.26 \| uat    \| t3\.large     \| 60G   \| 77%      \| 6 min   \| 🟡 较高                                 \|
    
    \| 10\.19\.10\.58  \| prod   \| r6i\.2xlarge  \| 200G  \| 75%      \| 13 min  \| 🟡 较高                                 \|
    
    \| 10\.19\.12\.250 \| prod   \| r6i\.2xlarge  \| 200G  \| 75%      \| 13 min  \| 🟡 较高                                 \|
    
    \| 10\.19\.5\.136  \| prod   \| r6i\.2xlarge  \| 200G  \| 75%      \| 13 min  \| 🟡 较高                                 \|
    
    \| 10\.19\.11\.174 \| prod   \| r6i\.2xlarge  \| 200G  \| 75%      \| 13 min  \| 🟡 较高                                 \|
    
    \| 10\.19\.15\.125 \| prod   \| m7i\.2xlarge  \| 300G  \| 74%      \| 15 min  \| 🟡 较高                                 \|
    
    \| 10\.19\.7\.228  \| prod   \| m7i\.2xlarge  \| 300G  \| 74%      \| 15 min  \| 🟡 较高                                 \|
    
    
    
    **\-\-\-**
    
    
    
    ### **6\.5 执行优先级建议**
    
    
    
    |优先级|服务器|原因|建议行动|
    |---|---|---|---|
    |P0 立即|mirror 10\.19\.8\.162|磁盘 100% 满|本周内换盘,新盘 ≥ 200G|
    |P1 本周|qa 10\.15\.96\.95、fat 10\.18\.33\.111|使用率 \>80%|先扩容或换更大新盘|
    |P2 本迭代|dev 10\.18\.17\.213(1TB)|Phase1 需 55 min|单独低峰排期,或临时调高 gp3 吞吐至 500 MB/s(约 18 min)|
    |P3 顺带|prod 10\.19\.5\.56(1TB 3%)|浪费最大|换盘时缩容,目标按 §6\.6 基准 300 G(原 50 G 未计日志配额)|
    |P4 批量|prod 14 台 c7i/m7i 100G(2\-12%)|合计月省待修正|先查各机 LOG\_SIZE\_GB,再按 §6\.6 基准确定缩容目标(勿直接缩至 20 G)|
    
    
    
    **\-\-\-**
    
    
    
    ### **6\.6 各环境基准磁盘规格建议**
    
    
    
    \> **计算公式**:\`基准磁盘 = ceil\(\(非日志数据估算 \+ 日志配额\) × 1\.35 / 50\) × 50 GB\`
    
    > - 非日志数据估算 ≈ 环境总已用 / 机器数 − 日志中位数(JAR / 制品 / 脚本占用代理值)
    > 
    > - 日志配额 = P90 × 1\.5(覆盖 \~90% 机器,50% 增长余量)
    > 
    > - 1\.35 = 35% 余量(日志增长 \+ JAR 堆积 \+ 系统留白);向上取整至 50 GB
    > 
    > 
    
    \> \- 此基准为**单台最小推荐规格**;方案 D 换盘时按此创建新 EBS 卷
    
    
    
    \| 环境 \| 非日志估算 \(GB\) \| 日志配额 \(GB\) \| **基准磁盘 \(GB\)** \| **长尾机器 \(GB\)** \| 说明 \|
    
    \| :\-\-\- \| \-\-\-: \| \-\-\-: \| \-\-\-: \| \-\-\-: \| :\-\-\- \|
    
    \| prod \| \~24 \| 200 \| **300** \| **500** \| 6 台日志 \>134 GB 需 500 G;\`10\.19\.5\.56\` \(1TB, 3%\) 原建议 50 G 不适用,修正为 300 G \|
    
    \| rapidtrade\-prod \| \~25 \| 80 \| **150** \| 200 \| — \|
    
    \| uat \| \~50 \| 150 \| **300** \| 350 \| — \|
    
    \| qa \| \~54 \| 100 \| **200** \| 250 \| 双峰:零日志机器 100 G 即可;\`10\.17\.11\.179\` / \`10\.18\.59\.246\` 需 250 G \|
    
    \| fat \| \~48 \| 100 \| **200** \| — \| — \|
    
    \| dev \| \~85 \| 75 \| **250** \| — \| dev 存储更多 JAR 版本历史,非日志占用较高;\`10\.18\.17\.213\`(1 TB)修正为 250 G \|
    
    \| sit \| \~43 \| 50 \| **150** \| — \| — \|
    
    \| rapidtrade\-uat \| \~25 \| 50 \| **100** \| — \| — \|
    
    \| mirror1/2/3 \| \~31 \| 50 \| **100** \| 150 \| — \|
    
    
    
    **对 §6\.2 / §6\.3 缩容目标的修正原则:**
    
    
    
    若 §6\.3 中某台机器的「目标 \(GB\)」\< 上表「基准磁盘」,**以基准磁盘为新的缩容下限**,原目标作废。典型修正示例:
    
    
    
    |IP|原目标 \(GB\)|修正后目标 \(GB\)|月省修正|
    |---|---|---|---|
    
    \| 10\.19\.5\.56(prod, 1TB) \| 50 \| **300** \| $91\.2 → **$67\.2**(仍为最大单台) \|
    
    \| prod 14 台 c7i/m7i 100G \| 20 \| **按实际 LOG\_SIZE\_GB 定**,≥ 100 G \| 需逐台查日志后重算 \|
    
    
    
    **\-\-\-**
    
    
    
    ## **7\. 运维维护指引**
    
    
    
    ### **7\.1 方案 D 执行后(日常维护最简)**
    
    
    
    |场景|操作|
    |---|---|
    
    \| **新服务上线到已有主机** \| 在 \`ansible/host\_vars/\<hostname\>\.yml\` 追加 \`managed\_services\` 条目            \|
    
    \| **新增目标服务器**        \| 新盘直接挂到 \`/data\`,无需配置即可使用兜底 glob 逻辑                          \|
    
    \| **再次换盘或扩容**        \| 重新执行 \`disk\-ebs\-replace\.yml\`,旧快照 30 天后清理                           \|
    
    \| **路径约定变更**          \| 等方案 B 完成后,仅改 \`data\_root\` 一处即可波及全部环境                        \|
    
    
    
    ### **7\.2 方案 A 软链存活期间注意事项**
    
    
    
    \- **每次主机重启前**:确认 fstab 正确 — \`awk '$2=="/data\-app"' /etc/fstab\`,软链重启后仍存在
    
    \- **新盘空间不足**:直接扩容 \`/data\-app\`(即物理新盘),软链透明
    
    \- **拆除软链时机**:方案 B 完成并验证后可安全拆除(\`unlink /data \&\& mv /data\-app /data\`,需先停应用并提前备份)
    
    
    
    ### **7\.3 方案 B 完成后日常换盘流程**
    
    
    
    变更路径时只改一处:
    
    
    
    ```YAML
    # ansible/inventory/<env>/group_vars/all.yaml
    data_root: /data-app   # 仅修改此行
    ```
    
    
    
    然后删除目标服务器旧启动脚本并触发一次部署(参见 \[方案B/C操作手册\]\(\./cicd\-disk\-runbook\-b\.md\) §2 Step 5)。
    
    
    
    ### **7\.4 定期检查项(建议每个 Sprint 执行一次)**
    
    
    
    ```Bash
    # ① 检查是否有新增硬编码 /data 路径(方案 B 完成前必查)
    grep -rn '/data/' \
      ansible/templates/ ansible/*.yml \
      jenkins/nonprod/ .github/workflows/ \
      --include="*.groovy" --include="*.yml" --include="*.yaml" --include="*.j2" \
      | grep -v "data_root\|/data-app\|# \|devops-ansible" \
      | grep -v ".git"
    
    # ② 检查目标服务器磁盘使用率(识别新高风险服务器,使用率 >70% 需关注)
    ansible all -i /data/devops-ansible/inventory/prod \
      -m shell -a "df /data --output=pcent | tail -1" --become \
      | grep -E '([7-9][0-9]|100)%'
    ```
    
    
    
    ### **7\.5 方案 D 执行后的 EBS 清理时间线**
    
    
    
    |时间节点|操作|
    |---|---|
    
    \| **执行当天**  \| 记录 SnapshotId 和旧 VolumeId,不做任何清理                                  \|
    
    \| **\+7 天**     \| 确认应用运行正常后,执行 \`aws ec2 detach\-volume\` 摘除旧卷(不删除)         \|
    
    \| **\+30 天**    \| 确认无回滚需求后,删除旧卷和快照(**不可逆**,务必确认快照已保存)           \|
    
    
    
    ```Bash
    # 列出本次迁移创建的所有快照
    aws ec2 describe-snapshots \
      --filters "Name=tag:Purpose,Values=disk-migration" \
      --query "Snapshots[*].[SnapshotId,StartTime,VolumeId,Description]" \
      --output table --region ap-northeast-1
    
    # 确认安全后删除(逐条执行)
    # aws ec2 delete-snapshot --snapshot-id snap-xxxxxxxxxxxxxxxxx --region ap-northeast-1
    ```
    View Code

     

  • 操作手册

    # cicd\-disk\-runbook\-d\.md
    
    # 1\.创建磁盘,继承目标实例标签,格式化新磁盘,挂载新磁盘,挂载验证
    
    ```Plain Text
    1.会创建新的磁盘(默认:60G)
    2.根据实例的标签对新磁盘打标签
    3.对新磁盘进行格式化(禁止对已经挂载的磁盘或者当前系统路径下的磁盘或者目录进行格式化)
    4.对新磁盘进行检测并挂载到/data-app目录下
    5.挂载验证,确认/etc/fstab配置文件正确
    
    备注:全程没有对/data源磁盘进行任何写入和格式化操作。
    ```
    
    ![Image](https://internal-api-drive-stream-sg.larksuite.com/space/api/box/stream/download/authcode/?code=MjNlNzU2ZDZhNmY5OTZmMjZlM2E3NDFmYWY2NDg5NjRfN2YwM2M4Njc2MjY3ZTFiZTU3NGUyODE4NzIwZGM1ODlfSUQ6NzY3NjQwMTQ5OTE5NDI4MTY5NV8xNzg5NjEzNTYwOjE3ODk2OTk5NjBfVjM)
    
    
    
    # 2\.磁盘数据同步
    
    ```Bash
    
    **bash disk-sync.sh start **
    ═══════════════════════════════════════════════════
      同步任务: /data → /data-app
      删除    : 否(追加模式)
      内置排除: *.log *.log.gz *.log.[0-9]* *.log-* nohup.out
    ═══════════════════════════════════════════════════
    [2026-08-21 07:06:43] ✅ rsync 后台启动
       PID  : 2182647
       日志 : /tmp/data-app-20260821-1.log
       进度 : tail -f /tmp/data-app-20260821-1.log
       状态 : disk-sync.sh status /data-app
       
    
    **./disk-sync.sh status /data-app**
    ═══════════════════════════════════════════════════
      同步状态: /data-app
      日志    : /tmp/data-app-20260821-1.log
    ═══════════════════════════════════════════════════
      STATUS=DONE  pid=2182647  ✅ 同步成功
    
      --- 同步统计 ---
    api/test_mock/f-stack/tools/compat/feature_present.c
    frank/.claude/plugins/marketplaces/claude-plugins-official/plugins/math-olympiad/skills/math-olympiad/references/presentation_prompts.md
    frank/.vscode-server/cli/servers/Stable-110a328ea54b42367b803ec53ee0bf52ef26b419/server/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts
    sent 4,969,481,916 bytes  received 1,293,232 bytes  17,410,771.10 bytes/sec
    total size is 20,013,572,360  speedup is 4.03
      完整日志: /tmp/data-app-20260821-1.log
    ```
    
    
    
    # 3\.切换磁盘,检查挂载情况
    
    ```Bash
    **bash disk-switch.sh switch /data-app /data**
    ╔══════════════════════════════════════════════════════╗
    ║        ⚠️  高危操作确认 — 路径切换                    ║
    ╠══════════════════════════════════════════════════════╣
      主机       : ip-10-18-43-72  IP: 10.18.43.72
      切换操作   :
        /data-app       →  /data            (新盘成为主目录)
        /data           →  /data-bak        (旧盘保留备份)
      同步状态   : DONE
      fstab 备份 : /etc/<IP>-fstab-<时间>.bak
    ╚══════════════════════════════════════════════════════╝
    
    **⚠️  执行前请确认数据已全量同步、业务服务已停止**
    
    确认执行路径切换?输入 yes 继续 [yes/NO]: yes
    [2026-08-21 07:22:27] 新盘: /dev/nvme4n1  UUID=24d32a4d-8d09-479d-858b-73d0b149b9af
    [2026-08-21 07:22:27] 旧盘: /dev/nvme1n1p1  UUID=94cd881f-a1d3-4b1b-88df-9c00cc2646f5
    [2026-08-21 07:22:27] fstab 已备份 → /etc/10.18.43.72-fstab-20260821T072227.bak
    [2026-08-21 07:22:27] 卸载 /data-app...
    [2026-08-21 07:22:27] 卸载 /data...
    [2026-08-21 07:22:27] 挂载新盘 /dev/nvme4n1 → /data...
    [2026-08-21 07:22:27] 挂载旧盘 /dev/nvme1n1p1 → /data-bak...
    [2026-08-21 07:22:27] fstab 已更新
    [2026-08-21 07:22:27] fstab 语法验证通过
    
    [2026-08-21 07:22:27] ✅ 路径切换完成
      /data   → 新盘 /dev/nvme4n1
      /data-bak  → 旧盘 /dev/nvme1n1p1(备份保留)
    
    Filesystem      Size  Used Avail Use% Mounted on
    /dev/nvme4n1     60G   20G   41G  33% /data     #切换后的新磁盘为/data
    /dev/nvme1n1p1  100G   22G   78G  22% /data-bak #老磁盘为/data-bak 待验证没问题可以卸载并删除释放
    
    
    
    备注📝:
    **手动关闭/data磁盘进程:**
    systemctl stop promtail
    systemctl stop monit
    systemctl stop logs_monitor.service
    systemctl stop alloy.service
    
    
    **一键清掉 /data 上所有进程(生产慎用):**
    **fuser -km /data**
    
    #查服务器空闲磁盘+挂载情况
    lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL
    
    du -h --max-depth=3 /data | sort -rh | head -n 10
    ```
    
    
    
    
    
    
    
    # 4\.重启服务验证
    
    ```Plain Text
    1.使用服务的启动脚本/data/scripts下启动服务
    2.观察服务是否能正常启动
    3.启动关停组件(如果有):
    systemctl start promtail
    systemctl start monit
    systemctl start logs_monitor.service
    systemctl start alloy.service
    ```
    
    
    
    
    
    # 5\.重新构建验证
    
    ```Plain Text
    1.使用Jenkins CD 进行deploy部署并启动服务
    2.观察服务是否能正常启动
    3.启动关停组件(如果有):
    systemctl start promtail
    systemctl start monit
    systemctl start logs_monitor.service
    systemctl start alloy.service
    ```
    View Code

     

  • 磁盘同步及切换脚本

    bash disk-sync.sh start /data /data-app
    ═══════════════════════════════════════════════════
      同步任务: /data → /data-app
      删除    : 否(追加模式)
      内置排除: *.log *.log.gz *.log.[0-9]* *.log-* nohup.out
    ═══════════════════════════════════════════════════
    [2026-09-17 03:12:48] ✅ rsync 后台启动
       PID  : 3968355
       日志 : /tmp/data-app-20260917-3.log
       进度 : tail -f /tmp/data-app-20260917-3.log
       状态 : disk-sync.sh status /data-app
    [root@ip-10-20-5-124 opt]# ./disk-sync.sh status /data-app
    ═══════════════════════════════════════════════════
      同步状态: /data-app
      日志    : /tmp/data-app-20260917-3.log
    ═══════════════════════════════════════════════════
      STATUS=DONE_UNKNOWN  pid=3968355  (退出码未记录)
    
      --- 同步统计 ---
    sent 2,991 bytes  received 33 bytes  6,048.00 bytes/sec
    total size is 1,334,966,987  speedup is 441,457.34
      完整日志: /tmp/data-app-20260917-3.log
    [root@ip-10-20-5-124 opt]# ./disk-sync.sh status /data-app
    ═══════════════════════════════════════════════════
      同步状态: /data-app
      日志    : /tmp/data-app-20260917-3.log
    ═══════════════════════════════════════════════════
      STATUS=DONE  pid=3968355  ✅ 同步成功
    
      --- 同步统计 ---
    sent 2,991 bytes  received 33 bytes  6,048.00 bytes/sec
    total size is 1,334,966,987  speedup is 441,457.34
      完整日志: /tmp/data-app-20260917-3.log

     

  • #!/usr/bin/env bash
    # disk-sync.sh — 后台 rsync 数据同步 / 状态查看 / 完成校验
    # 直接在目标服务器以 root 执行,无需 Ansible / Jenkins
    #
    # 用法:
    #   bash disk-sync.sh start  [选项] [SRC] [DST]   后台启动 rsync
    #   bash disk-sync.sh status [DST]                 查看同步进度和状态
    #   bash disk-sync.sh verify [SRC] [DST]           校验同步完成情况
    #   bash disk-sync.sh stop   [DST]                 停止后台同步进程
    #
    # 默认路径: SRC=/data  DST=/data-app
    #
    # ── start 过滤选项 ────────────────────────────────────────────────────────────
    #   --exclude PATTERN         排除文件/目录(可多次使用,支持 rsync 通配符)
    #   --exclude-dir DIR         排除指定目录(自动补 / 后缀)
    #   --exclude-from FILE       从文件加载排除规则(每行一条,# 开头为注释)
    #   --include PATTERN         白名单:优先于 --exclude 规则(可多次使用)
    #   --no-default-excludes     禁用内置日志文件排除规则
    #   --delete                  删除 DST 中 SRC 不存在的文件(默认关闭,需显式开启)
    #   --no-delete               追加模式,不删除目标多余文件(默认行为)
    #   --dry-run                 预览模式(不实际传输,打印将执行的操作)
    #   --bwlimit KBPS            限速(KB/s),0=不限(默认 0)
    #
    # ── 内置默认排除规则 ──────────────────────────────────────────────────────────
    #   *.log  *.log.gz  *.log.[0-9]*  *.log-*  nohup.out
    #   使用 --no-default-excludes 禁用
    #
    # ── 示例 ──────────────────────────────────────────────────────────────────────
    #   # 基本同步(使用默认排除)
    #   bash disk-sync.sh start /data /data-app
    #
    #   # 额外排除 tmp 目录和 *.tmp 文件
    #   bash disk-sync.sh start --exclude-dir /data/tmp --exclude '*.tmp' /data /data-app
    #
    #   # 白名单:只同步 *.conf 和 *.jar,其余全排除
    #   bash disk-sync.sh start --include '*.conf' --include '*.jar' --exclude '*' /data /data-app
    #
    #   # 从文件加载排除规则
    #   bash disk-sync.sh start --exclude-from /tmp/my-excludes.txt /data /data-app
    #
    #   # 预览模式(不实际传输)
    #   bash disk-sync.sh start --dry-run /data /data-app
    #
    #   # 查看进度
    #   bash disk-sync.sh status /data-app
    #
    #   # 校验同步结果
    #   bash disk-sync.sh verify /data /data-app
    
    set -euo pipefail
    
    # ── 全局默认 ──────────────────────────────────────────────────────────────────
    DEFAULT_SRC="/data"
    DEFAULT_DST="/data-app"
    
    # 内置排除(日志文件)
    DEFAULT_EXCLUDES=(
        "*.log"
        "*.log.gz"
        "*.log.[0-9]*"
        "*.log-*"
        "nohup.out"
    )
    
    log()  { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
    die()  { echo "❌ $*" >&2; exit 1; }
    warn() { echo "⚠️  $*"; }
    
    require_root() {
        [[ $EUID -eq 0 ]] || die "需要 root 权限,请切换到 root 或使用 sudo 执行"
    }
    
    _dst_basename() {
        printf '%s' "${1}" | sed 's|^/||;s|/|-|g'
    }
    
    _pid_file()   { echo "/var/run/data-sync-$(_dst_basename "${1}").pid"; }
    _rc_file()    { local f; f=$(_pid_file "${1}"); echo "${f%.pid}.rc"; }
    _count_file() { local f; f=$(_pid_file "${1}"); echo "${f%.pid}.count"; }
    
    # ── start ─────────────────────────────────────────────────────────────────────
    cmd_start() {
        require_root
    
        # ── 参数解析 ────────────────────────────────────────────────────────────
        local USE_DEFAULT_EXCLUDES=true
        local USE_DELETE=false
        local DRY_RUN=false
        local BWLIMIT=0
        local -a EXTRA_EXCLUDES=()
        local -a EXTRA_INCLUDES=()
        local EXCLUDE_FROM_FILE=""
    
        while [[ $# -gt 0 ]]; do
            case "$1" in
                --no-default-excludes) USE_DEFAULT_EXCLUDES=false; shift ;;
                --no-delete)           USE_DELETE=false;           shift ;;
                --delete)              USE_DELETE=true;            shift ;;
                --dry-run)             DRY_RUN=true;               shift ;;
                --bwlimit)
                    [[ -n "${2:-}" ]] || die "--bwlimit 需要一个 KBPS 参数"
                    [[ "${2}" =~ ^[0-9]+$ ]] || die "--bwlimit 必须为非负整数: $2"
                    BWLIMIT="$2"; shift 2 ;;
                --exclude)
                    [[ -n "${2:-}" ]] || die "--exclude 需要一个 PATTERN 参数"
                    EXTRA_EXCLUDES+=("$2"); shift 2 ;;
                --exclude-dir)
                    [[ -n "${2:-}" ]] || die "--exclude-dir 需要一个 DIR 参数"
                    # rsync 排除目录需要相对路径,去掉绝对前缀后补 /
                    local REL
                    REL=$(echo "$2" | sed 's|^/||')
                    EXTRA_EXCLUDES+=("${REL}/"); shift 2 ;;
                --exclude-from)
                    [[ -n "${2:-}" ]] || die "--exclude-from 需要一个 FILE 参数"
                    [[ -f "$2" ]] || die "exclude-from 文件不存在: $2"
                    EXCLUDE_FROM_FILE="$2"; shift 2 ;;
                --include)
                    [[ -n "${2:-}" ]] || die "--include 需要一个 PATTERN 参数"
                    EXTRA_INCLUDES+=("$2"); shift 2 ;;
                --) shift; break ;;
                -*) die "未知选项: $1" ;;
                *) break ;;
            esac
        done
    
        local SYNC_SRC="${1:-$DEFAULT_SRC}"
        local SYNC_DST="${2:-$DEFAULT_DST}"
    
        mountpoint -q "$SYNC_SRC" || die "${SYNC_SRC} 未挂载"
        mountpoint -q "$SYNC_DST" || die "${SYNC_DST} 未挂载"
    
        local PID_FILE RC_FILE COUNT_FILE
        PID_FILE=$(_pid_file "$SYNC_DST")
        RC_FILE=$(_rc_file   "$SYNC_DST")
        COUNT_FILE=$(_count_file "$SYNC_DST")
    
        # 防重入
        if [[ -f "$PID_FILE" ]]; then
            local PREV_PID
            PREV_PID=$(cat "$PID_FILE")
            if kill -0 "$PREV_PID" 2>/dev/null && grep -q rsync /proc/"$PREV_PID"/cmdline 2>/dev/null; then
                warn "rsync 仍在运行 (pid=${PREV_PID}),若需重启请先停止: $0 stop ${SYNC_DST}"
                echo "   进度: $0 status ${SYNC_DST}"
                exit 0
            fi
        fi
    
        # ── 构建 rsync 参数 ────────────────────────────────────────────────────
        local -a RSYNC_ARGS=(-avz)
        $USE_DELETE   && RSYNC_ARGS+=(--delete)
        $DRY_RUN      && RSYNC_ARGS+=(--dry-run)
        [[ $BWLIMIT -gt 0 ]] && RSYNC_ARGS+=(--bwlimit="$BWLIMIT")
    
        # include 规则必须在 exclude 之前(rsync 按顺序匹配)
        for pat in "${EXTRA_INCLUDES[@]+"${EXTRA_INCLUDES[@]}"}"; do
            RSYNC_ARGS+=(--include="$pat")
        done
    
        # 默认排除
        if $USE_DEFAULT_EXCLUDES; then
            for pat in "${DEFAULT_EXCLUDES[@]}"; do
                RSYNC_ARGS+=(--exclude="$pat")
            done
        fi
    
        # 额外排除
        for pat in "${EXTRA_EXCLUDES[@]+"${EXTRA_EXCLUDES[@]}"}"; do
            RSYNC_ARGS+=(--exclude="$pat")
        done
    
        # 从文件加载排除
        [[ -n "$EXCLUDE_FROM_FILE" ]] && RSYNC_ARGS+=(--exclude-from="$EXCLUDE_FROM_FILE")
    
        RSYNC_ARGS+=("${SYNC_SRC}/" "${SYNC_DST}/")
    
        # ── 打印摘要 ────────────────────────────────────────────────────────────
        echo "═══════════════════════════════════════════════════"
        echo "  同步任务: ${SYNC_SRC} → ${SYNC_DST}"
        $DRY_RUN && echo "  模式    : ⚠️  DRY-RUN(预览,不实际传输)"
        echo "  删除    : $($USE_DELETE && echo '是(DST 多余文件将被删除)' || echo '否(追加模式)')"
        if $USE_DEFAULT_EXCLUDES; then
            echo "  内置排除: ${DEFAULT_EXCLUDES[*]}"
        else
            echo "  内置排除: 已禁用"
        fi
        [[ ${#EXTRA_INCLUDES[@]} -gt 0 ]] && echo "  白名单  : ${EXTRA_INCLUDES[*]}"
        [[ ${#EXTRA_EXCLUDES[@]} -gt 0 ]] && echo "  额外排除: ${EXTRA_EXCLUDES[*]}"
        [[ -n "$EXCLUDE_FROM_FILE" ]]      && echo "  排除文件: ${EXCLUDE_FROM_FILE}"
        [[ $BWLIMIT -gt 0 ]]               && echo "  限速    : ${BWLIMIT} KB/s"
        echo "═══════════════════════════════════════════════════"
    
        if $DRY_RUN; then
            log "DRY-RUN: 执行预览..."
            rsync "${RSYNC_ARGS[@]}"
            log "DRY-RUN 完成,未实际传输任何数据"
            return 0
        fi
    
        # ── 自增计数(防日志文件名冲突)────────────────────────────────────────
        exec 9>"${COUNT_FILE}.lock"
        flock -x 9
        local COUNT
        COUNT=$([[ -f "$COUNT_FILE" ]] && cat "$COUNT_FILE" || echo 0)
        COUNT=$((COUNT + 1))
        echo "$COUNT" > "$COUNT_FILE"
        flock -u 9
        exec 9>&-
    
        local DST_BASENAME
        DST_BASENAME=$(_dst_basename "$SYNC_DST")
        local SYNC_LOG="/tmp/${DST_BASENAME}-$(date +%Y%m%d)-${COUNT}.log"
        # 将实际命令写入日志头,便于排查
        echo "# rsync args: ${RSYNC_ARGS[*]}" > "$SYNC_LOG"
        echo "# started: $(date '+%Y-%m-%d %H:%M:%S')" >> "$SYNC_LOG"
        rm -f "$RC_FILE"
    
        nohup rsync "${RSYNC_ARGS[@]}" >> "$SYNC_LOG" 2>&1 &
        local SYNC_PID=$!
        echo "$SYNC_PID" > "$PID_FILE"
    
        # watcher:记录退出码(临时脚本文件,避免 nohup sh -c 字符串注入)
        local WATCHER_SCRIPT
        WATCHER_SCRIPT=$(mktemp /tmp/disk-watcher-XXXXXX.sh)
        chmod 700 "$WATCHER_SCRIPT"
        cat > "$WATCHER_SCRIPT" <<EOF
    #!/bin/sh
    while kill -0 ${SYNC_PID} 2>/dev/null; do sleep 10; done
    if grep -q '^sent ' "${SYNC_LOG}" 2>/dev/null && ! grep -q 'rsync error:' "${SYNC_LOG}" 2>/dev/null; then
        echo 0 > "${RC_FILE}"
    else
        echo 1 > "${RC_FILE}"
    fi
    echo "# ended: \$(date '+%Y-%m-%d %H:%M:%S')" >> "${SYNC_LOG}"
    rm -f "${WATCHER_SCRIPT}"
    EOF
        nohup sh "$WATCHER_SCRIPT" > /dev/null 2>&1 &
    
        log "✅ rsync 后台启动"
        echo "   PID  : ${SYNC_PID}"
        echo "   日志 : ${SYNC_LOG}"
        echo "   进度 : tail -f ${SYNC_LOG}"
        echo "   状态 : $0 status ${SYNC_DST}"
    }
    
    # ── status ────────────────────────────────────────────────────────────────────
    cmd_status() {
        local SYNC_DST="${1:-$DEFAULT_DST}"
        local DST_BASENAME
        DST_BASENAME=$(_dst_basename "$SYNC_DST")
    
        local PID_FILE RC_FILE
        PID_FILE=$(_pid_file "$SYNC_DST")
        RC_FILE=$(_rc_file   "$SYNC_DST")
        local LATEST_LOG
        LATEST_LOG=$(ls -t /tmp/"${DST_BASENAME}"-*.log 2>/dev/null | head -1 || true)
    
        echo "═══════════════════════════════════════════════════"
        echo "  同步状态: ${SYNC_DST}"
        echo "  日志    : ${LATEST_LOG:-(无日志)}"
        echo "═══════════════════════════════════════════════════"
    
        if [[ ! -f "$PID_FILE" ]]; then
            echo "  STATUS=NO_TASK  (未执行过后台同步)"
            return 0
        fi
    
        local PID
        PID=$(cat "$PID_FILE")
    
        if kill -0 "$PID" 2>/dev/null && grep -q rsync /proc/"$PID"/cmdline 2>/dev/null; then
            local ELAPSED
            ELAPSED=$(ps -o etime= -p "$PID" 2>/dev/null | tr -d ' ' || echo "unknown")
            echo "  STATUS=RUNNING  pid=${PID}  elapsed=${ELAPSED}"
    
            # 已传输字节(rsync 进度行)
            if [[ -n "$LATEST_LOG" ]]; then
                local LAST_PROGRESS
                LAST_PROGRESS=$(grep -oE '[0-9,]+  +[0-9]+%' "$LATEST_LOG" 2>/dev/null | tail -1 || true)
                [[ -n "$LAST_PROGRESS" ]] && echo "  最新进度: ${LAST_PROGRESS}"
                echo ""
                echo "  --- 最新日志 (last 5 lines) ---"
                tail -5 "$LATEST_LOG" 2>/dev/null || echo "  (日志不可读)"
            fi
        else
            if [[ -f "$RC_FILE" ]]; then
                local RC
                RC=$(cat "$RC_FILE")
                if [[ "$RC" == "0" ]]; then
                    echo "  STATUS=DONE  pid=${PID}  ✅ 同步成功"
                else
                    echo "  STATUS=FAILED  pid=${PID}  exit_code=${RC}  ❌ rsync 异常退出"
                fi
            else
                echo "  STATUS=DONE_UNKNOWN  pid=${PID}  (退出码未记录)"
            fi
    
            if [[ -n "$LATEST_LOG" ]]; then
                echo ""
                echo "  --- 同步统计 ---"
                grep -E "sent|total size|speedup|rsync error" "$LATEST_LOG" 2>/dev/null | tail -5 || true
                echo "  完整日志: ${LATEST_LOG}"
            fi
        fi
    }
    
    # ── verify ────────────────────────────────────────────────────────────────────
    cmd_verify() {
        local SYNC_SRC="${1:-$DEFAULT_SRC}"
        local SYNC_DST="${2:-$DEFAULT_DST}"
    
        mountpoint -q "$SYNC_SRC" || die "${SYNC_SRC} 未挂载"
        mountpoint -q "$SYNC_DST" || die "${SYNC_DST} 未挂载"
    
        echo "═══════════════════════════════════════════════════"
        echo "  同步校验: ${SYNC_SRC} → ${SYNC_DST}"
        echo "  $(date '+%Y-%m-%d %H:%M:%S')"
        echo "═══════════════════════════════════════════════════"
    
        local STATUS="PASS"
    
        # 1. rsync 退出码检查
        local RC_FILE
        RC_FILE=$(_rc_file "$SYNC_DST")
        if [[ -f "$RC_FILE" ]]; then
            local RC
            RC=$(cat "$RC_FILE")
            if [[ "$RC" == "0" ]]; then
                echo "  [状态]  ✅ rsync 退出码=0"
            else
                echo "  [状态]  ❌ rsync 退出码=${RC},同步可能未完整"
                STATUS="FAIL"
            fi
        else
            echo "  [状态]  ⚠️  未找到退出码文件,请确认 disk-sync.sh start 已执行并完成"
            STATUS="WARN"
        fi
    
        # 2. 文件数对比(排除日志文件,与默认 rsync 规则一致)
        log "统计文件数(排除日志文件)..."
        local SRC_COUNT DST_COUNT
        SRC_COUNT=$(find "$SYNC_SRC" -type f \
            ! -name "*.log" ! -name "*.log.gz" \
            ! -name "*.log.[0-9]*" ! -name "*.log-*" \
            ! -name "nohup.out" 2>/dev/null | wc -l)
        DST_COUNT=$(find "$SYNC_DST" -type f \
            ! -name "*.log" ! -name "*.log.gz" \
            ! -name "*.log.[0-9]*" ! -name "*.log-*" \
            ! -name "nohup.out" 2>/dev/null | wc -l)
    
        if [[ $DST_COUNT -ge $SRC_COUNT ]]; then
            echo "  [文件数] ✅ SRC=${SRC_COUNT}  DST=${DST_COUNT}"
        else
            local MISSING=$(( SRC_COUNT - DST_COUNT ))
            echo "  [文件数] ⚠️  SRC=${SRC_COUNT}  DST=${DST_COUNT}  缺少约 ${MISSING} 个文件"
            echo "           注意: 被排除的日志文件不计入统计,差值属正常现象"
            STATUS="WARN"
        fi
    
        # 3. 磁盘用量对比
        local SRC_USED_G DST_USED_G
        SRC_USED_G=$(df -BG --output=used "$SYNC_SRC" | tail -1 | tr -d 'G ')
        DST_USED_G=$(df -BG --output=used "$SYNC_DST" | tail -1 | tr -d 'G ')
        echo "  [用量]  SRC 已用 ${SRC_USED_G}G  DST 已用 ${DST_USED_G}G"
    
        # 4. rsync --checksum 抽样校验(最近修改的 20 个文件)
        log "抽样 checksum 校验(最近修改的 20 个文件)..."
        local SAMPLE_FILES
        SAMPLE_FILES=$(find "$SYNC_SRC" -type f \
            ! -name "*.log" ! -name "*.log.gz" \
            ! -name "*.log.[0-9]*" ! -name "*.log-*" \
            ! -name "nohup.out" \
            -printf '%T@ %P\n' 2>/dev/null \
            | sort -rn | head -20 | awk '{print $2}')
    
        local SAMPLE_PASS=0 SAMPLE_FAIL=0
        while IFS= read -r rel_path; do
            local SRC_FILE="${SYNC_SRC}/${rel_path}"
            local DST_FILE="${SYNC_DST}/${rel_path}"
            if [[ ! -f "$DST_FILE" ]]; then
                SAMPLE_FAIL=$(( SAMPLE_FAIL + 1 ))
                echo "  [抽样]  ❌ 目标缺失: ${rel_path}"
            else
                local SRC_MD5 DST_MD5
                SRC_MD5=$(md5sum "$SRC_FILE" 2>/dev/null | awk '{print $1}' || true)
                DST_MD5=$(md5sum "$DST_FILE" 2>/dev/null | awk '{print $1}' || true)
                if [[ "$SRC_MD5" == "$DST_MD5" ]]; then
                    SAMPLE_PASS=$(( SAMPLE_PASS + 1 ))
                else
                    SAMPLE_FAIL=$(( SAMPLE_FAIL + 1 ))
                    echo "  [抽样]  ❌ checksum 不一致: ${rel_path}"
                fi
            fi
        done <<< "$SAMPLE_FILES"
    
        if [[ $SAMPLE_FAIL -eq 0 ]]; then
            echo "  [抽样]  ✅ ${SAMPLE_PASS} 个文件 checksum 一致"
        else
            echo "  [抽样]  ❌ ${SAMPLE_FAIL} 个文件不一致(通过: ${SAMPLE_PASS})"
            STATUS="FAIL"
        fi
    
        echo "═══════════════════════════════════════════════════"
        case "$STATUS" in
            PASS) echo "  结果: ✅ PASS" ;;
            WARN) echo "  结果: ⚠️  WARN(请人工确认差异)" ;;
            FAIL) echo "  结果: ❌ FAIL" ;;
        esac
        echo "═══════════════════════════════════════════════════"
    
        [[ "$STATUS" != "FAIL" ]] || exit 1
    }
    
    # ── stop ─────────────────────────────────────────────────────────────────────
    cmd_stop() {
        local SYNC_DST="${1:-$DEFAULT_DST}"
        local PID_FILE
        PID_FILE=$(_pid_file "$SYNC_DST")
    
        [[ -f "$PID_FILE" ]] || { echo "未找到 PID 文件,同步未运行或已完成"; return 0; }
    
        local PID
        PID=$(cat "$PID_FILE")
    
        if kill -0 "$PID" 2>/dev/null && grep -q rsync /proc/"$PID"/cmdline 2>/dev/null; then
            kill "$PID"
            log "已发送 SIGTERM 到 rsync 进程 (pid=${PID})"
            # 等待最多 10s
            local i=0
            while kill -0 "$PID" 2>/dev/null && [[ $i -lt 10 ]]; do
                sleep 1; i=$(( i + 1 ))
            done
            if kill -0 "$PID" 2>/dev/null; then
                kill -9 "$PID" 2>/dev/null || true
                log "SIGKILL 已发送"
            fi
            echo "1" > "$(_rc_file "$SYNC_DST")"
            log "✅ 同步进程已停止"
        else
            echo "rsync 进程 (pid=${PID}) 已不在运行"
        fi
    }
    
    # ── 入口 ──────────────────────────────────────────────────────────────────────
    COMMAND="${1:-}"
    shift || true
    
    case "$COMMAND" in
        start)  cmd_start  "$@" ;;
        status) cmd_status "$@" ;;
        verify) cmd_verify "$@" ;;
        stop)   cmd_stop   "$@" ;;
        *)
            cat <<'USAGE'
    用法: bash disk-sync.sh <command> [选项] [args]
    
    命令:
      start  [选项] [SRC] [DST]   后台启动 rsync(默认 /data → /data-app)
      status [DST]                 查看进度和状态
      verify [SRC] [DST]           校验同步完成情况(文件数 + checksum 抽样)
      stop   [DST]                 停止后台同步进程
    
    start 选项:
      --exclude PATTERN         排除文件/目录(可多次使用)
      --exclude-dir DIR         排除指定目录
      --exclude-from FILE       从文件加载排除规则(每行一条,# 开头为注释)
      --include PATTERN         白名单(优先于 --exclude,可多次使用)
      --no-default-excludes     禁用内置日志排除(*.log nohup.out 等)
      --no-delete               追加模式,不删除 DST 多余文件(默认行为)
      --delete                  删除 DST 中 SRC 不存在的文件(需显式开启)
      --dry-run                 预览模式,不实际传输
      --bwlimit KBPS            限速(KB/s)
    
    示例:
      # 基本同步
      bash disk-sync.sh start /data /data-app
    
      # 额外排除 tmp 目录和 *.tmp 文件
      bash disk-sync.sh start --exclude-dir /data/tmp --exclude '*.tmp' /data /data-app
    
      # 白名单:只同步 *.jar 和 *.conf,其余全排除
      bash disk-sync.sh start --include '*.jar' --include '*.conf' --exclude '*' /data /data-app
    
      # 从文件加载排除规则
      bash disk-sync.sh start --exclude-from /tmp/my-excludes.txt /data /data-app
    
      # 预览(不实际传输)
      bash disk-sync.sh start --dry-run /data /data-app
    
      # 查看进度
      bash disk-sync.sh status /data-app
    
      # 校验同步结果
      bash disk-sync.sh verify /data /data-app
    
      # 停止同步
      bash disk-sync.sh stop /data-app
    USAGE
            exit 1
            ;;
    esac
    disk-sync.sh

     

  • #!/usr/bin/env bash
    # disk-switch.sh — 路径切换
    # 直接在目标服务器以 root 执行,无需 Ansible
    #
    # 用法:
    #   bash disk-switch.sh switch [NEW_MOUNT] [OLD_MOUNT]    路径切换(新盘接管旧路径)
    #
    # 示例:
    #   bash disk-switch.sh switch /data-app /data
    
    set -euo pipefail
    
    log()  { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
    die()  { echo "❌ $*" >&2; exit 1; }
    warn() { echo "⚠️  $*"; }
    
    require_root() {
        [[ $EUID -eq 0 ]] || die "需要 root 权限,请切换到 root 或使用 sudo 执行"
    }
    
    check_busy() {
        local DIR="$1"
        local BUSY
        BUSY=$(lsof +D "$DIR" 2>/dev/null | tail -n +2 | head -10 || true)
        if [[ -n "$BUSY" ]]; then
            echo "❌ ${DIR} 有进程占用,请先停止相关服务:"
            echo ""
            echo "$BUSY"
            echo ""
            echo "  建议清理命令:"
            local PIDS USERS
            PIDS=$(awk '{print $2}' <<< "$BUSY" | sort -un)
            USERS=$(awk '{print $3}' <<< "$BUSY" | sort -u)
            while IFS= read -r pid; do
                [[ "$pid" =~ ^[0-9]+$ ]] || continue
                # 优先识别 systemd 服务单元
                local UNIT
                UNIT=$(systemctl status "$pid" 2>/dev/null | awk 'NR==1{print $2}' || true)
                if echo "$UNIT" | grep -qE '\.(service|socket)$' 2>/dev/null; then
                    echo "    systemctl stop ${UNIT}"
                else
                    local COMM
                    COMM=$(ps -p "$pid" -o comm= 2>/dev/null | tr -d ' ' || echo "?")
                    echo "    kill -TERM ${pid}  # ${COMM}"
                fi
            done <<< "$PIDS"
            echo ""
            echo "  进程较多时(如 VSCode Remote / IDE 会话),按用户批量清理更高效:"
            while IFS= read -r u; do
                [[ -n "$u" ]] || continue
                echo "    pkill -TERM -u ${u}   # 先 TERM(允许进程干净退出)"
                echo "    pkill -9    -u ${u}   # 仍有残留时强杀"
            done <<< "$USERS"
            echo ""
            return 1
        fi
        return 0
    }
    
    # UUID 格式校验(防 blkid 异常输出注入 fstab)
    validate_uuid() {
        local _uuid="$1"
        [[ "$_uuid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] \
            || die "UUID 格式非法: ${_uuid}"
    }
    
    # 挂载点路径校验(防换行符注入 fstab、路径穿越)
    validate_mount_point() {
        local _mp="$1"
        [[ "$_mp" == /* ]]       || die "挂载点必须是绝对路径: ${_mp}"
        [[ "$_mp" != *$'\n'* ]] || die "挂载点路径包含换行符(非法)"
        [[ "$_mp" != *..* ]]    || die "挂载点路径包含 '..'(路径穿越风险): ${_mp}"
    }
    
    # ── 回滚函数(全局状态变量由 cmd_switch 填充)────────────────────────────────
    # 使用全局变量是因为 ERR trap 函数无法访问 cmd_switch 的 local 变量
    _SWITCH_NEW_DEV="" _SWITCH_OLD_DEV=""
    _SWITCH_NEW_UUID="" _SWITCH_OLD_UUID=""
    _SWITCH_NEW_MOUNT="" _SWITCH_OLD_MOUNT="" _SWITCH_BAK_MOUNT=""
    _SWITCH_FSTAB_BAK=""
    _SWITCH_UMOUNTED_NEW=false _SWITCH_UMOUNTED_OLD=false
    
    _switch_rollback() {
        trap - ERR  # 防止回滚过程中再次触发
        warn "切换中断,尝试回滚挂载状态..."
    
        # 卸载可能已挂载到新路径的设备
        if mountpoint -q "${_SWITCH_OLD_MOUNT}" 2>/dev/null; then
            umount "${_SWITCH_OLD_MOUNT}" 2>/dev/null \
                || warn "  umount ${_SWITCH_OLD_MOUNT} 失败"
        fi
        if mountpoint -q "${_SWITCH_BAK_MOUNT}" 2>/dev/null; then
            umount "${_SWITCH_BAK_MOUNT}" 2>/dev/null \
                || warn "  umount ${_SWITCH_BAK_MOUNT} 失败"
        fi
    
        # 恢复原挂载
        if ${_SWITCH_UMOUNTED_NEW} && ! mountpoint -q "${_SWITCH_NEW_MOUNT}" 2>/dev/null; then
            mount -o defaults,noatime,nosuid,nodev "${_SWITCH_NEW_DEV}" "${_SWITCH_NEW_MOUNT}" 2>/dev/null \
                || warn "  ⚠️  无法恢复 ${_SWITCH_NEW_MOUNT},手动执行: mount UUID=${_SWITCH_NEW_UUID} ${_SWITCH_NEW_MOUNT}"
        fi
        if ${_SWITCH_UMOUNTED_OLD} && ! mountpoint -q "${_SWITCH_OLD_MOUNT}" 2>/dev/null; then
            mount -o defaults,noatime,nosuid,nodev "${_SWITCH_OLD_DEV}" "${_SWITCH_OLD_MOUNT}" 2>/dev/null \
                || warn "  ⚠️  无法恢复 ${_SWITCH_OLD_MOUNT},手动执行: mount UUID=${_SWITCH_OLD_UUID} ${_SWITCH_OLD_MOUNT}"
        fi
    
        # 恢复 fstab
        if [[ -f "${_SWITCH_FSTAB_BAK}" ]]; then
            cp "${_SWITCH_FSTAB_BAK}" /etc/fstab 2>/dev/null \
                && warn "  /etc/fstab 已从 ${_SWITCH_FSTAB_BAK} 恢复" \
                || warn "  ⚠️  /etc/fstab 恢复失败,请手动从 ${_SWITCH_FSTAB_BAK} 还原"
        fi
    
        echo "" >&2
        echo "❌ 路径切换失败(已尝试回滚)。请确认当前状态: df -h && cat /etc/fstab" >&2
        exit 1
    }
    
    # ── switch:新盘接管旧路径 ────────────────────────────────────────────────────
    cmd_switch() {
        local NEW_MOUNT="${1:-/data-app}"
        local OLD_MOUNT="${2:-/data}"
        local BAK_MOUNT="${OLD_MOUNT}-bak"
    
        require_root
        validate_mount_point "$NEW_MOUNT"
        validate_mount_point "$OLD_MOUNT"
    
        # 禁止系统保留目录作为 NEW_MOUNT
        local -a BLOCKED=("/" "/data" "/app" "/opt" "/root" "/tmp" "/var" "/etc"
                          "/usr" "/bin" "/sbin" "/lib" "/lib64" "/home"
                          "/boot" "/proc" "/sys" "/dev" "/run" "/media" "/mnt" "/srv")
        for b in "${BLOCKED[@]}"; do
            [[ "$NEW_MOUNT" != "$b" ]] || die "new_mount 不允许使用系统保留目录: ${NEW_MOUNT}"
        done
    
        # 检查同步状态
        local DST_BASENAME
        DST_BASENAME=$(printf '%s' "$NEW_MOUNT" | sed 's|^/||;s|/|-|g')
        local PID_FILE="/var/run/data-sync-${DST_BASENAME}.pid"
        local RC_FILE="${PID_FILE%.pid}.rc"
    
        local SYNC_STATUS="UNKNOWN"
        if [[ -f "$PID_FILE" ]]; then
            local PID
            PID=$(cat "$PID_FILE")
            if kill -0 "$PID" 2>/dev/null && grep -q rsync /proc/"$PID"/cmdline 2>/dev/null; then
                SYNC_STATUS="RUNNING"
            elif [[ -f "$RC_FILE" ]] && [[ "$(cat "$RC_FILE")" == "0" ]]; then
                SYNC_STATUS="DONE"
            else
                SYNC_STATUS="DONE_OR_FAILED"
            fi
        fi
    
        echo "╔══════════════════════════════════════════════════════╗"
        echo "║        ⚠️  高危操作确认 — 路径切换                    ║"
        echo "╠══════════════════════════════════════════════════════╣"
        echo "  主机       : $(hostname)  IP: $(hostname -I | awk '{print $1}')"
        echo "  切换操作   :"
        printf "    %-15s →  %-15s  (新盘成为主目录)\n" "$NEW_MOUNT" "$OLD_MOUNT"
        printf "    %-15s →  %-15s  (旧盘保留备份)\n"  "$OLD_MOUNT"  "$BAK_MOUNT"
        echo "  同步状态   : ${SYNC_STATUS}"
        echo "  fstab 备份 : /etc/<IP>-fstab-<时间>.bak"
        echo "╚══════════════════════════════════════════════════════╝"
        echo ""
    
        if [[ "$SYNC_STATUS" == "RUNNING" ]]; then
            die "后台同步仍在进行,请等待完成后再切换(bash disk-sync.sh status ${NEW_MOUNT})"
        fi
    
        if [[ "$SYNC_STATUS" != "DONE" ]]; then
            warn "无法确认同步已完成(STATUS=${SYNC_STATUS})"
            read -r -p "同步状态未确认,仍要继续?输入 yes 继续 [yes/NO]: " CONFIRM_SYNC
            [[ "$CONFIRM_SYNC" == "yes" ]] || { echo "已取消"; exit 0; }
        fi
    
        echo "⚠️  执行前请确认数据已全量同步、业务服务已停止"
        echo ""
        read -r -p "确认执行路径切换?输入 yes 继续 [yes/NO]: " CONFIRM
        [[ "$CONFIRM" == "yes" ]] || { echo "已取消"; exit 0; }
    
        # 验证挂载状态
        mountpoint -q "$NEW_MOUNT" || die "${NEW_MOUNT} 未挂载,请先完成挂载和验证"
        mountpoint -q "$OLD_MOUNT" || die "${OLD_MOUNT} 未挂载"
    
        # 进程占用检查
        check_busy "$NEW_MOUNT" || die "请停止占用 ${NEW_MOUNT} 的进程后重试"
        check_busy "$OLD_MOUNT" || die "请停止占用 ${OLD_MOUNT} 的进程后重试"
    
        # 获取设备、UUID 和文件系统类型
        local NEW_DEV OLD_DEV NEW_UUID OLD_UUID NEW_FSTYPE OLD_FSTYPE
        NEW_DEV=$(findmnt -n -o SOURCE "$NEW_MOUNT")
        OLD_DEV=$(findmnt -n -o SOURCE "$OLD_MOUNT")
        NEW_UUID=$(blkid -s UUID -o value "$NEW_DEV")
        OLD_UUID=$(blkid -s UUID -o value "$OLD_DEV")
        # 优先从内核挂载表读 FSTYPE(设备已挂载,比 blkid 更可靠,避免 blkid 失败静默 fallback ext4)
        NEW_FSTYPE=$(findmnt -n -o FSTYPE "$NEW_MOUNT")
        OLD_FSTYPE=$(findmnt -n -o FSTYPE "$OLD_MOUNT")
        [[ -n "$NEW_FSTYPE" ]] || die "无法检测 ${NEW_MOUNT} (${NEW_DEV}) 的文件系统类型,请手动确认"
        [[ -n "$OLD_FSTYPE" ]] || die "无法检测 ${OLD_MOUNT} (${OLD_DEV}) 的文件系统类型,请手动确认"
    
        [[ -n "$NEW_UUID" ]] || die "无法获取 ${NEW_DEV} 的 UUID"
        [[ -n "$OLD_UUID" ]] || die "无法获取 ${OLD_DEV} 的 UUID"
        validate_uuid "$NEW_UUID"
        validate_uuid "$OLD_UUID"
    
        log "新盘: ${NEW_DEV}  UUID=${NEW_UUID}"
        log "旧盘: ${OLD_DEV}  UUID=${OLD_UUID}"
    
        # 备份 fstab
        local HOST_IP
        HOST_IP=$(hostname -I | awk '{print $1}')
        local FSTAB_BAK="/etc/${HOST_IP}-fstab-$(date +%Y%m%dT%H%M%S).bak"
        cp /etc/fstab "$FSTAB_BAK"
        log "fstab 已备份 → ${FSTAB_BAK}"
    
        # ── 高危区域:umount + remount,失败时自动回滚 ───────────────────────────
        _SWITCH_NEW_DEV="$NEW_DEV"     _SWITCH_OLD_DEV="$OLD_DEV"
        _SWITCH_NEW_UUID="$NEW_UUID"   _SWITCH_OLD_UUID="$OLD_UUID"
        _SWITCH_NEW_MOUNT="$NEW_MOUNT" _SWITCH_OLD_MOUNT="$OLD_MOUNT"
        _SWITCH_BAK_MOUNT="$BAK_MOUNT" _SWITCH_FSTAB_BAK="$FSTAB_BAK"
        _SWITCH_UMOUNTED_NEW=false     _SWITCH_UMOUNTED_OLD=false
        trap '_switch_rollback' ERR
    
        log "卸载 ${NEW_MOUNT}..."
        umount "$NEW_MOUNT"
        _SWITCH_UMOUNTED_NEW=true
    
        log "卸载 ${OLD_MOUNT}..."
        umount "$OLD_MOUNT"
        _SWITCH_UMOUNTED_OLD=true
    
        mkdir -p "$BAK_MOUNT"
        log "挂载新盘 ${NEW_DEV} → ${OLD_MOUNT}..."
        mount -o defaults,noatime,nosuid,nodev "$NEW_DEV" "$OLD_MOUNT"
    
        log "挂载旧盘 ${OLD_DEV} → ${BAK_MOUNT}..."
        mount -o defaults,noatime,nosuid,nodev "$OLD_DEV" "$BAK_MOUNT"
    
        # 原子更新 fstab(awk 原地替换挂载点,UUID/FSTYPE/options 完全保留)
        local FSTAB_TMP
        FSTAB_TMP=$(mktemp)
        awk -v nuuid="${NEW_UUID}" -v ouuid="${OLD_UUID}" \
            -v old="${OLD_MOUNT}" -v bak="${BAK_MOUNT}" '
            /^[[:space:]]*#/ || /^[[:space:]]*$/ { print; next }
            {
                spec = $1
                gsub(/^UUID=["'"'"']?|["'"'"']$/, "", spec)
                if (spec == nuuid) $2 = old
                if (spec == ouuid) $2 = bak
                print
            }
        ' /etc/fstab > "$FSTAB_TMP"
        mv "$FSTAB_TMP" /etc/fstab
        chmod 644 /etc/fstab
        log "fstab 已更新(备份: ${FSTAB_BAK})"
    
        trap - ERR  # 清除回滚 trap(fstab 已更新,后续失败无需回滚挂载)
        # ── 高危区域结束 ─────────────────────────────────────────────────────────
    
        # 语法验证
        if mount -a --fake 2>/dev/null; then
            log "fstab 语法验证通过"
        else
            warn "mount -a --fake 失败,请检查 /etc/fstab(备份: ${FSTAB_BAK})"
        fi
    
        echo ""
        log "✅ 路径切换完成"
        echo "  ${OLD_MOUNT}   → 新盘 ${NEW_DEV}"
        echo "  ${BAK_MOUNT}  → 旧盘 ${OLD_DEV}(备份保留)"
        echo ""
        df -h "$OLD_MOUNT" "$BAK_MOUNT"
    }
    
    # ── 入口 ──────────────────────────────────────────────────────────────────────
    COMMAND="${1:-}"
    shift || true
    
    case "$COMMAND" in
        switch) cmd_switch "$@" ;;
        *)
            cat <<'USAGE'
    用法: bash disk-switch.sh switch [NEW_MOUNT] [OLD_MOUNT]
    
      路径切换:新盘接管旧路径,旧盘移到 OLD-bak 保留
      默认: /data-app  /data
    
    示例:
      bash disk-switch.sh switch /data-app /data
    
    前置检查:
      1. 数据同步已完成: bash disk-sync.sh status /data-app → STATUS=DONE
      2. 相关业务服务已停止
    USAGE
            exit 1
            ;;
    esac
    disk-switch.sh

     

  • 架构逻辑梳理

    # Disk Pipeline 完整指南(可移植版)
    
    > **用途**:本文档融合 disk 流水线(`disk-pipeline.groovy`)及全部配套脚本/Playbook/IAM/状态文件逻辑,供阅读、排障与移植到其他 Jenkins 使用。
    > **源目录**:`jenkins/nonprod/disk/`
    > **Job 名(参考)**:`ec2-disk-manage`
    > **SCM 加载**:Pipeline script from SCM → `jenkins/nonprod/disk/disk-pipeline.groovy`
    
    ---
    
    ## 0. 一图总览
    
    ```
    ┌──────────────────────────────────────────────────────────────────────────┐
    │                      Jenkins disk-pipeline.groovy                       │
    ├──────────────────────────────────────────────────────────────────────────┤
    │  CPS 块① 参数校验 + ⓪ 前置检查(mountpoint -q SYNC_DST)                │
    │    └─ eachInventoryDir: ENV 白名单 + DEPLOY_DIRS 路径穿越防护           │
    │                                                                          │
    │  CPS 块② ① EBS 创建 + ①b 打标签                                          │
    │    ①   aws-bulk-attach-volume.sh  (orchestrator)                        │
    │         └─ aws-bulk-attach-worker.sh (per-instance worker, xargs -P)     │
    │    ①b  aws-bulk-attach-volume.sh --tag-only                              │
    │                                                                          │
    │  CPS 块② ①c EBS 分离                                                     │
    │    aws-bulk-detach-volume.sh  (orchestrator)                             │
    │      ├─ ansible disk-umount.yml     (umount + 清 fstab)                  │
    │      └─ aws-bulk-attach-volume.sh --detach-only                           │
    │          └─ aws-bulk-attach-worker.sh do_detach                           │
    │                                                                          │
    │  CPS 块③ 变更确认 + ② 格式化挂载 + ③ 验证                                │
    │    ②   ansible disk-mount.yml     (8 层保护,仅操作空白新盘)            │
    │         └─ 失败回滚:detach 新建 EBS + 打 CleanupRequired 标签            │
    │    ③   ansible disk-verify.yml    (PASS/WARN/FAIL + fstab 语法)           │
    │         └─ disk-state-write.sh  写 per-IP .verify 文件                   │
    │                                                                          │
    │  CPS 块④ 汇总报告(PASS/WARN/FAIL/SKIP/UNREACHABLE 统计)                │
    └──────────────────────────────────────────────────────────────────────────┘
    
    跨构建门控(disk-gate-check.groovy + disk-gate-verify.sh):
      阶段⑤(路径切换)执行前,按 ENV/dstBasename/IP 三级隔离读 .verify 文件,
      TTL 72h,MISSING/STALE/FAILED/UNSAFE 任一命中即阻断切换。
    ```
    
    ---
    
    ## 1. 文件清单与职责
    
    | 文件 | 类型 | 职责 | 由谁调用 |
    |------|------|------|---------|
    | `disk-pipeline.groovy` | Jenkins Pipeline | 主入口,参数定义/校验/阶段编排/CPS 分块 | Jenkins SCM 加载 |
    | `disk-gate-check.groovy` | Groovy 片段 | 阶段⑤跨构建门控①:读 REPORT_FILE 或 per-IP .verify | `load()` 由 pipeline 调用 |
    | `aws-bulk-attach-volume.sh` | Bash orchestrator | ① 创建+挂载 / ①b 仅打标签 / ①c detach-only | pipeline stage ①/①b/①c 调用 |
    | `aws-bulk-attach-worker.sh` | Bash worker | 单实例 EBS 操作(attach/tag/detach 三种 action) | orchestrator 通过 `xargs -P` 调用 |
    | `aws-bulk-detach-volume.sh` | Bash orchestrator | ①c umount + 清 fstab + detach 全流程 | pipeline stage ①c 调用 |
    | `disk-mount.yml` | Ansible Playbook | ② 自动发现空白盘 → 格式化 → 挂载 → 写 fstab | pipeline stage ② 调用 |
    | `disk-umount.yml` | Ansible Playbook | ①c umount 挂载点 + 清 fstab 残留条目 | aws-bulk-detach-volume.sh 调用 |
    | `disk-verify.yml` | Ansible Playbook | ③ 验证挂载/容量/fstab,生成 REPORT_FILE | pipeline stage ③ 调用 |
    | `disk-switchover.yml` | Ansible Playbook | ⑤ 路径切换(/data-app→/data,/data→/data-bak)| pipeline stage ⑤ 调用(如启用)|
    | `disk-gate-verify.sh` | Bash | 跨构建逐 IP 检查 .verify 状态(MISSING/STALE/FAILED/UNSAFE)| disk-gate-check.groovy 调用 |
    | `disk-state-write.sh` | Bash | 从 REPORT_FILE 解析 per-IP 状态写入 `.verify` 文件 | pipeline stage ③ 调用 |
    | `disk-migration-iam-policy.json` | IAM JSON | Jenkins IAM 最小权限策略 | 账号管理员附加到 IAM User/Role |
    | `disk-migration-iam-policy.md` | Markdown | IAM 策略说明文档 | 人读 |
    | `scripts/disk-init.sh` | Bash | 目标服务器手工:mount/verify/all | 运维直登主机时使用 |
    | `scripts/disk-switch.sh` | Bash | 目标服务器手工:路径切换(含 ERR trap 回滚)| 运维直登主机时使用 |
    | `scripts/disk-sync.sh` | Bash | 目标服务器手工:后台 rsync / status / verify / stop | 运维直登主机时使用 |
    
    > 注:`scripts/` 下的脚本是 Ansible/Jenkins 之外的**手工操作通道**,与自动化流水线逻辑解耦但语义一致(同样的 8 层保护、UUID 校验、fstab 备份等)。
    
    ---
    
    ## 2. 流水线参数(Jenkins UI)
    
    ### 2.1 阶段开关(booleanParam)
    
    | 参数 | 默认 | 说明 |
    |------|------|------|
    | `STAGE_EBS_CREATE` | false | ① 创建 EBS 并挂载(从 TARGET_HOSTS IP 反查 instance-id)|
    | `STAGE_EBS_TAG` | false | ①b 为已挂载卷同步 EC2 实例标签 |
    | `STAGE_EBS_DETACH` | false | ①c umount + 清 fstab + detach + 打 CleanupRequired 标签 |
    | `STAGE_DISK_MOUNT` | true | ② 自动发现空白盘 → 格式化 → 挂载到 SYNC_DST |
    | `STAGE_DISK_VERIFY` | true | ③ 验证挂载/容量/fstab,生成 REPORT_FILE |
    | `DRY_RUN` | true | 预览模式(首次保持开启)|
    
    ### 2.2 环境与目标
    
    | 参数 | 默认 | 校验规则 |
    |------|------|---------|
    | `ENV` | dev | `^(dev\|sit\|fat\|qa\|uat\|mirror\|prod)[a-z0-9]{0,6}$`(白名单 + 最多 6 位后缀)|
    | `DEPLOY_DIRS` | (动态) | CascadeChoiceParameter,基于 ENV 列出 `INVENTORY_BASE` 下匹配目录;每项须 `^[A-Za-z0-9._-]+$` |
    | `TARGET_HOSTS` | all | `all` / 逗号分隔 IP / Ansible 分组名;非 all 时须 `^[0-9A-Za-z.,_-]+$` 且存在于 inventory |
    
    ### 2.3 AWS EBS 参数
    
    | 参数 | 默认 | 校验 |
    |------|------|------|
    | `AWS_REGION` | ap-northeast-1 | `^[a-z]{2}-[a-z]+-\d+$` |
    | `DISK_SIZE` | 60 | 正整数;同时用于自动发现 ±5G 窗口 |
    | `VOLUME_TYPE` | gp3 | 白名单 gp3/gp2/io1/io2/st1/sc1 |
    | `EBS_DEVICE` | /dev/xvdf | `^/dev/[a-zA-Z0-9]+$`;根盘黑名单 `/dev/xvda /dev/sda /dev/sda1 /dev/nvme0n1 /dev/nvme0n1pN` |
    | `UMOUNT_POINT` | /data-bak | ①c 专用,与 SYNC_DST 解耦;系统保留目录黑名单 |
    | `EBS_PARALLEL` | 5 | 1-20 整数(pipeline),worker 脚本范围 1-50 |
    
    ### 2.4 同步路径
    
    | 参数 | 默认 | 校验 |
    |------|------|------|
    | `SYNC_DST` | /data-app | 绝对路径 `^/[a-zA-Z0-9][a-zA-Z0-9/_-]*$`,禁止系统保留目录(exact + prefix 双层)|
    | `SYNC_SRC` | /data(固定)| 不通过参数暴露 |
    
    > **派生变量**:`dstBasename = syncDst.replaceFirst('^/','').replace('/','-')`(如 `/data-app` → `data-app`),用于 `STABLE_STATE_DIR` 三级隔离路径。
    
    ---
    
    ## 3. 流水线阶段详解
    
    ### 3.1 CPS 分块(关键!避免 Jenkins 64KB 方法体限制)
    
    ```groovy
    withEnv(ANSIBLE_ENV) {
        // CPS 块① 参数校验 + ⓪ 前置检查
    }
    withEnv(ANSIBLE_ENV) {
        // CPS 块② ① EBS 创建 + ①b 打标签 + ①c 分离
    }
    withEnv(ANSIBLE_ENV) {
        // CPS 块③ 变更确认 + ② 格式化挂载 + ③ 验证
    }
    withEnv(ANSIBLE_ENV) {
        // CPS 块④ 汇总报告
    }
    ```
    
    > **移植要点**:单个 `withEnv` 闭包内的代码不能超 64KB 字节码。如新增大量逻辑,按 stage 边界拆分到新的 `withEnv` 块。`@NonCPS` 辅助函数(`_normalizeLimit`、`_blockedPaths`、`_paramsBanner`)不经 CPS 变换,无此限制。
    
    ### 3.2 阶段 ⓪ 前置检查
    
    - `eachInventoryDir` 遍历匹配 ENV 的 inventory 目录
    - 对每台目标主机 SSH 执行 `mountpoint -q "${SYNC_DST}"` 只读检查
    - 收集已挂载主机 IP 到 `mountedIps` Set,后续 ①/② 自动跳过这些主机
    - DRY_RUN 下仍执行(不修改状态),让预览准确反映"哪些会被跳过"
    
    ### 3.3 阶段 ① EBS 创建
    
    ```
    TARGET_HOSTS → resolveIpList() → IP 列表
    IP → aws ec2 describe-instances (private-ip-address filter) → instance-id
    instance-id 格式校验 ^i-[0-9a-f]{8}([0-9a-f]{9})?$
    writeFile instanceFile
    aws-bulk-attach-volume.sh --file instanceFile --size --type --region --parallel --env --log
      └─ xargs -P PARALLEL -I INST_ID aws-bulk-attach-worker.sh attach INST_ID per-log
           ├─ fetch_instance_info (AZ/Name/PrivateIp/Tags)
           ├─ find_available_device (xvdf→xvdz 自动避让)
           ├─ build_merged_tags (继承实例白名单标签 + iac/managed_by 覆盖)
           ├─ IAM 预检 (create-volume --dry-run)
           ├─ describe-volumes 复用 available 孤儿卷(同 AZ 同 Size 同 env 同 managed_by)
           ├─ create-volume (仅 iac/managed_by 标签,走 EC2TagVolumeOnCreateOnly 白名单)
           ├─ create-tags (file://tags_file,走 EC2TagDevSitVolumes)
           ├─ wait volume-available (timeout 300s + 实际状态二次校验)
           ├─ attach-volume
           └─ wait volume-in-use (timeout 300s + 实际状态二次校验)
           失败路径:tag_orphan_volume (Status=orphaned, CleanupRequired=true, FailedAt, InstanceId)
    ```
    
    ### 3.4 阶段 ①b EBS 打标签
    
    ```
    同 ① 但 --tag-only 模式:
      └─ aws-bulk-attach-worker.sh tag INST_ID per-log
           ├─ fetch_instance_info
           ├─ describe-instances BlockDeviceMappings[?DeviceName=='${DEVICE}'].Ebs.VolumeId
           └─ create-tags file://tags_file
    ```
    
    ### 3.5 阶段 ①c EBS 分离(互斥,不与 ①/①b/② 同时启用)
    
    ```
    aws-bulk-detach-volume.sh 编排器:
      1. resolve_ip_list (all / 逗号 IP / Ansible 分组名)
      2. resolve_instance_ids (IP → instance-id via describe-instances)
      3. run_umount_playbook:
           每个 inventory 目录: ansible-playbook disk-umount.yml --limit "${TARGETS//,/:}:localhost"
           失败即中止,不进入 detach(避免主机侧残留就强 detach 损坏数据)
      4. run_aws_detach:
           aws-bulk-attach-volume.sh --file instances --detach-only
             └─ aws-bulk-attach-worker.sh detach INST_ID per-log
                  ├─ 查 BlockDeviceMappings 定位 vol-id
                  ├─ 根盘保护(/dev/xvda /dev/sda /dev/nvme0n1 等拒绝)
                  ├─ 当前 available → 直接 DETACH_OK(不重复 detach)
                  ├─ 当前 in-use → detach-volume --force → wait volume-available
                  └─ detach 后不打标签(由运维到控制台手动删除卷)
      5. 写 detach-summary-${BUILD_NUMBER}.txt (DETACH_OK / DETACH_FAIL 计数)
      退出码:0=成功;1=参数/前置错误;2=部分失败(pipeline 标 UNSTABLE)
    ```
    
    ### 3.6 阶段 ② 磁盘格式化挂载(disk-mount.yml 8 层保护)
    
    | 层 | 检查 | 失败行为 |
    |---|------|---------|
    | 1 | 目标挂载点已存在挂载 → `meta: end_host` 跳过 + 触发 EBS 回滚 | rollback_needed=true |
    | 2 | 自动发现仅匹配:无 FSTYPE + 无 MOUNTPOINT + 无 children + 大小 ±5G | 跳过本机 |
    | 2b | fstab + blkid 交叉排查:已配置或已挂载的盘从候选剔除 | 从候选移除 |
    | 3 | 候选 >1 块 → fail(歧义,要求手动指定 disk_device)| fail |
    | 3b | 大小二次校验(自动/手动均执行)| fail |
    | 3c | 手动指定时 lsblk FSTYPE 非空且不等于 fs_type → 拒绝 | fail |
    | 4a | 目标设备及分区存在已挂载路径 → 拒绝 | fail |
    | 4b | 目标设备承载 / /boot /data /home /var /usr /tmp /etc → 拒绝 | fail |
    | 4c | blkid 检测文件系统/元数据残留(LVM PV/RAID member)→ 拒绝 | fail |
    | 4d | pvdisplay 检测 LVM PV → 拒绝 | fail |
    | 4e | mdadm --examine 检测 RAID member → 拒绝 | fail |
    | 5 | mkfs `force: false`(已有 fs 不覆盖,双保险)| skip mkfs |
    | 6 | blkid -s UUID 格式隐式校验(用于 fstab)| assert 非空 |
    | 7 | fstab 使用 UUID 而非设备名(防重启后设备名漂移)| - |
    | 8 | 挂载选项含 `nosuid,nodev,nofail` | - |
    
    **EBS 回滚逻辑**(stage ② 内嵌):
    - disk-mount.yml 写 `skip-exists-${BUILD_NUMBER}.txt`(每行一个已挂载 IP)
    - pipeline 读取该文件,对每个 IP 在 `aws-bulk-attach-${BUILD_NUMBER}.log` 中 grep `OK <IP> ` 第 4 列 vol-id
    - vol-id 格式校验 `^vol-[0-9a-f]{8}([0-9a-f]{9})?$`
    - `aws ec2 detach-volume --force` + `create-tags Status=orphaned CleanupRequired=true`
    - **不调 DeleteVolume**(IAM 策略不含),运维到控制台手动删
    
    ### 3.7 阶段 ③ 磁盘挂载验证(disk-verify.yml)
    
    检查项:
    1. `mountpoint -q ${mount_point}` → 未挂载 = `FAIL_NOT_MOUNTED`
    2. `df -BG --output=avail` → 可用 < `min_size_gb`(50) = `FAIL_SIZE_TOO_SMALL`
    3. 旧盘对比:新盘可用 < 旧盘已用 = `WARN_LESS_THAN_OLD`(不计入失败,但提示清理)
    4. fstab:
       - `mount -a --fake` 语法 → 失败 = `FAIL_SYNTAX`
       - blkid UUID 格式 `^[0-9a-f]{8}-...$` 校验
       - grep fstab UUID 条目 + 字段数 ≥ 6 + 第 5/6 列数字 → `FSTAB_OK` / `FSTAB_MISSING_ENTRY` / `FSTAB_BAD_FORMAT`
    
    报告输出:
    - `[PASS] / [WARN] / [FAIL] / [SKIP] / [UNREACHABLE] <IP> <detail>`
    - pipeline 读 `[FAIL]` 计数 > 0 → `currentBuild.result = 'UNSTABLE'`
    - 调用 `disk-state-write.sh` 写 per-IP `.verify` 文件
    
    ### 3.8 阶段 ⑤ 路径切换(disk-switchover.yml,如启用)
    
    **前置门控**(disk-gate-check.groovy):
    - 优先读同构建 `REPORT_FILE`(`[FAIL]` / `[SKIP]` 计数)
    - 跨构建读 `STABLE_STATE_DIR` 下 per-IP `.verify` 文件
    - TTL 72h(`STABLE_VERIFY_TTL_MIN=4320`)
    - 输出 `GATE_NO_DIR / GATE_UNSAFE / GATE_MISSING / GATE_STALE / GATE_FAILED`,任一命中即 `error()`
    - `TARGET_HOSTS` 必须显式指定(禁 all,防漏验证)
    
    切换逻辑(shell 原子操作 + 级联回滚):
    1. 备份 fstab → `/etc/fstab.bak-YYYYMMDD-HHMMSS-PID`
    2. 构造新 fstab:`grep -vF` 移除两盘旧条目,追加新条目(新盘→target_mount,旧盘→old_mount_bak)
    3. `umount NEW_MOUNT` 失败 → 回滚 fstab
    4. `umount OLD_MOUNT` 失败 → 回滚 fstab + 重挂 NEW_MOUNT
    5. `mount TARGET_MOUNT` 失败 → 回滚 fstab + 恢复两盘原挂载
    6. `mount OLD_MOUNT_BAK` 失败 → umount TARGET_MOUNT + 回滚 fstab + 恢复两盘
    7. 验证 `mountpoint -q` 双盘均挂载成功
    
    ---
    
    ## 4. 跨构建状态管理
    
    ### 4.1 目录结构
    
    ```
    ${WORKSPACE}/disk-state/
    └── ${ENV}/
        └── ${dstBasename}/            # 如 data-app
            ├── verify-meta.txt        # 最近批次元信息(build/env/dst/hosts/timestamp)
            ├── 10.10.1.10.verify      # per-IP 状态文件
            ├── 10.10.1.11.verify
            └── ...
    ```
    
    ### 4.2 .verify 文件格式
    
    ```
    status=PASS
    build=123
    env=dev
    dst=/data-app
    timestamp=2026-09-15T08:30:00Z
    ```
    
    > `disk-state-write.sh` 用 `mktemp` + `mv` 原子写入(防并发撕裂)。timestamp 为 UTC ISO 8601。
    
    ### 4.3 门控检查逻辑(disk-gate-verify.sh)
    
    ```bash
    # 环境变量
    DIR=${STABLE_STATE_DIR}
    TTL_MIN=4320
    HOSTS="10.10.1.10 10.10.1.11"
    
    # 输出(每行一条,pipeline split 解析)
    GATE_NO_DIR              # 状态目录不存在
    GATE_UNSAFE:<host> ...   # 主机名含非法字符(tr -cd 'a-zA-Z0-9._-' 后不等原值)
    GATE_MISSING:<host> ...  # 无 .verify 文件
    GATE_STALE:<host> ...    # timestamp 超 TTL
    GATE_FAILED:<host> ...   # status 非 PASS
    # 无输出 = 全部通过
    ```
    
    ---
    
    ## 5. 安全机制全景
    
    ### 5.1 注入防护
    
    | 输入 | 校验规则 | 实现位置 |
    |------|---------|---------|
    | ENV | `^(dev\|sit\|fat\|qa\|uat\|mirror\|prod)[a-z0-9]{0,6}$` | pipeline 参数校验 + eachInventoryDir 二次校验 + aws-bulk-detach-volume.sh |
    | TARGET_HOSTS | `^[0-9A-Za-z.,_-]+$`(禁空格/冒号防 ansible 选项注入)| pipeline + aws-bulk-detach-volume.sh |
    | DEPLOY_DIRS | 每项 `^[A-Za-z0-9._-]+$`(防路径穿越)| pipeline eachInventoryDir |
    | SYNC_DST | `^/[a-zA-Z0-9][a-zA-Z0-9/_-]*$` + 系统保留目录黑名单 | pipeline 参数校验 |
    | EBS_DEVICE | `^/dev/[a-zA-Z0-9]+$` + 根盘黑名单 | pipeline + worker + aws-bulk-detach-volume.sh 三层 |
    | UMOUNT_POINT | 同 SYNC_DST + 系统保留目录 | pipeline + aws-bulk-detach-volume.sh |
    | instance-id | `^i-[0-9a-f]{8}([0-9a-f]{9})?$` | worker H1 + pipeline C5 |
    | volume-id | `^vol-[0-9a-f]{8}([0-9a-f]{9})?$` | worker P2/Q2 |
    | UUID | `^[0-9a-f]{8}-[0-9a-f]{4}-...$` | disk-init.sh/disk-switch.sh/disk-verify.yml |
    | AWS_REGION | `^[a-z]{2}-[a-z]+-\d+$` | pipeline 参数校验 |
    | tag key/val | `^[A-Za-z0-9:/_.-]{1,128}$` / `^[-A-Za-z0-9:/_.-]{1,256}$`,禁通配符/逗号 | aws-bulk-attach-volume.sh P3/P4/H6/N1 |
    
    ### 5.2 根盘保护(三层)
    
    1. **pipeline 参数校验**:EBS_DEVICE 黑名单 `/dev/xvda /dev/sda /dev/sda1 /dev/nvme0n1 /dev/nvme0n1pN`
    2. **pipeline stage ①c 二次校验**:同黑名单
    3. **worker do_detach 设备级拦截**:同黑名单
    
    ### 5.3 系统保留目录保护
    
    **Exact**:`/ /data /app /opt /root /tmp /var /etc /usr /bin /sbin /lib /lib64 /home /boot /proc /sys /dev /run /media /mnt /srv`
    **Prefix**:`/tmp/ /var/ /etc/ /usr/ /proc/ /sys/ /dev/ /run/ /boot/`
    
    > SYNC_DST 与 UMOUNT_POINT 均用此黑名单(pipeline `_blockedPaths()` + aws-bulk-detach-volume.sh `_BLOCKED_EXACT/_BLOCKED_PREFIX`)。
    
    ### 5.4 凭证安全
    
    - AWS AK/SK 从文件 `/data/aws-disk-iam.txt` 读取(非 Jenkins credential store)
    - 文件权限必须 400600(pipeline `stat -c '%a'` 校验)
    - `set +x` 防止 shell 回显含凭证的命令行
    - `withEnv` 注入环境变量,不写入 Groovy 堆
    - **TODO(代码注释标记)**:迁移到 `withCredentials([aws(credentialsId:'aws-disk-iam')])` + Mask Passwords 插件
    
    ### 5.5 IAM 最小权限(disk-migration-iam-policy.json)
    
    | Sid | Action | 限制 |
    |-----|--------|------|
    | EC2ReadOnly | Describe* | `aws:RequestedRegion` 限 ap-northeast-1/ap-southeast-1 |
    | EC2CreateVolumeGP3Only | CreateVolume | 仅 gp3 + 10≤Size≤2000 + 必须带 `iac/managed_by=jenkins-disk-manage-pipeline` |
    | DenyNonGP3VolumeCreate | CreateVolume Deny | 非 gp3 拒绝 |
    | DenySnapshotBasedVolumeCreate | CreateVolume Deny | 基于 snapshot 的创建拒绝 |
    | EC2TagDevVolumes | CreateTags (volume) | 目标卷须有 `iac/managed_by` + `env in [dev,sit,fat,qa,uat]`;TagKeys 白名单 |
    | EC2TagDevSnapshots | CreateTags (snapshot) | 同上但 env 用 StringLike `dev*` |
    | EC2AttachVolumeDevInstanceOnly | AttachVolume (instance) | 目标实例 `env=dev/sit/...` |
    | EC2AttachVolumeDevVolumeOnly | AttachVolume (volume) | 卷须有 `iac/managed_by` |
    | EC2DetachVolumeDevInstanceOnly | DetachVolume (instance) | 同 Attach |
    | EC2DetachVolumeDevVolumeOnly | DetachVolume (volume) | 同 Attach |
    | EC2CreateSnapshotDevVolumesOnly | CreateSnapshot (volume) | 源卷 `env=dev*` |
    | EC2CreateSnapshotTargetSnapshot | CreateSnapshot (snapshot) | RequestTag `env=dev*` |
    | EC2CreateTagsAtCreateVolume | CreateTags (volume) | CreateAction=CreateVolume + RequestTag `iac/managed_by` |
    | STSGetCallerIdentity | GetCallerIdentity | 用于 runbook 前置检查 |
    
    **关键设计**- AttachVolume/DetachVolume **双 Statement**(instance 侧约束 env 标签,volume 侧不约束,避免新卷未打标签时被拒)
    - **不含 DeleteVolume**:孤儿卷由 pipeline detach + 打 CleanupRequired 标签,运维到控制台手动删
    
    ---
    
    ## 6. 移植到其他 Jenkins 的步骤
    
    ### 6.1 前置准备
    
    ```bash
    # 1. 在目标 Jenkins 节点创建目录
    mkdir -p /data/devops-ansible/inventory
    mkdir -p /data                           # 放 runner.key 和 aws-disk-iam.txt
    chmod 700 /data
    
    # 2. 写 AWS 凭证文件(权限必须 400600cat > /data/aws-disk-iam.txt <<'EOF'
    AWS_ACCESS_KEY_ID=AKIA...
    AWS_SECRET_ACCESS_KEY=wJalr...
    EOF
    chmod 400 /data/aws-disk-iam.txt
    chown jenkins:jenkins /data/aws-disk-iam.txt
    
    # 3. 放 Ansible SSH 私钥
    install -m 400 /path/to/runner.key /data/runner.key
    chown jenkins:jenkins /data/runner.key
    
    # 4. 准备 inventory 目录(按 ENV 分子目录,每目录一个 hosts 文件)
    #    目录名匹配规则:(^|-)${ENV}([0-9]*(-|$))
    #    例如 ENV=dev → dev01, dev02, prod-dev 等
    ls /data/devops-ansible/inventory/
    #   dev01/hosts  dev02/hosts  sit01/hosts  ...
    ```
    
    ### 6.2 Jenkins Job 配置
    
    1. **New Item → Pipeline**
    2. **General**:勾选 "This project is parameterized"(pipeline 内 `properties` 已声明参数,可跳过)
    3. **Pipeline → Definition**:Pipeline script from SCM
    4. **SCM → Git**- Repository URL: `git@github.com:LiquidityTech/devops-cicd.git`(或你的仓库)
       - Credentials: Jenkins SSH key
       - Branch: `nonprod`(或目标分支)
    5. **Script Path**:`jenkins/nonprod/disk/disk-pipeline.groovy`
    6. **Lightweight checkout**:勾选(加速构建)
    7. **保存**
    
    ### 6.3 IAM 策略部署
    
    ```bash
    # 1. 创建策略(替换 ACCOUNT_ID)
    aws iam create-policy \
      --policy-name disk-migration-nonprod \
      --policy-document file://disk-migration-iam-policy.json \
      --description "Disk migration pipeline - nonprod"
    
    # 2. 附加到 Jenkins 使用的 IAM User 或 Role
    aws iam attach-user-policy \
      --user-name jenkins-disk-migration \
      --policy-arn arn:aws:iam::ACCOUNT_ID:policy/disk-migration-nonprod
    
    # 3. 验证权限(dry-run 预检)
    aws ec2 create-volume --dry-run --volume-type gp3 --size 60 \
      --availability-zone ap-northeast-1a \
      --tag-specifications 'ResourceType=volume,Tags=[{Key=iac/managed_by,Value=jenkins-disk-manage-pipeline}]'
    # 期望输出: (DryRunOperation) — 有权限
    ```
    
    ### 6.4 路径变量调整
    
    | 变量 | 默认值 | 调整建议 |
    |------|--------|---------|
    | `INVENTORY_BASE` | `/data/devops-ansible/inventory` | 改为你的 inventory 根目录 |
    | `ANSIBLE_KEY` | `/data/runner.key` | 改为你的 SSH 私钥路径 |
    | `ANSIBLE_USER` | `root` | 非 root 环境改为 `ansible` 或 `ec2-user`,并配置 sudo |
    | `AWS_CREDS_FILE` | `/data/aws-disk-iam.txt` | 改为你的凭证文件路径,或迁移到 `withCredentials` |
    | `STABLE_VERIFY_TTL_MIN` | 4320 (72h) | 按变更窗口调整 |
    
    ### 6.5 扩展到新环境
    
    1. **ENV 白名单**:pipeline 参数校验正则 `^(dev|sit|fat|qa|uat|mirror|prod)[a-z0-9]{0,6}$` 增加新环境名
    2. **IAM 策略**:在 `EC2TagDevVolumes` / `EC2AttachVolumeDevInstanceOnly` 等的 `ec2:ResourceTag/env` Condition 增加新 env3. **inventory 目录**:创建 `${ENV}*/hosts` 子目录
    4. **Jenkins 凭证**(可选):为每个 ENV 创建独立 `aws-${ENV}` 凭证,pipeline 通过 `credentialsId: "aws-${params.ENV}"` 动态选择
    
    ### 6.6 首次运行验证清单
    
    ```
    □ DRY_RUN=true,勾选 STAGE_DISK_VERIFY,执行 → 确认 inventory 解析正确
    □ DRY_RUN=true,勾选 STAGE_EBS_CREATE,执行 → 确认 IP→instance-id 反查成功
    □ DRY_RUN=true,全流程执行 → 预览所有阶段
    □ 选 1 台测试机,DRY_RUN=false,仅勾 STAGE_EBS_CREATE + STAGE_DISK_MOUNT + STAGE_DISK_VERIFY
    □ 验证 EBS 卷已创建并挂载(aws console + 目标主机 lsblk)
    □ 验证挂载点 + fstab UUID 条目
    □ 验证 .verify 文件已写入 STABLE_STATE_DIR
    □ 测试 ①c 分离流程(DRY_RUN=true 先预览,再 false 实际执行)
    □ 验证卷已 detach + 打 CleanupRequired 标签
    □ 到控制台手动删除测试卷(IAM 无 DeleteVolume 权限)
    ```
    
    ---
    
    ## 7. 关键陷阱与经验
    
    ### 7.1 CPS 64KB 限制
    
    **现象**:pipeline 方法体超 64KB 字节码 → Jenkins 抛 `IOException:ASM TransformerException` 或 `Method too large`。
    **解决**- 按 stage 边界拆 `withEnv` 块(每块独立方法)
    - 大量逻辑下沉到 Bash 脚本(`aws-bulk-detach-volume.sh` 就是为此从 pipeline 抽出)
    - `@NonCPS` 辅助函数无此限制
    
    ### 7.2 instance-id/volume-id 格式校验
    
    **现象**:AWS CLI 偶发返回 `null`/`None` 字符串而非空,被 shell 当作合法值,导致后续 `aws ec2 attach-volume --instance-id null` 失败。
    **解决**:worker 脚本统一校验 `^i-[0-9a-f]{8}([0-9a-f]{9})?$` 和 `^vol-[0-9a-f]{8}([0-9a-f]{9})?$`,排除 `None`/`null`。
    
    ### 7.3 孤儿卷(orphan volume)
    
    **场景**:create-volume 成功但 attach 失败 → 卷残留。
    **解决**- worker `tag_orphan_volume`:detach(如已 attach)+ 打 `Status=orphaned, CleanupRequired=true, FailedAt, InstanceId` 标签
    - pipeline stage ② 内嵌 EBS 回滚:检测 `skip-exists` 主机 → grep EBS 日志找 vol-id → detach + 打标签
    - **不删卷**(IAM 无 DeleteVolume),运维到控制台手动删
    
    ### 7.4 设备名自动避让
    
    **场景**:默认 `/dev/xvdf` 已被占用(之前挂载未清理)。
    **解决**:worker `find_available_device` 从 xvdf 起依次尝试 xvdg/xvdh/.../xvdz,第一个未挂载的胜出。
    
    ### 7.5 EBS 卷复用(防重复创建)
    
    **场景**:重跑 pipeline 时,之前创建的 available 卷未被挂载。
    **解决**:worker `do_attach` 先 `describe-volumes` 查 `tag:iac/managed_by=jenkins-disk-manage-pipeline + tag:env=<instance_env> + status=available + same AZ + same Size`,找到则复用,跳过 create-volume。
    - **仅对有 env 标签的实例复用**(无 env 跳过,防跨环境误复用)
    - 复用卷 attach 失败只写 FAIL,**不打 orphan 标签**(卷仍 available,下次可重跑)
    
    ### 7.6 fstab 写入用 UUID 而非设备名
    
    **原因**:NVMe 设备名在重启后可能漂移(`/dev/nvme1n1` → `/dev/nvme2n1`)。
    **实现**:disk-mount.yml / disk-init.sh / disk-switchover.yml 全部用 `UUID=<uuid>` 写 fstab。
    
    ### 7.7 cross-build 状态隔离
    
    **场景**:分批运行(先 10 台,再 10 台),per-IP 状态文件不能被覆盖。
    **解决**:状态目录三级隔离 `${ENV}/${dstBasename}/${IP}.verify`,`disk-state-write.sh` 用 `mktemp`+`mv` 原子写入单文件。
    
    ### 7.8 互斥校验(①c vs ①/①b/②)
    
    **原因**:detach 是破坏性操作,不能与创建/挂载同流执行(状态机混乱)。
    **实现**:pipeline 参数校验 + stage ①c 二次校验 `if (params.STAGE_EBS_CREATE || params.STAGE_EBS_TAG || params.STAGE_DISK_MOUNT) error(...)`。
    
    ---
    
    ## 8. 手工操作脚本(scripts/ 目录)
    
    > 用于流水线之外的应急或单机操作,逻辑与 Ansible 版本一致但更轻量。
    
    ### 8.1 disk-init.sh — 新盘挂载+验证
    
    ```bash
    # 在目标服务器以 root 执行
    bash disk-init.sh mount  /dev/nvme2n1 /data-app        # 格式化+挂载
    bash disk-init.sh verify /data-app /data               # 验证
    bash disk-init.sh all    /dev/nvme2n1 /data-app         # 挂载+验证一步完成
    ```
    
    **保护**- 检测旧盘 `/data` FSTYPE,新盘格式与旧盘一致(xfs/ext4)
    - UUID 格式校验 `^[0-9a-f]{8}-[0-9a-f]{4}-...$`
    - fstab 已存在 UUID 条目则跳过写入
    - 挂载点路径校验(禁换行符/`..`)
    
    ### 8.2 disk-switch.sh — 路径切换
    
    ```bash
    # 在目标服务器以 root 执行
    bash disk-switch.sh switch /data-app /data
    ```
    
    **保护**- 同步状态检查(读 `/var/run/data-sync-<dst>.pid` + `.rc`)
    - 进程占用检查 + 清理建议(systemctl stop / pkill -u)
    - ERR trap 全局回滚(umount 失败 → 恢复 fstab + 重挂原挂载)
    - 系统保留目录黑名单(同 pipeline `_blockedPaths`)
    
    ### 8.3 disk-sync.sh — 后台 rsync
    
    ```bash
    # 在目标服务器以 root 执行
    bash disk-sync.sh start /data /data-app                          # 后台同步
    bash disk-sync.sh start --exclude-dir /data/tmp --exclude '*.tmp' /data /data-app
    bash disk-sync.sh start --include '*.conf' --include '*.jar' --exclude '*' /data /data-app
    bash disk-sync.sh start --dry-run /data /data-app                # 预览
    bash disk-sync.sh status /data-app                               # 查看进度
    bash disk-sync.sh verify /data /data-app                          # 校验(文件数+checksum 抽样 20)
    bash disk-sync.sh stop /data-app                                  # 停止
    ```
    
    **特性**- 内置默认排除 `*.log *.log.gz *.log.[0-9]* *.log-* nohup.out`(可用 `--no-default-excludes` 关)
    - 后台 `nohup rsync` + watcher 脚本记录退出码到 `.rc` 文件
    - 自增计数防日志文件名冲突(`/tmp/data-app-YYYYMMDD-N.log`)
    - `flock -x` 保护计数文件并发写入
    - `verify` 抽样最近修改的 20 个文件做 md5sum 比对
    
    ---
    
    ## 9. 排障速查
    
    | 现象 | 可能原因 | 排查 |
    |------|---------|------|
    | `parameter validation failed: ENV` | ENV 不在白名单 | 检查 ENV 值是否 `^(dev\|sit\|fat\|qa\|uat\|mirror\|prod)[a-z0-9]{0,6}$` |
    | `未找到匹配 'X' 的 inventory 目录` | INVENTORY_BASE 路径错或目录名不匹配 | `ls $INVENTORY_BASE \| grep -E "(^\|-)X([0-9]*(-\|$))"` |
    | `AccessDenied (create-volume)` | IAM 缺权限或非 gp3 或超 Size 范围 | 检查 IAM 策略 `EC2CreateVolumeGP3Only` + DenyNonGP3 |
    | `FAIL no-available-device` | /dev/xvdf~xvdz 全占用 | 清理已挂载卷或改 EBS_DEVICE |
    | `FAIL attach-failed` + 孤儿卷 | attach 阶段失败 | 控制台找 `Status=orphaned` 卷,确认后删除 |
    | `SKIP_EXISTS` + EBS 回滚未触发 | skip-exists 文件路径不匹配 | 检查 disk-mount.yml 写入路径与 pipeline 读取路径 |
    | `GATE_STALE` | 验证超 72h | 重跑阶段 ③ 刷新 .verify 文件 |
    | `GATE_MISSING` | 跨构建切换未先验证 | 显式指定 TARGET_HOSTS,先跑 ③ |
    | Jenkins `Method too large` | CPS 64KB 超限 | 拆 withEnv 块或下沉逻辑到 Bash |
    | `mount -a --fake` 失败 | fstab 语法错 | 检查 `/etc/fstab.before-umount-cleanup*` 备份 |
    | keeper CrashLoop after detach | 卷未 umount 就 detach | 先跑 disk-umount.yml 再 detach |
    
    ---
    
    ## 10. 依赖与版本
    
    | 依赖 | 用途 | 安装 |
    |------|------|------|
    | Jenkins ( LTS 2.4+ ) | 流水线执行 | - |
    | Pipeline plugin + CPS | 支持 @NonCPS / CPS 分块 | Jenkins 内置 |
    | Ansible 2.10+ | Playbook 执行 | `pip install ansible` 或系统包 |
    | AWS CLI v2 | EBS API 调用 | `pip install awscli` 或 https://docs.aws.amazon.com/cli/ |
    | python3 | worker 脚本 JSON 解析 | 系统自带 |
    | `jq`(可选)| 调试 JSON 输出 | `apt install jq` |
    | `lsof` | 进程占用检查 | `apt install lsof` |
    | `flock` | disk-sync.sh 并发锁 | `util-linux` 自带 |
    | `md5sum` | disk-sync.sh verify 抽样 | `coreutils` 自带 |
    
    ---
    
    ## 11. 参考路径
    
    - **流水线入口**:`jenkins/nonprod/disk/disk-pipeline.groovy`
    - **IAM 策略**:`jenkins/nonprod/disk/disk-migration-iam-policy.json` + `.md`
    - **Ansible Playbooks**:`disk-mount.yml` / `disk-umount.yml` / `disk-verify.yml` / `disk-switchover.yml`
    - **Bash 编排器**:`aws-bulk-attach-volume.sh` / `aws-bulk-attach-worker.sh` / `aws-bulk-detach-volume.sh`
    - **状态管理**:`disk-gate-check.groovy` / `disk-gate-verify.sh` / `disk-state-write.sh`
    - **手工脚本**:`scripts/disk-init.sh` / `scripts/disk-switch.sh` / `scripts/disk-sync.sh`
    - **原有 README**:`README.md`(手工操作示例与 FAQ)
    
    ---
    
    **文档版本**:v1.0 · 2026-09-15 · 基于 `disk-pipeline.groovy` 当前版本
    View Code

     

  • 实践问题修复📝

    bash ghost-mount-detector.sh 
    [INFO] 扫描内核挂载表 (/proc/mounts)...
    
    [幽灵挂载 #1]
      设备路径   : /dev/nvme1n1
      挂载点     : /data
      文件系统   : xfs
      挂载选项   : rw,seclabel,noatime,attr2,inode64,logbufs=8,logbsize=32k,noquota
      内核记录   : mount ID 在 /proc/self/mountinfo
      实际状态   : /dev/nvme1n1 不存在或不是块设备
      lsblk 验证 :
        lsblk: /dev/nvme1n1: not a block device
        (lsblk 超时或失败)
    
      清理命令 (dry-run,未执行):
        umount -f /dev/nvme1n1        # 强制 umount
        # 若上面失败,用 lazy umount:
        umount -l /dev/nvme1n1        # lazy umount (fallback)
    
    ────────────────────────────────────────────────────────
    [INFO] 额外检查:叠挂(同一挂载点多个设备)
    
    [OK] 无叠挂
    
    ────────────────────────────────────────────────────────
    检测总结
    ────────────────────────────────────────────────────────
    幽灵挂载数量: 1
    叠挂数量    : 0
    
    [WARN] 发现 1 个幽灵挂载,以上为清理命令 (dry-run)
    如需执行清理,请运行:
      sudo bash ghost-mount-detector.sh --apply
    
    
    ---
    #!/usr/bin/env bash
    # ghost-mount-detector.sh — 幽灵挂载检测与清理
    #
    # 用途:
    #   1. 对比内核挂载表 (/proc/mounts) 和实际块设备 (lsblk),找出幽灵挂载
    #   2. 幽灵挂载 = 内核记录了挂载,但底层块设备节点不存在 (EBS detach 未 umount 干净)
    #   3. 输出检测报告 + 生成清理命令 (dry-run,默认不执行)
    #
    # 用法:
    #   bash ghost-mount-detector.sh            # 检测 + 显示清理命令 (dry-run)
    #   bash ghost-mount-detector.sh --apply    # 检测 + 自动执行清理 (需 root)
    #   bash ghost-mount-detector.sh --help     # 帮助
    #
    # 清理策略:
    #   umount -f <device>       # 强制 umount,适用于内核认为 busy 的挂载
    #   umount -l <device>       # lazy umount,立即从挂载表移除,实际清理延迟到不 busy 时
    #
    # 安全保证:
    #   - 只清理"设备节点不存在"的幽灵挂载,不动任何真实挂载
    #   - 默认 dry-run,显示命令但不执行,需 --apply 才真正 umount
    #   - --apply 需 root 权限,执行前再次校验设备不存在
    #   - 不删除任何文件,不触碰 fstab,只 umount 幽灵记录
    #
    # 退出码:
    #   0  检测完成 (无论是否发现幽灵挂载)
    #   1  参数错误 / 非 root 执行 --apply
    #   2  umount 失败
    
    set -euo pipefail
    
    # ── 颜色输出 ──────────────────────────────────────────────────────────────
    RED='\033[0;31m'
    YELLOW='\033[0;33m'
    GREEN='\033[0;32m'
    CYAN='\033[0;36m'
    BOLD='\033[1m'
    NC='\033[0m'
    
    log()   { echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
    info()  { echo -e "${CYAN}[INFO]${NC} $*"; }
    warn()  { echo -e "${YELLOW}[WARN]${NC} $*"; }
    ok()    { echo -e "${GREEN}[OK]${NC} $*"; }
    err()   { echo -e "${RED}[ERR]${NC} $*" >&2; }
    
    # ── 帮助 ──────────────────────────────────────────────────────────────────
    usage() {
        cat <<'USAGE'
    ghost-mount-detector.sh — 幽灵挂载检测与清理
    
    用法:
      bash ghost-mount-detector.sh              检测 + 显示清理命令 (dry-run)
      bash ghost-mount-detector.sh --apply      检测 + 自动执行清理 (需 root)
      bash ghost-mount-detector.sh --help       显示帮助
    
    幽灵挂载定义:
      内核挂载表 (/proc/mounts) 记录了某设备挂载到某挂载点,
      但实际块设备节点 /dev/xxx 不存在 (EBS detach 时未 umount 干净)。
    
    清理策略:
      umount -f <device>   # 强制 umount
      umount -l <device>   # lazy umount (fallback)
    
    安全保证:
      - 只清理"设备节点不存在"的幽灵挂载,不动任何真实挂载
      - 默认 dry-run,显示命令但不执行
      - --apply 需 root,执行前再次校验设备不存在
      - 不删除任何文件,不触碰 fstab
    
    示例:
      bash ghost-mount-detector.sh              # 只检测
      bash ghost-mount-detector.sh --apply      # 检测并清理
    USAGE
    }
    
    # ── 参数解析 ───────────────────────────────────────────────────────────────
    APPLY=false
    [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
    [[ "${1:-}" == "--apply" ]] && APPLY=true
    [[ $# -gt 1 ]] && { usage; exit 1; }
    
    # ── 权限校验 ───────────────────────────────────────────────────────────────
    if $APPLY; then
        [[ $EUID -eq 0 ]] || { err "--apply 需要 root 权限,请用 sudo 或切 root"; exit 1; }
    fi
    
    # ── 依赖检查 ───────────────────────────────────────────────────────────────
    for cmd in awk umount; do
        command -v "$cmd" >/dev/null 2>&1 || { err "缺少依赖: $cmd"; exit 1; }
    done
    # lsblk 可选 (用于额外展示设备信息),没有也能跑
    HAS_LSBLK=false
    command -v lsblk >/dev/null 2>&1 && HAS_LSBLK=true
    
    # ── 扫描挂载表,找挂载了但设备不存在的记录 ────────────────────────────────
    info "扫描内核挂载表 (/proc/mounts)..."
    echo ""
    
    GHOST_COUNT=0
    GHOST_LIST=""
    
    # /proc/mounts 格式: source target fstype opts dump pass
    # 只取 source/target/fstype/opts,跳过非设备源 (tmpfs, proc, sysfs, cgroup, overlay 等)
    while IFS=$'\t' read -r src tgt fstype opts; do
        # 跳过非设备源 (tmpfs, proc, sysfs, cgroup, overlay 等)
        [[ "$src" == /dev/* ]] || continue
    
        # 提取设备名 (去掉 /dev/ 前缀,如 nvme1n1)
        dev_name="${src#/dev/}"
    
        # 检查 /dev/xxx 设备节点是否存在且是块设备
        # 双重校验: -b 测试 + lsblk 验证 (如果可用)
        if [[ -b "/dev/$dev_name" ]]; then
            # 设备存在,不是幽灵挂载,跳过
            continue
        fi
    
        # 幽灵挂载确认: /proc/mounts 记录了 /dev/xxx,但 /dev/xxx 不是块设备
        GHOST_COUNT=$((GHOST_COUNT + 1))
        GHOST_LIST="$GHOST_LIST $src"
    
        echo -e "${RED}[幽灵挂载 #${GHOST_COUNT}]${NC}"
        echo "  设备路径   : $src"
        echo "  挂载点     : $tgt"
        echo "  文件系统   : $fstype"
        echo "  挂载选项   : $opts"
        echo "  内核记录   : mount ID 在 /proc/self/mountinfo"
        echo -e "  实际状态   : ${RED}/dev/$dev_name 不存在或不是块设备${NC}"
        if $HAS_LSBLK; then
            # timeout 5s 防止 lsblk 卡住 (设备不存在时 lsblk 可能长时间等待)
            lsblk_out=$(timeout 5 lsblk "/dev/$dev_name" 2>&1 | head -3 || echo "(lsblk 超时或失败)")
            echo "  lsblk 验证 :"
            echo "$lsblk_out" | sed 's/^/    /'
        fi
        echo ""
        echo -e "  ${YELLOW}清理命令 (dry-run,未执行):${NC}"
        echo "    umount -f $src        # 强制 umount"
        echo "    # 若上面失败,用 lazy umount:"
        echo "    umount -l $src        # lazy umount (fallback)"
        echo ""
    
    done < <(awk '{print $1"\t"$2"\t"$3"\t"$4}' /proc/mounts 2>/dev/null | sort -u)
    
    # ── 额外检查:叠挂(同一挂载点挂多个真实设备)────────────────────────────
    echo "────────────────────────────────────────────────────────"
    info "额外检查:叠挂(同一挂载点多个设备)"
    echo ""
    
    # 用 findmnt 找所有挂载点,看每个挂载点是否挂了多个设备
    declare -A MOUNT_POINT_COUNT=()
    declare -A MOUNT_POINT_DEVS=()
    while IFS=$'\t' read -r src tgt; do
        [[ "$src" == /dev/* ]] || continue
        # 只看真实存在的设备(幽灵挂载上面已处理)
        [[ -b "$src" ]] || continue
    
        MOUNT_POINT_COUNT["$tgt"]=$(( ${MOUNT_POINT_COUNT["$tgt"]:-0} + 1 ))
        MOUNT_POINT_DEVS["$tgt"]="${MOUNT_POINT_DEVS["$tgt"]:-} $src"
    done < <(awk '{print $1"\t"$2}' /proc/mounts 2>/dev/null | sort -u)
    
    STACK_COUNT=0
    for tgt in "${!MOUNT_POINT_COUNT[@]}"; do
        [[ ${MOUNT_POINT_COUNT["$tgt"]} -gt 1 ]] || continue
        STACK_COUNT=$((STACK_COUNT + 1))
        echo -e "${YELLOW}[叠挂 #${STACK_COUNT}]${NC}"
        echo "  挂载点     : $tgt"
        echo "  设备列表   :${MOUNT_POINT_DEVS["$tgt"]}"
        echo "  数量       : ${MOUNT_POINT_COUNT["$tgt"]}"
        echo ""
        echo -e "  ${YELLOW}清理建议:${NC}"
        echo "    # 1. 确认哪个是 fstab 声明的正确挂载"
        echo "    # 2. umount 栈顶的设备 (后挂的,findmnt 第一行)"
        echo "    # 3. 不要直接批量 umount,先确认数据归属"
        echo "    findmnt -n -o SOURCE,TARGET $tgt"
        echo ""
    done
    
    [[ $STACK_COUNT -eq 0 ]] && ok "无叠挂"
    
    echo ""
    echo "────────────────────────────────────────────────────────"
    echo "检测总结"
    echo "────────────────────────────────────────────────────────"
    echo "幽灵挂载数量: ${GHOST_COUNT}"
    echo "叠挂数量    : ${STACK_COUNT}"
    echo ""
    
    # ── 执行清理 (--apply) ──────────────────────────────────────────────────────
    if ! $APPLY; then
        if [[ $GHOST_COUNT -gt 0 ]]; then
            warn "发现 $GHOST_COUNT 个幽灵挂载,以上为清理命令 (dry-run)"
            echo "如需执行清理,请运行:"
            echo "  sudo bash $0 --apply"
        else
            ok "未发现幽灵挂载,挂载表干净"
        fi
        exit 0
    fi
    
    # --apply 模式:真正执行清理
    if [[ $GHOST_COUNT -eq 0 ]]; then
        ok "未发现幽灵挂载,无需清理"
        exit 0
    fi
    
    warn "即将清理 $GHOST_COUNT 个幽灵挂载"
    echo ""
    read -r -p "确认清理幽灵挂载? 输入 yes 继续 [yes/NO]: " CONFIRM
    [[ "$CONFIRM" == "yes" ]] || { info "已取消"; exit 0; }
    
    FAIL_COUNT=0
    CLEAN_COUNT=0
    for src in $GHOST_LIST; do
        # 再次校验设备确实不存在(防止竞态)
        if [[ -b "$src" ]]; then
            warn "$src 现在已经是块设备了 (可能刚被 attach),跳过清理"
            continue
        fi
    
        info "清理幽灵挂载: $src"
        # 先尝试 umount -f
        if umount -f "$src" 2>/dev/null; then
            ok "umount -f $src 成功"
            CLEAN_COUNT=$((CLEAN_COUNT + 1))
        elif umount -l "$src" 2>/dev/null; then
            ok "umount -l $src 成功 (lazy)"
            CLEAN_COUNT=$((CLEAN_COUNT + 1))
        else
            err "umount $src 失败 (umount -f 和 umount -l 都失败)"
            err "  可能原因: 1)内核仍认为 busy 2)需要重启节点 3)mount namespace 隔离"
            FAIL_COUNT=$((FAIL_COUNT + 1))
        fi
    done
    
    echo ""
    echo "────────────────────────────────────────────────────────"
    echo "清理总结"
    echo "────────────────────────────────────────────────────────"
    echo "成功清理: ${CLEAN_COUNT}"
    echo "清理失败: ${FAIL_COUNT}"
    echo ""
    
    if [[ $FAIL_COUNT -gt 0 ]]; then
        err "有 $FAIL_COUNT 个幽灵挂载清理失败,建议重启节点彻底清理"
        exit 2
    fi
    
    ok "所有幽灵挂载已清理"
    info "建议重新检测确认:"
    echo "  bash $0"
    
    exit 0
    防止前人埋坑导致幽灵挂载

     

posted @ 2026-09-17 11:28  JameMei  阅读(6)  评论(0)    收藏  举报