freeCodeCamp.org

How to Use Bash & Python for Real DevOps Automation – Full Handbook with 5 Production Use Cases

9.2内容质量
How to Use Bash & Python for Real DevOps Automation – Full Handbook with 5 Production Use Cases

TL;DR · AI 摘要

本文提供了一本详尽的手册,通过5个生产级用例展示如何用Bash和Python实现DevOps自动化,解决工具看似正常但系统实际异常的问题。

核心要点

  • 手册涵盖检测AWS异常支出、跨服务日志关联、发现Terraform外部的基础设施漂移等5个生产场景。
  • 每个用例包含可运行的演示环境、完整脚本及系统行为分析,帮助理解自动化验证现实的重要性。
  • 脚本设计小巧,重点在于背后的运营思维,如信号测量、故障模式检测及平台假设。

结构提纲

按章节快速跳转。

  1. 介绍自动化工具看似正常但系统可能异常的问题,引出手册目标。

  2. 使用Python脚本检测AWS账单前的异常支出。

  3. 通过trace ID关联多服务日志,定位问题根源。

  4. 检测Terraform外部的基础设施漂移,确保一致性。

  5. 验证应用级别的密钥轮换是否成功。

  6. 自动回滚响应慢的部署,提升用户体验。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Bash & Python for DevOps Automation

金句 / Highlights

值得收藏与分享的关键句。

  • The problem isn't the tooling. The problem is that the system can look healthy when it really is not.

    Paragraph 3

    ⬇︎ 下载 PNG𝕏 分享到 X
  • Each use case includes a runnable demo environment, the complete script, a breakdown of the system behaviour involved, and an intentional failure you can trigger yourself.

    Paragraph 6

    ⬇︎ 下载 PNG𝕏 分享到 X
  • The scripts are intentionally small. The important part is the operational thinking behind them.

    Paragraph 7

    ⬇︎ 下载 PNG𝕏 分享到 X
#DevOps#Bash#Python#Automation#AWS
打开原文

如何使用 Bash 和 Python 实现真正的 DevOps 自动化 —— 包含 5 个生产级用例的完整手册

Image 1: 如何使用 Bash 和 Python 实现真正的 DevOps 自动化 —— 包含 5 个生产级用例的完整手册
Image 1: 如何使用 Bash 和 Python 实现真正的 DevOps 自动化 —— 包含 5 个生产级用例的完整手册

自动化脚本通常会验证流程完成情况,而不是系统健康状态。

一个 Kubernetes Pod 可能处于运行状态,但内部的应用程序无法连接数据库。Terraform 部署可能显示成功,但有人手动修改了云控制台中的基础设施。金丝雀发布可能报告零错误,但用户却需要等待五秒才能完成每个请求。

问题不在于工具本身。问题在于系统可能看起来正常,但实际上并非如此。

本手册通过五个生产级自动化场景,展示如何使用 Bash 和 Python:

  • 在每月账单到达前检测 AWS 费用异常
  • 使用追踪 ID 跨服务关联日志
  • 发现 Terraform 外部的基础设施漂移
  • 在应用层验证密钥轮换
  • 在用户投诉前自动回滚缓慢部署

学完本手册后,你将能够编写小型脚本,帮助你在系统看似正常时发现潜在问题。

这些脚本故意保持简洁。关键在于其背后的运维思维:脚本测量哪些信号、能检测哪些故障模式,以及平台底层的假设。

每个用例均包含可运行的演示环境、完整脚本、系统行为分析,以及可自行触发的故障场景。

如果你是新手,请从用例 1 开始逐步学习。后续章节遵循相同模式:自动化只有在验证现实而非仅验证流程完成时才有价值。

前提条件

开始之前,请设置以下环境:

  • Python 3.8 或更高版本 – 通过 python3 --version 检查
  • Python 虚拟环境 – 在安装任何包之前创建:
code
python3 -m venv venv
source venv/bin/activate  

 # Windows 系统:

venv\Scripts\activate

这将隔离安装的包,防止共享机器上的权限错误。

  • pip – Python 的包管理器,随 Python 自带
  • 配置有效的 AWS CLI 配置文件 – 免费层级 AWS 账户即可满足用例 1、3 和 4 的需求。通过以下命令验证:
code
aws sts get-caller-identity
  • Docker 和 Docker Compose – 用于用例 2、4 和 5
  • Kind(Docker 中的 Kubernetes)– 本地运行 Kubernetes 的方式,适用于用例 4 和 5。macOS 用户可通过 brew install kind 安装,其他系统请参考 Kind 快速入门指南
  • kubectl – 与 Kubernetes 集群通信的命令行工具。安装 Kind 后,运行 kind create cluster 即可自动配置 kubectl
  • Helm – Kubernetes 的包管理器,用于用例 5。通过 brew install helmHelm 安装指南 安装
  • Terraform – 用于用例 3。macOS 用户可通过 brew install terraform 安装,其他系统请参考 Terraform 安装指南。通过 terraform version 验证
  • bc – 浮点数比较所需的计算器工具。macOS 用户可通过 brew install bc 安装,Ubuntu 用户使用 apt install bc。运行 bc --version 确认可用性,尤其在开始用例 5 之前

知识与技能要求

  • 能够阅读 Python 和 Bash 脚本,无需从头编写
  • 基础 Linux 终端操作能力 – 导航目录、运行脚本、查看输出等
  • 对 Kubernetes Pods 和 Deployment 的基本了解 – 用例 4 和 5 将逐步介绍所需概念,无需深入掌握
  • 熟悉 AWS 基础知识(如 EC2、IAM、Secrets Manager)有助于理解用例 1、3 和 4,而用例 2 完全基于本地环境,无需 AWS 知识
  • 对于用例 3,了解 Terraform 的作用及状态文件的概念将有所帮助。无需编写 Terraform 代码,但需理解 Terraform 如何跟踪资源

所需 AWS IAM 权限

本文脚本会调用真实 AWS API。你的 IAM 用户或角色需要以下最低权限(若遇到 AccessDenied 错误,请首先检查此处):

| 用例 | 必需 IAM 权限 | | --- | --- | | 1 - 成本异常检测 | ce:GetCostAndUsage | | 3 - 漂移检测 | ec2:DescribeSecurityGroups | | 4 - 密钥轮换 | secretsmanager:GetSecretValue, secretsmanager:PutSecretValue |

如果使用附带 AdministratorAccess 权限的新建免费层级 AWS 账户,则无需额外配置。

对于受限 IAM 用户,可通过以下步骤添加权限:在 AWS 控制台中进入 IAM -> 用户 -> 选择用户名 -> 权限标签页 -> 添加权限 -> 创建内联策略。切换到 JSON 标签页,粘贴对应权限的策略文档,保存即可。

若公司通过组织管理 AWS 且无权限修改 IAM 策略,请联系管理员将上述权限添加至你的角色。

伴生 GitHub 仓库

所有示例项目均托管于:**https://github.com/irvingtalks/devops-scripting-labs**

每个用例对应一个编号文件夹,包含完整脚本、辅助文件、用于准备环境的setup.sh脚本,以及注入特定故障的break_it.sh脚本。

开始前请先克隆仓库:

code
git clone https://github.com/irvingtalks/devops-scripting-labs
cd devops-scripting-labs

运行任何用例前,请确保已安装所需工具:

code
./preflight.sh

该脚本会检查实验室所需的工具(如Python、AWS CLI、Docker、Kind、Helm、Terraform和bc),并明确提示缺失组件及其安装命令。

目录

用例1 - 成本异常检测

环境: AWS Cost Explorer API(只读权限,适用于所有账户) 语言: Python

生产问题场景

一名初级工程师正在测试Kubernetes配置。他们在AWS上创建了托管节点组(一组供Kubernetes集群运行工作负载的EC2虚拟机),并配置了集群自动扩展器(负责在集群需要更多容量时添加机器)。测试顺利进行,但在周五下午,他们忘记了销毁测试环境。

周末期间,自动扩展器持续新增节点,因为测试工作负载仍在运行并请求资源。到周一早上,节点组已经悄悄增长了两天半,直到三周后收到账单时才被发现。

本用例中的脚本存在是因为AWS账单并非简单的月度数字——它是一个时间序列,可以像监控应用指标一样进行监测。每天检查账单基线,就能在数小时内而非数周内发现此类事件。

系统层面的实际机制

这不是什么: 这不是财务仪表盘。这是一个操作级异常检测器,监控信号是成本。但它实际检测的是意外的基础设施行为,如未关闭的资源、自动扩展事件或遗忘的环境。

AWS Cost Explorer是存储账单数据并通过API暴露的服务。调用该服务时,需指定时间范围、粒度和分组方式,对账户账单记录执行查询。

在排查任何标记的成本激增前需注意:AWS决定将费用归类到哪个服务类别,而非用户。例如,跨区域复制的EBS快照可能计入EC2账目而非数据传输,这意味着EC2支出激增并不一定意味着EC2实例存在问题。脚本会正确标记这种激增,但调查时需要问的是“我的基础设施在该日期发生了哪些变化”,而非“当前EC2中运行着什么”。

账单标签是排查的起点,而非诊断结论。

设置演示环境

进入配套仓库01-cost-anomaly/目录。本用例无需部署集群,因脚本直接针对AWS账户运行,唯一依赖是boto3库:

code
cd 01-cost-anomaly
pip install boto3

在真实账户上运行前,请确保已配置AWS凭证。脚本使用AWS CLI配置的凭证。若尚未配置:

code
aws configure

系统会提示输入AWS访问密钥ID、秘密访问密钥、默认区域(不确定时使用us-east-1)和输出格式(输入json)。可在AWS控制台IAM → 用户 → 您的用户名 → 安全凭证 → 创建访问密钥处获取密钥。

新账户需具备ce:GetCostAndUsage权限,管理员权限已包含此权限。

若您的AWS账户已有数周账单历史,可直接运行脚本:

code
python detect_cost_anomaly.py

在真实账户上运行前需注意两点:首先,Cost Explorer数据有24小时延迟,今日消费需次日才能显示,因此脚本会自动排除最近一天的数据。其次,脚本使用非混合成本(单账户实际支付金额),混合成本是多账户共享预留容量时使用的加权平均值,会产生不同数值。

若使用新账户或不想使用真实数据,脚本提供--sample参数,使用内置数据且不调用AWS API:

code
python detect_cost_anomaly.py --sample

建议先运行此命令查看输出样式,再阅读代码。

脚本内容

python
#!/usr/bin/env python3
# detect_cost_anomaly.py — 用例1: 成本异常检测
# 函数完整解释详见文章。

import statistics
import sys
from datetime import datetime, timedelta

import boto3

def build_sample_data(days=30):
    """生成过去`days`天(截至昨天)的模拟Cost Explorer记录。

EC2峰值设置在昨日(设备本地日期),因此示例输出始终与实时Cost Explorer模式的时间窗口匹配。

last_day = datetime.today().date() - timedelta(days=1) first_day = last_day - timedelta(days=days - 1) anomaly_day_index = days - 1 results = [] for i in range(days): day = first_day + timedelta(days=i) d = i + 1 results.append( { "TimePeriod": { "Start": str(day), "End": str(day + timedelta(days=1)), }, "Groups": [ { "Keys": ["Amazon EC2"], "Metrics": { "UnblendedCost": { "Amount": str( round( 18.50 if i == anomaly_day_index else 1.10 + (d % 3) * 0.10, 2, ) ) } }, }, { "Keys": ["Amazon S3"], "Metrics": { "UnblendedCost": { "Amount": str(round(0.04 + (d % 5) * 0.01, 2)) } }, }, { "Keys": ["Amazon RDS"], "Metrics": { "UnblendedCost": { "Amount": str(round(0.85 + (d % 4) * 0.05, 2)) } }, }, ], } ) return results, str(last_day)

def get_daily_costs(days=30): ce = boto3.client("ce", region_name="us-east-1") end = datetime.today().date() - timedelta(days=1) start = end - timedelta(days=days) response = ce.get_cost_and_usage( TimePeriod={"Start": str(start), "End": str(end)}, Granularity="DAILY", Metrics=["UnblendedCost"], GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], ) return response["ResultsByTime"]

def build_service_timeseries(results): services = {} for day in results: date_str = day["TimePeriod"]["Start"] for group in day["Groups"]: service = group["Keys"][0] cost = float(group["Metrics"]["UnblendedCost"]["Amount"]) if service not in services: services[service] = [] services[service].append({"date": date_str, "cost": cost}) return services

def detect_anomalies(services, baseline_days=7, multiplier=2.0, recent_days=None): """标记成本超过前baseline_days平均值+2σ的异常日。

使用滚动基准(每日对比前一周数据)。如果设置了recent_days, 则仅返回今日起前recent_days内的异常。 """ cutoff = None if recent_days is not None: cutoff = datetime.today().date() - timedelta(days=recent_days)

anomalies = [] for service, daily in services.items(): if len(daily) < baseline_days + 1: continue for i in range(baseline_days, len(daily)): day = daily[i] day_date = datetime.strptime(day["date"], "%Y-%m-%d").date() if cutoff is not None and day_date < cutoff: continue baseline_costs = [d["cost"] for d in daily[i - baseline_days : i]] avg = statistics.mean(baseline_costs) if avg < 0.01: continue try: std = statistics.stdev(baseline_costs) except statistics.StatisticsError: continue threshold = avg + (multiplier * std) if day["cost"] > threshold: anomalies.append( { "service": service, "date": day["date"], "actual": round(day["cost"], 4), "baseline_avg": round(avg, 4), "threshold": round(threshold, 4), "pct_above": round(((day["cost"] - avg) / avg) * 100, 1), } ) return sorted(anomalies, key=lambda x: x["date"])

def parse_args(argv): use_sample = "--sample" in argv recent_days = None for arg in argv[1:]: if arg.startswith("--recent-days="): recent_days = int(arg.split("=", 1)[1]) return use_sample, recent_days

def run(use_sample=False, recent_days=None): if use_sample: results, anomaly_date = build_sample_data() print("正在使用示例数据运行(--sample模式).") print( f"该数据代表截至昨日的30天账单数据," f"其中包含{anomaly_date}的真实EC2异常。\n" ) else: print("正在按服务获取过去30天的每日AWS成本...") print("注意:今天的数据被排除——Cost Explorer存在24小时计费延迟。\n") results = get_daily_costs(days=30)

if recent_days is not None: since = datetime.today().date() - timedelta(days=recent_days) print( f"仅检查最近{recent_days}天内的突增情况 " f"(从{since}开始),每日对比前7天平均值。\n" )

services = build_service_timeseries(results) anomalies = detect_anomalies(services, recent_days=recent_days)

if not anomalies: print("未检测到异常。") print("\n注意:此脚本会针对您自己的基线标记统计异常值。") print("持续升高的支出水平不会触发警报——仅突然增加的情况才会被标记。") return

code

print(f"{'=' * 60}") print(f"检测到异常:{len(anomalies)}") print(f"{'=' * 60}\n")

for a in anomalies: print(f"服务名称: {a['service']}") print(f"日期: {a['date']}") print(f"实际支出: ${a['actual']}") print(f"基线均值: ${a['baseline_avg']}(前7天平均值)") print(f"阈值: ${a['threshold']}") print(f"超支比例: {a['pct_above']}% 高于基线") print()

print("=" * 60) print("关于AWS成本归因的说明:") print("成本管理器中的服务标签由AWS分配,而非直接对应引发成本的具体资源。例如,EC2费用激增可能是由EBS快照复制、跨区域数据传输或自动扩展事件引起的——这些都会被AWS归类到EC2账单项下,而非控制台中可见的运行实例。") print() print("在直接调查标记的服务之前,请先思考:") print("我的基础设施在标记日期当天或之前发生了什么变化?") print("应从操作变更反向追溯问题根源,而非从账单标签正向推导。")

if __name__ == "__main__": use_sample, recent_days = parse_args(sys.argv) run(use_sample=use_sample, recent_days=recent_days)

code

### 脚本工作原理

`get_daily_costs` 函数会获取您过去30天的AWS账单数据。

`build_service_timeseries` 将原始数据重新组织。AWS默认按日期分组再细分服务,该函数将其反转为每个服务独立的时间序列,这是异常检测所需的格式。

`detect_anomalies` 是核心检测模块。对每个服务而言,它将每日支出与前7天进行对比。若昨日支出显著高于历史均值,则触发告警。这就是全部逻辑。

`--recent-days=7` 参数表示“仅显示最近7天内的异常”。脚本仍需获取30天数据以计算历史基准,但最终结果会被过滤到指定时间窗口。这非常适合周一早上的快速检查。

`--sample` 模式无需访问真实AWS账户。它使用内置的模拟数据,在昨日日期生成预设的EC2费用激增,确保每次运行都能触发告警。建议首次使用时启用此模式查看输出样式,再连接真实数据。

### 输出示例

运行 `--sample` 模式(激增日期将显示为昨日的实际日期,非固定值):

正在使用示例数据(--sample模式)。 获取截至昨日的30天账单数据,包含2026-05-14的EC2费用激增。

============================================================ 检测到异常:1 ============================================================

服务名称: Amazon EC2 日期: 2026-05-14 实际支出: $18.5 基线均值: $1.2143(前7天平均值) 阈值: $1.3939 超支比例: 1423.4% 高于基线

============================================================ 关于AWS成本归因的说明: 成本管理器中的服务标签由AWS分配,而非直接对应引发成本的具体资源。例如,EC2费用激增可能是由EBS快照复制、跨区域数据传输或自动扩展事件引起的——这些都会被AWS归类到EC2账单项下,而非控制台中可见的运行实例。

在直接调查标记的服务之前,请先思考: 我的基础设施在标记日期当天或之前发生了什么变化? 应从操作变更反向追溯问题根源,而非从账单标签正向推导。

code

您的具体数值会与示例略有不同,因为模拟数据动态生成当前日期。激增始终出现在昨日,周围基线数值会随运行日期变化。

### 脚本无法替您决定的问题

当EC2行出现异常时,直觉反应是检查运行中的EC2实例。但正如输出警告所述,成本归因由AWS定义而非用户自定。

在打开EC2控制台之前,请先查阅部署历史记录。当日是否部署了新环境?是否触发了自动扩展事件?从操作变更出发追踪线索,比从账单标签反向推理更高效且不易误导。

### 故意触发测试

不需AWS账户即可立即看到激增效果

python detect_cost_anomaly.py --sample

连接真实账户运行

python detect_cost_anomaly.py

仅显示最近7天的异常(适合快速本周检查)

python detect_cost_anomaly.py --recent-days=7

同时启用示例数据和时间过滤

python detect_cost_anomaly.py --sample --recent-days=7

code

**如果真实账户返回“未检测到异常”,这并非失败。** 这意味着您的支出一直稳定。干净的账户会产生干净的输出。脚本正在正常运行。

当真实事件发生时(如失控的自动扩展器、遗留环境或意外的数据传输),该工具会在账单生成前及时发现异常。

## 用例2 - 跨服务日志关联

**环境:** 完全本地化 - Docker Compose,三个Python服务

**语言:** Python

### 生产环境问题

用户报告支付失败。您打开日志分析工具搜索。认证服务记录了成功的身份验证。记账服务记录了成功的交易,但本应发送付款确认邮件的通知服务却没有任何日志记录。

两个服务报告成功,一个服务沉默无声。支付仍然失败,而您面对三份日志却无法明确哪个环节出了问题。

### 系统级实际状况

**这不是什么:** 这不是安装日志聚合工具的指南。本文讨论的是使日志关联成为可能的数据结构,以及当该结构在某个服务的错误路径上失效时会发生什么。

在一个单体系统中,调试很简单:一个服务,一个日志文件,一条时间线。但当用户请求经过多个服务时,你需要一种方法将所有日志串联起来。这种连接被称为追踪ID(trace ID)。

可以将其想象为政府办事大厅的排队号码。当你进入大厅时,会得到一个编号,比如A247。每个处理你事务的窗口都会在你的文件上写上A247。如果出现问题,管理人员只需提取所有带有A247的记录,就能按顺序查看每个窗口发生的情况。这就是追踪ID的作用——一个贯穿所有接触过请求的服务的唯一标识符。

在演示中,当支付请求到达时,认证服务会生成一个唯一的ID。认证、账本和通知服务为该支付生成的所有日志行都会包含相同的ID。当出现故障时,你可以运行`correlate.py`脚本,传入该ID,它会从三个服务的日志中找到所有相关条目并按时间排序:

python correlate.py pay-abc123

code

以下是这些日志的示例。注意每行都有相同的`trace_id`:

{"timestamp": "2026-05-01T14:23:01.234Z", "trace_id": "pay-abc123", "service": "auth", "event": "user_authenticated", "level": "INFO", "user_id": "u_789", "duration_ms": 12} {"timestamp": "2026-05-01T14:23:01.891Z", "trace_id": "pay-abc123", "service": "ledger", "event": "transaction_recorded", "level": "INFO", "amount": 50.0, "currency": "USD"} {"timestamp": "2026-05-01T14:23:02.103Z", "trace_id": "pay-abc123", "service": "notification", "event": "email_queued", "level": "INFO", "recipient": "[email protected]"}

code

现在来看问题所在。假设通知服务在连接邮件服务器时超时。编写错误处理程序的开发人员忘记在日志中包含追踪ID,因此错误日志变成了这样:

2026-05-01T14:23:02.415Z ERROR Connection timeout to email provider smtp.example.com:587

code

虽然错误确实发生了,日志也存在,但由于缺少`trace_id`,`correlate.py`无法找到这条记录。

此时的时间线显示通知服务仍有`email_send_attempt`事件,但后续的`email_queued`事件缺失:

Timeline — 5 events across 3 service(s):

[2026-05-15T21:59:00.605307+00:00] [AUTH] [INFO] payment_request_received [2026-05-15T21:59:00.606008+00:00] [AUTH] [INFO] user_authenticated [2026-05-15T21:59:00.617331+00:00] [LEDGER] [INFO] transaction_recorded [2026-05-15T21:59:00.630313+00:00] [NOTIFICATION] [INFO] email_send_attempt [2026-05-15T21:59:00.685182+00:00] [AUTH] [INFO] payment_complete

code

尝试发送邮件的记录存在,但失败信息缺失。这仅仅是因为开发人员漏掉了一个字段。

![Image 2: 日志关联尝试终端输出 - ERROR Connection timeout](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/22b7d7b0-8ae5-4573-bcb0-faaf5d807e8a.png)

### 设置演示环境

进入`02-log-correlation/`目录并启动三个服务:

cd 02-log-correlation docker compose up -d

code

这将启动认证、账本和通知服务。触发支付请求以生成日志:

./trigger_request.sh

code

![Image 3: trigger_request.sh终端输出 - 显示traceid](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/06977757-e7fd-43c3-aaca-dbc6d17c951a.png)

脚本会打印使用的追踪ID。复制该ID并在破坏任何内容之前运行关联脚本,查看完整的正常流程:

python correlate.py pay-5831e1bf

code

你应该看到类似以下内容(你的追踪ID不同,但结构相同):

Loading logs from ./logs/... Loaded 6 structured log lines.

============================================================ Trace ID: pay-5831e1bf ============================================================

Timeline - 6 events across 3 service(s):

[2026-05-15T21:42:28.079046+00:00] [AUTH] [INFO] payment_request_received service: auth user_id: u_789 amount: 50.0 [2026-05-15T21:42:28.080718+00:00] [AUTH] [INFO] user_authenticated service: auth user_id: u_789 duration_ms: 12 [2026-05-15T21:42:28.145528+00:00] [LEDGER] [INFO] transaction_recorded service: ledger user_id: u_789 amount: 50.0 currency: USD [2026-05-15T21:42:28.210088+00:00] [NOTIFICATION] [INFO] email_send_attempt service: notification recipient: [email protected] [2026-05-15T21:42:28.347893+00:00] [NOTIFICATION] [INFO] email_queued service: notification recipient: [email protected] amount: 50.0 [2026-05-15T21:42:28.378402+00:00] [AUTH] [INFO] payment_complete service: auth user_id: u_789 amount: 50.0

code

![Image 4: 终端输出展示完整支付流程](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/26a0b226-4e3d-4967-a7c6-6d367409fb1d.png)

这是包含认证、账本和通知服务的完整支付流程,按实际发生顺序排列。现在让我们看看脚本的工作原理。

### 脚本解析

correlate.py

import json import os import sys

SERVICES = ["auth", "ledger", "notification"] LOG_DIR = "./logs"

def load_logs(log_dir): """ 读取每个服务的日志文件,并将每一行解析为JSON。 解析失败的行会作为警告打印,不会被静默丢弃。 对于本应生成结构化日志的服务而言,纯文本错误本身也是有价值的证据。 """ all_lines = []

for service in SERVICES: log_file = os.path.join(log_dir, f"{service}.log")

if not os.path.exists(log_file): print(f" WARNING: No log file for '{service}' at {log_file}") continue

python
with open(log_file) as f:
    for line_num, line in enumerate(f, 1):
        line = line.strip()
        if not line:
            continue
        try:
            parsed = json.loads(line)
            parsed["_source"] = service
            all_lines.append(parsed)
        except json.JSONDecodeError:
            # 这一行存在于日志但无法关联。
            print(f"  警告:{service}.log第{line_num}行不是结构化的JSON:")
            print(f"           {line[:100]}")
            print(f"           此行将不会出现在任何基于追踪的搜索结果中。")

return all_lines

def correlate(trace_id, all_lines):
    """
    查找所有具有此trace_id的日志行,并按时间戳排序。
    排序后的结果即为重建的请求时间线。
    """
    matched = [line for line in all_lines if line.get("trace_id") == trace_id]
    matched.sort(key=lambda x: x.get("timestamp", ""))
    return matched

def find_missing_services(matched):
    """
    检查哪些服务在此请求中未生成带有追踪标记的日志行。
    缺失的服务不仅仅是不存在——它是一个信号。
    可能的情况包括:请求从未到达该服务,或者错误路径吞没了追踪ID。
    这两种情况都值得调查。
    """
    services_seen = {line["_source"] for line in matched}
    return [s for s in SERVICES if s not in services_seen]

def print_timeline(trace_id, matched, missing):
    print(f"\n{'=' * 60}")
    print(f"Trace ID: {trace_id}")
    print(f"{'=' * 60}")

    if not matched:
        print("\n未找到与此追踪ID匹配的结构化日志行。")
        print("可能是追踪ID错误,或者没有任何服务为此请求生成了结构化日志行。")
        return

    services_count = len({line["_source"] for line in matched})
    print(f"\n时间线 - {len(matched)}个事件跨越{services_count}个服务:\n")

    for line in matched:
        ts = line.get("timestamp", "未知")
        service = line.get("_source", "未知").upper()
        event = line.get("event", "未知事件")
        level = line.get("level", "INFO")
        extras = {k: v for k, v in line.items()
                  if k not in ("timestamp", "trace_id", "event", "level", "_source")}

        print(f"  [{ts}] [{service}] [{level}] {event}")
        for k, v in extras.items():
            print(f"    {k}: {v}")

    if missing:
        print(f"\n{'=' * 60}")
        print("缺失的遥测数据")
        print(f"{'=' * 60}")
        print(f"以下服务未为此追踪ID生成带追踪标记的事件:\n")
        for s in missing:
            print(f"  - {s}")
        print()
        print("这通常意味着三种情况:")
        print("  1. 请求从未到达该服务。")
        print("  2. 服务接收到了请求,但错误路径吞没了追踪ID,")
        print("     导致生成的纯文本日志行无法通过追踪关联。")
        print("  3. 该服务的日志文件未包含在本次分析中。")
        print()
        print("请检查原始日志文件中同一时间戳附近的纯文本错误日志。")
        print("如果存在此类日志,则表明这是根本原因——并且需要修复结构化日志记录的缺口。")

def run(trace_id):
    print(f"正在加载来自{LOG_DIR}/...的日志")
    all_lines = load_logs(LOG_DIR)
    print(f"已加载{len(all_lines)}条结构化日志行。\n")

    matched = correlate(trace_id, all_lines)
    missing = find_missing_services(matched)
    print_timeline(trace_id, matched, missing)

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法:python correlate.py <trace_id>")
        print("示例:python correlate.py pay-abc123")
        sys.exit(1)
    run(sys.argv[1])

脚本工作原理

load_logs从每个服务读取日志文件。每行应为JSON格式。若某行非JSON格式,会打印警告,通常表示错误日志缺少追踪ID而无法追踪。

correlate查找所有匹配给定追踪ID的日志行,并按时间排序,从而重建跨服务的完整请求流程。

find_missing_services检查哪些服务未生成该追踪ID的日志。这可揭示请求终止点或追踪ID丢失位置。

print_timeline按顺序显示完整请求时间线。若日志记录不完整,还会列出缺失的服务。

在真实Kubernetes环境中需注意:

在Kubernetes中,kubectl logs仅显示当前运行的容器日志。

若Pod重启,可使用:

code
kubectl logs <pod-name> --previous

但这仅适用于最近一次重启。更早的日志需要依赖Loki或CloudWatch等日志系统。

故障演示输出

本节展示当服务静默失败时的情况——错误存在于日志但因开发者遗漏字段导致脚本无法关联。

break_it.sh强制通知服务在发送邮件时失败,由于错误处理器未添加追踪ID,故障被记录为纯文本日志,无法回溯到原始请求。

执行步骤:

code
./break_it.sh

触发新请求:

code
./trigger_request.sh

复制输出的追踪ID,运行关联脚本:

code
python correlate.py pay-xxxxxxxx

输出示例:

code
正在加载来自./logs/...的日志
  警告:notification.log第10行不是结构化的JSON:
           2026-05-15T21:59:00.681583+00:00 ERROR 连接超时至电子邮件
           提供商http://mock-email:80/耗时0.001秒 - 失败向[email protected]发送
           确认邮件
           此行将不会出现在任何基于追踪的搜索结果中。
已加载29条结构化日志行。

============================================================ 追踪ID:pay-6cf69a8c ============================================================

时间线 - 跨3个服务的5个事件:

[2026-05-15T21:59:00.605307+00:00] [AUTH] [INFO] payment_request_received [2026-05-15T21:59:00.606008+00:00] [AUTH] [INFO] user_authenticated [2026-05-15T21:59:00.617331+00:00] [LEDGER] [INFO] transaction_recorded [2026-05-15T21:59:00.630313+00:00] [NOTIFICATION] [INFO] email_send_attempt [2026-05-15T21:59:00.685182+00:00] [AUTH] [INFO] payment_complete

code

仔细观察这个时间线。通知事件存在,并记录了`email_send_attempt`。但缺少`email_queued`,这意味着邮件从未实际发送,而解释原因的错误信息完全不在时间线中。它隐藏在最顶部的WARNING中,脚本提示发现了一行无法解析的日志。

这就是问题所在:尝试可见但失败不可见。

运行 `cat logs/notification.log` 并滚动到末尾:

{"timestamp": "2026-05-15T21:59:00.630313+00:00", "trace_id": "pay-6cf69a8c", "service": "notification", "event": "email_send_attempt", ...} 2026-05-15T21:59:00.681583+00:00 ERROR 连接邮件提供商超时 http://mock-email:80/ 在0.001秒后 - 失败向[email protected]发送确认邮件

code

需注意的两行:第一行包含追踪ID,脚本找到并显示在时间线中。第二行未包含追踪ID——脚本将其标记为警告并跳过。错误发生在尝试之后0.075秒。日志文件包含这两行,但时间线仅显示其中一行。

这就是生产环境中“隐形失败”的表现形式。支付成功,确认邮件未发送。错误信息就存在于日志文件中(连接邮件提供商超时0.001秒),但在上述关联输出的时间线中,从`email_send_attempt`直接跳转到`payment_complete`,中间没有任何错误、失败或间隙。看起来一切正常。

修复方法位于`02-log-correlation/services/notification/main.py`。以下是错误的错误处理器:

except httpx.TimeoutException: emit_plain(f"连接邮件提供商 {EMAIL_PROVIDER_URL} 超时") return {"status": "ok"}

code

以下是修复后的版本。唯一改动是将`req.trace_id`传递给`emit`而不是调用`emit_plain`:

except httpx.TimeoutException: emit(req.trace_id, "email_timeout", level="ERROR", provider=EMAIL_PROVIDER_URL) return {"status": "ok"}

code

修改后,超时错误会像其他事件一样出现在时间线中:

[2026-05-15T21:59:00.681583+00:00] [NOTIFICATION] [ERROR] email_timeout provider: http://mock-email:80/

code

一条命令,一个追踪ID,完整画面呈现。

### 脚本无法为您做出的决策

关联脚本识别出通知服务存在缺口。当您检查原始`notification.log`时,会发现纯文本超时错误,表明请求到达服务,认证和交易记录均成功,但邮件发送失败。

通知失败是否构成支付失败完全取决于系统设计。如果通知是软依赖,则此错误不应作为支付失败暴露给用户,此时系统设计存在问题。如果是硬依赖,则交易本身应回滚。脚本找到了故障点,但正确响应取决于设计。

### 故意制造故障

1. 运行 `./break_it.sh` — 将通知服务切换为错误处理程序丢弃追踪ID的模式

2. 运行 `./trigger_request.sh` 生成新的支付请求并获取新追踪ID

3. 运行 `python correlate.py <新追踪ID>` — 时间线中将缺失通知事件

4. 运行 `cat logs/notification.log` — 超时错误就在那里,没有追踪ID,对脚本不可见

## 使用案例3 - 基础设施漂移检测

**环境:** AWS免费套餐(一个安全组)+ Terraform

**语言:** Python

### 生产环境问题

您的Terraform计划显示无变更。部署行为与昨日不同,询问后得知:某位同事上周通过AWS控制台手动修改了安全组规则以解除测试环境阻塞。他们打算后续通过Terraform应用该变更,但忘记了。

自此,Terraform状态文件与实际AWS基础设施开始悄悄分歧。没有明显故障或警报触发。除非有人运行`terraform plan`检查,否则Terraform不会察觉差异——而在此场景中,没人这么做。

这被称为基础设施漂移,比大多数团队愿意承认的更为常见。

### 系统层面的实际发生情况

**这不是什么:** 这不同于运行`terraform plan`。计划展示Terraform*将要*变更的内容。该脚本展示AWS中*已经*发生的变更,而Terraform对此一无所知。

脚本本身不执行任何Terraform命令。它读取Terraform已生成的状态文件。在演示中,Terraform创建该文件。在真实环境中,它来自常规工作流程。

可将Terraform状态文件视为收据。当Terraform创建安全组时,会精确记录其创建内容:规则、端口、CIDR范围。该收据即为状态文件。

脚本将该收据与AWS当前实际配置进行对比。若有人通过控制台添加了未记录在收据中的规则,脚本会标记为漂移。

盲点在于,如果有人在控制台中创建了一个全新的安全组,并且从未使用过Terraform,那么就不会有任何记录。脚本无法比较从未见过的内容。它会返回干净的结果,而该安全组会默默存在于你的账户中未被检测到。

演示展示了两种情况。首先破坏一个已知的资源。然后,`--invisible`场景会在完全脱离Terraform的情况下创建一个新的资源,此时脚本仍然返回干净的结果,尽管你的账户中多了一个安全组。

### 设置演示环境

导航到配套仓库中的 `03-drift-detection/` 目录:

cd 03-drift-detection pip install -r requirements.txt

code

运行设置脚本。这会使用真实的Terraform,而非模拟环境:

./setup.sh

code

该脚本会执行 `terraform init` 和 `terraform apply`,从而在AWS上创建一个真实的安全组:

![图5:AWS控制台显示已创建的安全组的截图](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d5617704-d04e-40cc-8ca9-aa9d6b806e5e.png)

同时还会生成一个真实的 `terraform.tfstate` 文件。你可以用任意文本编辑器打开查看Terraform实际产生的内容。这是可读的JSON格式,包含所有真实数据。

![图6:IDE目录结构显示 terraform.tfstate 文件被创建的截图](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3ea8e237-a1d7-43bc-be3d-50dcb6ff4b76.png)

设置完成后,运行脚本:

python detect_drift.py terraform.tfstate

code

你应该会看到类似以下输出(实际安全组ID会不同):

从 terraform.tfstate 加载Terraform状态

正在检查:sg-0a1b2c3d4e5f6a7b8

OK - 未检测到漂移。

code

现在实验室环境已就绪,双方状态匹配。接下来我们看看脚本是如何工作的。

### 脚本 ([代码文件](https://github.com/Osomudeya/devops-scripting-labs))

detect_drift.py

import boto3 import json import sys

def load_tfstate(path): """ Terraform状态文件是纯JSON格式——在任何文本编辑器中打开它,你会看到一个名为'resources'的数组,其中列出了Terraform所管理的所有资源。 此函数读取该文件并返回解析后的内容。 """ with open(path) as f: return json.load(f)

def get_security_groups_from_state(tfstate): """ 遍历资源数组,收集所有安全组条目。 每个资源包含'type'、'name'和'instances'数组,其中保存了Terraform最后一次运行时记录的属性值。 我们提取资源ID和入站规则。 """ resources = {} for resource in tfstate.get("resources", []): if resource["type"] == "aws_security_group": for instance in resource.get("instances", []): sg_id = instance["attributes"]["id"] resources[sg_id] = { "ingress": instance["attributes"].get("ingress", []) } return resources

def get_security_group_from_aws(sg_id): """ 调用AWS EC2 API获取该安全组的实时状态。 boto3底层会构造经过身份验证的HTTPS请求,使用你的AWS凭证签名后发送到配置区域的EC2 API端点,并解析响应。响应包含远超所需的数据量,我们仅提取入站规则。 """ ec2 = boto3.client("ec2") response = ec2.describe_security_groups(GroupIds=[sg_id]) sg = response["SecurityGroups"][0] return {"ingress": sg.get("IpPermissions", [])}

def normalize_state_rules(rules): """ Terraform以自身格式存储入站规则。 我们将其规范化为元组集合以便于比较。 每个元组包含:(起始端口, 结束端口, 协议, CIDR块) """ normalized = set() for rule in rules: for cidr in rule.get("cidr_blocks", []): normalized.add(( rule.get("from_port", 0), rule.get("to_port", 0), rule.get("protocol", "-1"), cidr )) return normalized

def normalize_aws_rules(rules): """ AWS返回的入站规则格式与Terraform的不同。 我们将其规范化为相同的元组结构,使比较生效。 """ normalized = set() for rule in rules: from_port = rule.get("FromPort", 0) to_port = rule.get("ToPort", 0) protocol = rule.get("IpProtocol", "-1") for ip_range in rule.get("IpRanges", []): normalized.add((from_port, to_port, protocol, ip_range["CidrIp"])) return normalized

def detect_drift(tfstate_path): print(f"从 {tfstate_path} 加载Terraform状态") tfstate = load_tfstate(tfstate_path) state_sgs = get_security_groups_from_state(tfstate)

if not state_sgs: print("状态文件中未找到安全组。无可比较内容。") return

drift_found = False

for sg_id, state_data in state_sgs.items(): print(f"\n正在检查:{sg_id}")

try: aws_data = get_security_group_from_aws(sg_id) except Exception as e: print(f" 错误:无法从AWS获取 {sg_id} - {e}") print(f" 请检查IAM权限:需要 ec2:DescribeSecurityGroups 权限。") continue

state_rules = normalize_state_rules(state_data["ingress"]) aws_rules = normalize_aws_rules(aws_data["ingress"])

AWS中存在但Terraform状态中缺失的规则(手动添加)

added_in_aws = aws_rules - state_rules

Terraform期望存在但在AWS中缺失的规则(手动删除)

removed_from_aws = state_rules - aws_rules

if added_in_aws: drift_found = True print(" 漂移 - 存在于AWS但未记录在状态文件中的规则:") for rule in added_in_aws: print(f" 端口 {rule[0]}-{rule[1]} | 协议:{rule[2]} | CIDR:{rule[3]}")

code

如果从AWS移除,则会检测到漂移:

plaintext
DRIFT - 规则存在于状态文件中但已从AWS删除:
Port {规则的端口}-{规则的目标} | 协议: {协议类型} | CIDR: {CIDR地址}

如果没有新增加或移除了资源,

plaintext
OK - 没有发现漂移。

输出如下所示:

code
加载Terraform的状态数据来自:terraform.tfstate

检查对象:sg-0a1b2c3d4e5f6a7b8

  OK - 没有发现漂移。

===========================================================================================================
监控中的资源没有出现漂移现象。

重要提示:此脚本仅检查跟踪于您状态文件内的资源。未通过Terraform手动创建并部署至AWS的新资源无法在此处检出。此处干净整洁的结果并不意味着您的AWS账户本身也是清洁无瑕——它表示的是所监视的对象与Terraform最后记录的内容相符而已。

注入了漂移后的情况示例图显示了一个安全组入站规则的变化情况,请参考下方截图展示结果:

图片描述
图片描述

运行该脚本不会自动决定是否需要立即回滚更改;因此,在执行之前先问自己一个问题:“这个变更是不是紧急补丁?”可能有人为了恢复服务而临时打开了一个端口,并且正在准备正式修复方案。若自动回退可能会导致原本出于特定目的放置的服务停止工作的问题发生。

尽管发现了变化,但是并未告知哪一种版本才是正确的答案。这之后的工作就是由用户自行调查确认哪个版本更正确。

故意破坏测试步骤包括但不限于下面这些操作:

  1. 运行命令 ./break_it.sh ,模拟手工修改的安全策略调整;
  1. 执行 python detect_drift.py terraform.tfstate 命令来触发异常状况的发生;
  1. 使用参数 -invisble 来生成一个新的安全组而不将其包含进状态文件内再重新跑一次上述流程即可观察到新的问题浮现出来;
  1. 最终使用 ./teardown.sh 脚本来清理所有相关环境变量及配置项以确保后续实验能够顺利开展下去。

场景案例四 —— 零停机时间下的密钥轮换机制

应用场景背景设定 :基于Kubernetes集群+本地Kind集群环境下实现数据库密码更新自动化管理功能

生产环境中遇到的实际难题在于当应用容器重启时并没有及时感知到新旧证书切换带来的影响从而造成短暂性中断故障等问题存在 。此时就需要借助外部工具对现有系统健康度进行全面评估以便提前预判潜在风险点避免业务层面受到影响带来不必要的损失成本支出等等负面影响因素产生作用发挥其最大价值体现意义所在之处就在于这里涉及到的核心逻辑部分即是在原有基础上增加了一次针对应用程序自身内部结构完整性校验环节使得整体架构更加完善可靠化程度得以提升同时也大大降低了运维人员日常工作中所需要投入精力数量比例缩小范围进一步优化资源配置效率最大化利用空间方面也起到了积极正面推动促进效果显著增强之功效表现形式上则是体现在以下几个关键指标维度之上具体表现为提高响应速度缩短平均处理周期减少错误率降低维护频率等方面均取得了明显进步成果值得我们高度重视起来认真对待加以推广普及开来共同携手努力构建起更为坚实稳固可持续发展的未来愿景蓝图构想框架体系之中去落实落地实施过程中还需要特别强调一点那就是在整个项目推进期间务必严格把控好各个环节细节节点以免因小失大最终酿成不可挽回的巨大失误所带来的严重后果再次提醒各位同仁们切勿掉以轻心谨慎从事!

与此同时,Kubernetes 观察到 Pod 对 HTTP 请求有响应并将其标记为 Running 状态,此时用户会遭遇失败,但集群本身没有任何异常提示。

`/healthz/db` 端点的作用

/healthz 端点仅在 HTTP 服务器存活时返回 200 状态码,这也是 Kubernetes 就绪探针唯一检查的内容。

/healthz/db 端点则会使用当前环境变量中的凭证建立新的数据库连接,并执行 SELECT 1 操作。如果密码轮换后该 Pod 未重启,环境变量仍保存旧密码导致连接失败,此时 Pod 虽然处于 Running 状态却无法处理数据库请求。密码轮换脚本会在最后一步调用此端点——而这是 Kubernetes 从未执行过的检查。

以下是演示 FastAPI 应用中的实现方式(完整代码):

python
# app.py(相关片段)
import os
import asyncpg
from fastapi import FastAPI, HTTPException

app = FastAPI()

DB_HOST = os.environ.get("DB_HOST", "postgres")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))
DB_NAME = os.environ.get("DB_NAME", "appdb")
DB_USERNAME = os.environ.get("DB_USERNAME", "appuser")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")

@app.get("/healthz")
async def healthz():
    # 如果HTTP服务器存活则始终返回200状态码
    # 这正是Kubernetes就绪探针检查的内容
    return {"status": "ok"}

@app.get("/healthz/db")
async def healthz_db():
    # 使用当前环境变量中的凭证建立新连接
    # 若密码已轮换且Pod未重启,环境变量仍保存旧密码导致连接失败
    # 此时/healthz端点仍返回200,但用户会看到错误
    try:
        conn = await asyncpg.connect(
            host=DB_HOST, port=DB_PORT,
            database=DB_NAME, user=DB_USERNAME, password=DB_PASSWORD,
        )
        await conn.execute("SELECT 1")
        await conn.close()
        return {"status": "ok", "db": "authenticated"}
    
    except asyncpg.InvalidPasswordError:
        raise HTTPException(
            status_code=503,
            detail=(
                f"认证 '{DB_USERNAME}' 失败。"
                "密码可能已被轮换。"
                "就绪探针不会检查此项。"
            )
        )
    except Exception as e:
        raise HTTPException(status_code=503, detail=f"数据库错误:{str(e)}")

这两个端点的区别正是本案例的核心教训。

配置演示环境

进入 04-secrets-rotation/ 目录并运行初始化脚本:

code
cd 04-secrets-rotation
./setup.sh

该脚本将启动 Kind 集群,部署包含预创建 appuser 账户的真实 PostgreSQL 实例,部署与之关联的演示 FastAPI 应用,并在 AWS Secrets Manager 中创建初始密钥。

初始化完成后安装依赖项:

code
pip install boto3 kubernetes

运行密码轮换前,请确认所有组件正常运行:

code
kubectl get pods

应显示 myapppostgres 两个 Pod 均处于 Running 状态。若出现 Pending 或 Error 状态,等待 30 秒后重试。PostgreSQL 初始化需要一定时间。

您还可以通过 AWS 控制台验证密钥是否存在:导航至 AWS Secrets Manager 并查找 myapp/db-credentials

Image 9: 展示 AWS 密钥创建的截图
Image 9: 展示 AWS 密钥创建的截图

或者使用 CLI 命令:

code
aws secretsmanager get-secret-value --secret-id myapp/db-credentials

当两个 Pod 均处于 Running 状态且密钥存在后,运行轮换脚本观察完整流程:

code
python rotate_secret.py

如果首次运行时第6步显示 FAILED,通常是由于定时问题:应用 Pod 成功重启,但 /healthz/db 在新 Pod 完成首个数据库连接前被调用。等待 20 秒后重新运行脚本。若持续失败,可通过 kubectl logs deployment/myapp 查看应用日志。

成功运行后应显示以下结果:

code
轮换完成。应用级凭证验证通过。
  AWS Secrets Manager:已更新
  PostgreSQL:          已更新 (ALTER USER)
  Kubernetes Secret:   已更新
  应用 Pod:            已重启并完成认证

至此,实验室环境已激活,完整的轮换链路已端到端验证成功。现在让我们分析脚本的具体实现。

脚本 ([完整代码](https://github.com/Osomudeya/devops-scripting-labs))

python
# rotate_secret.py
import boto3
import base64
import json
import subprocess
import sys
from kubernetes import client, config

def get_current_secret(secret_name):
    """
    从 AWS Secrets Manager 获取当前凭证
    密钥以包含 'username' 和 'password' 字段的 JSON 字符串形式存储
    """
    sm = boto3.client("secretsmanager")
    response = sm.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

def rotate_in_aws(secret_name, username, new_password):
    """
    将新凭证写入 AWS Secrets Manager
    put_secret_value 创建新版本,旧版本不会立即删除,提供短暂回滚窗口
    """
    sm = boto3.client("secretsmanager")
    new_value = json.dumps({"username": username, "password": new_password})
    sm.put_secret_value(SecretId=secret_name, SecretString=new_value)
    print("  [AWS] 密钥已在 Secrets Manager 更新。")

def update_kubernetes_secret(namespace, k8s_secret_name, username, new_password):
    """
    使用新凭证值更新 Kubernetes Secret 对象
    Kubernetes 要求密钥数据进行 base64 编码 - 这是编码而非加密
    静态加密需要单独配置 etcd 加密策略
    """
    config.load_kube_config()
    v1 = client.CoreV1Api()

secret_data = { "username": base64.b64encode(username.encode()).decode(), "password": base64.b64encode(new_password.encode()).decode() }

v1.patch_namespaced_secret( name=k8s_secret_name, namespace=namespace, body={"data": secret_data} ) print(f" [K8s] 已更新 Kubernetes Secret '{k8s_secret_name}'。")

def rolling_restart(namespace, deployment_name): """ 触发部署的滚动重启。 滚动重启意味着 Kubernetes 创建一个新 Pod,等待其通过就绪探针, 然后终止一个旧 Pod - 直到所有 Pod 都被替换。全程保持可用性。 这与一次性删除所有 Pod 完全不同。 """ result = subprocess.run( ["kubectl", "rollout", "restart", f"deployment/{deployment_name}", "-n", namespace], capture_output=True, text=True ) if result.returncode != 0: raise RuntimeError(f"滚动重启失败:{result.stderr}") print(f" [K8s] 已触发 '{deployment_name}' 的滚动重启。")

def wait_for_rollout(namespace, deployment_name, timeout=120): """ 阻塞直到滚动重启完成或超时。 '完成'表示所有新 Pod 处于运行状态且通过了就绪探针。 这并不意味着应用程序可以使用新凭据进行身份验证。 下一步由 verify_credential 进行检查。 """ print(f" [K8s] 等待滚动更新完成(超时:{timeout}s)...") result = subprocess.run( ["kubectl", "rollout", "status", f"deployment/{deployment_name}", "-n", namespace, f"--timeout={timeout}s"], capture_output=True, text=True ) if result.returncode != 0: raise RuntimeError(f"滚动更新未完成:{result.stderr}") print(" [K8s] 滚动更新完成。所有 Pod 均报告 Ready 状态。")

def verify_credential(namespace, deployment_name): """ 这是就绪探针不会执行的检查。 我们进入正在运行的 Pod 并调用 /healthz/db 端点 - 该端点会执行实际的数据库身份验证查询。 如果通过:凭据在应用层有效。 如果在就绪探针通过后失败:确认存在契约不匹配。 Pod 处于运行状态,但应用程序无法处理数据库请求。 """ print(" [验证] 正在执行凭据轮换后的验证检查...")

result = subprocess.run( ["kubectl", "get", "pods", "-n", namespace, "-l", f"app={deployment_name}", "-o", "jsonpath={.items[0].metadata.name}"], capture_output=True, text=True ) pod_name = result.stdout.strip()

if not pod_name: print(" [验证] 错误:未找到此部署的运行 Pod。") return False

verify = subprocess.run( ["kubectl", "exec", pod_name, "-n", namespace, "--", "curl", "-sf", "http://localhost:8000/healthz/db"], capture_output=True, text=True )

if verify.returncode != 0: print(" [验证] 失败 - Pod 处于运行状态但数据库身份验证失败。") print(" 就绪探针已验证 HTTP 可达性。") print(" 应用程序无法使用新凭据进行身份验证。") print(" 这是两个不同的契约。只有其中一个被自动检查。") return False

print(" [验证] 通过 - 应用程序确认可以使用新凭据进行身份验证。") return True

def rotate(secret_name, new_password, namespace, k8s_secret_name, deployment_name): print("\n[第1/6步] 从 AWS Secrets Manager 读取当前密钥...") current = get_current_secret(secret_name) username = current["username"]

print("[第2/6步] 更新 AWS Secrets Manager...") rotate_in_aws(secret_name, username, new_password)

print("[第3/6步] 在数据库级别更改密码(ALTER USER)...") rotate_postgres_password(namespace, new_password)

print("[第4/6步] 更新 Kubernetes Secret 对象...") update_kubernetes_secret(namespace, k8s_secret_name, username, new_password)

print("[第5/6步] 触发滚动重启...") rolling_restart(namespace, deployment_name) wait_for_rollout(namespace, deployment_name)

print("[第6/6步] 验证新凭据在应用层是否有效...") success = verify_credential(namespace, deployment_name)

print("\n" + "=" * 60) if success: print("轮换完成。凭据已在应用层验证成功。") else: print("轮换未完成。就绪探针通过但凭据验证失败。") print("建议操作:强制重启所有 Pod 以刷新连接池,") print("或检查数据库会话超时配置。") sys.exit(1)

if __name__ == "__main__": import secrets as _secrets rotate( secret_name="myapp/db-credentials", new_password=_secrets.token_urlsafe(16), namespace="default", k8s_secret_name="db-credentials", deployment_name="myapp" )

code

### 脚本工作原理

`get_current_secret` 从 AWS Secrets Manager 读取当前凭据,以便脚本在生成新密码前获取用户名。

`rotate_in_aws` 将新凭据写入 Secrets Manager。它创建新版本而非覆盖旧版本,以便在出现问题时提供短暂回滚窗口。

`_pg_password_literal` 和 `rotate_postgres_password` 处理大多数轮换脚本忽略的关键步骤:在 PostgreSQL 中实际更改密码。这通过在活动 PostgreSQL Pod 上直接执行 `ALTER USER appuser PASSWORD '...'` 实现。在这一步之前,数据库仍接受旧密码;之后则不再接受。

`update_kubernetes_secret` 将新密码写入 Kubernetes 密钥对象,这样所有新启动的 Pod 从一开始就获取新的凭证。

`rolling_restart` 和 `wait_for_rollout` 逐个重启应用 Pod,确保部署在整个过程中保持可用。当此步骤完成时,所有 Pod 均处于 Running 状态且通过了就绪探针检查——但需注意,“Running”仅表示 `/healthz` 返回了 200 状态码,而这正是本案例关注的核心问题。

`verify_credential` 是 Kubernetes 从未执行的额外验证步骤。它会进入新 Pod 内部调用 `/healthz/db`,使用当前环境变量中的凭证建立真实的数据库连接。若此检查通过,则表示凭证轮换真正完成;若在就绪探针通过后仍失败,则证明存在漏洞:Pod 显示健康却无法处理数据库请求。

### 输出示例

成功的凭证轮换:

[Step 1/6] 从 AWS 密钥管理服务读取当前密钥... [Step 2/6] 更新 AWS 密钥管理服务... [AWS] 密钥管理服务已更新。 [Step 3/6] 在数据库级别轮换密码(ALTER USER)... [DB] 正在 PostgreSQL 上执行 ALTER USER... [DB] 数据库级别密码已变更。 新连接现在需要新密码。 现有连接池中的连接保持有效直至关闭。 [Step 4/6] 更新 Kubernetes 密钥对象... [K8s] Kubernetes 密钥对象 'db-credentials' 已更新。 [Step 5/6] 触发滚动重启... [K8s] 已触发 'myapp' 的滚动重启。 [K8s] 等待部署完成(超时:120秒)... [K8s] 部署完成。所有 Pod 均报告 Ready。 [Step 6/6] 验证应用层面的新凭证有效性... [Verify] 正在执行轮换后凭证检查... [Verify] PASSED - 应用确认可使用新凭证进行身份验证。

============================================================ 轮换完成。应用层面已验证凭证有效性。 AWS 密钥管理服务: 已更新 PostgreSQL: 已更新 (ALTER USER) Kubernetes 密钥: 已更新 应用 Pod: 已重启并完成身份验证

code

实验室环境正常运行,完整的轮换链端到端生效。

在破坏系统前,请先确认 Pod 状态正常:

kubectl get pods

code

应显示 `myapp` 处于 Running 状态。这是基准状态:一切按预期工作。现在开始破坏操作。

![Image 10: 终端截图显示 'kubectl get pods' 的输出](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/caabd892-562a-40f4-81c1-2c59fd15240b.png)
### 故意破坏系统

#### 第一步:使数据库不同步

./break_it.sh

code

该脚本直接在 PostgreSQL 上执行带有错误密码的 `ALTER USER`。此时 K8s 密钥对象仍保留旧密码,导致 Pod 环境与数据库出现不同步。

#### 第二步:检查 Kubernetes 观察到的状态

kubectl exec deployment/myapp -- curl -s http://localhost:8000/healthz

code

将返回 `{"status":"ok"}`。Pod 在 `kubectl get pods` 中仍显示 Ready 状态。Kubernetes 完全无法察觉异常——这正是终端中可见的契约缺口。

#### 第三步:检查用户实际体验

kubectl exec deployment/myapp -- curl -s http://localhost:8000/healthz/db

code

将返回 `503` 错误。新建数据库连接已失败,用户已开始遇到此问题。

#### 第四步:观察混合模式(可选)

./load_test.sh

code

部分请求因复用旧连接池中的已认证连接而成功,部分因需新建连接而失败。Pod 显示健康,但一半流量已失效。

#### 第五步:运行轮换脚本

python rotate_secret.py

code

本次第 6 步将捕获失败,终端输出如下:

[Step 5/6] 触发滚动重启... [K8s] 部署完成。所有 Pod 均报告 Ready。 [Step 6/6] 验证应用层面的新凭证有效性... [Verify] 正在执行轮换后凭证检查... [Verify] FAILED - Pod 处于 Running 状态但数据库身份验证失败。 就绪探针仅验证了 HTTP 可达性。 应用无法使用新凭证进行身份验证。 这是两种不同的契约。Kubernetes 仅自动检查其中一种。

============================================================ 轮换未完成。就绪探针通过但凭证验证失败。

code

Pod 显示为 Running 且 Ready,但轮换脚本指出凭证已损坏。这就是终端中可见的契约缺口,但在用户发现前已被拦截。

**核心教训:** `/healthz` 仅表明 HTTP 服务器存活,`/healthz/db` 才能证明应用可实际连接数据库。Kubernetes 默认仅检查前者,除非添加数据库探针。轮换脚本在每次轮换末尾添加此检查,从而在用户受影响前捕获故障。

### 脚本无法为您做出的决策

验证失败时,Pod 处于 Running 状态但数据库请求失败。您有两种选择:

1. 强制重启所有 Pod 清空连接池(速度更快但会导致短暂容量下降),或
2. 等待旧会话自然过期(避免停机但期间流量会间歇性失败直至连接池循环完毕)

脚本发现了问题,但下一步决策需由熟悉系统的工程师判断。

### 清理环境

./teardown.sh

code

## 使用场景5 - 自动化金丝雀回滚触发器

**环境:** 完全本地化 – Kind + Helm 安装的 Prometheus

**语言:** Bash

### 该使用场景的作用及重要性

本场景运行一个脚本监控新部署,并在出现问题前自动回滚,避免用户大量涌入支持渠道。

这在生产环境中很重要,因为你发布新版本时不会立即将所有流量切换过去。通常会先分配一小部分流量,比如20%到新版本,剩下的80%仍保留在旧版本。如果新版本存在问题,只有20%的用户会受到影响,并且可以在问题扩散前及时回滚。但回滚机制能否生效取决于你是否监控了正确的指标。

**要点总结:** 两个脚本监控同一个失败的金丝雀实例。一个报告一切正常,另一个却触发了回滚。唯一的区别在于它们监控的指标不同。你的自动化能力完全取决于你监控的内容。

**应关注的指标:** `canary_watch_v1.sh` 仅监控错误率,在金丝雀响应变慢时保持沉默。`canary_watch_v2.sh` 同时监控错误率和响应时间并触发回滚。两者的差异正是本文的核心教训。

**这不是什么:** 这不是金丝雀部署指南,而是探讨当你的监控只关注单一信号时会遗漏哪些关键信息。

### 工作原理

集群中运行着三个组件:稳定版应用(3个Pod,处理大部分流量)、金丝雀应用(1个Pod,处理少量流量),以及Prometheus(每15秒从两者收集响应时间和错误计数)。

监控脚本每15秒向Prometheus询问:"金丝雀表现正常吗?" 如果连续三次检查结果异常,则自动回滚金丝雀。

关键问题在于:"表现正常"具体指什么?这就是本文的全部应用场景。

![Image 11: 终端截图显示'kubectl get pods'的输出结果](https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/204f992e-44c2-4404-a6ff-f2279ea23aeb.png)
### 搭建演示环境

进入目录 `05-canary-rollback/` 并运行:

cd 05-canary-rollback ./setup.sh

code

安装过程需要几分钟,将部署Prometheus、两个版本的演示应用,并启动负载生成器Pod持续发送流量,确保Prometheus始终有数据可用。

安装完成后,验证所有组件运行状态:

kubectl get pods

code

预期输出如下:

NAME READY STATUS RESTARTS AGE load-generator-68c59698b7-kws2l 1/1 Running 0 4m54s myapp-canary-6d6979c66f-g9lgw 1/1 Running 0 32s myapp-stable-6bcf994fc4-b4k9l 1/1 Running 0 4m55s myapp-stable-6bcf994fc4-ndhxc 1/1 Running 0 4m55s myapp-stable-6bcf994fc4-z97kx 1/1 Running 0 4m55s prometheus-kube-prometheus-operator-59b847d96c-mp72s 1/1 Running 0 5m58s prometheus-prometheus-kube-prometheus-prometheus-0 2/2 Running 0 5m1s

code

包含3个稳定版Pod、1个金丝雀Pod、1个负载生成器和运行中的Prometheus,实验环境已就绪。

**等待60秒后再执行其他操作。** Prometheus需要时间抓取初始指标,否则监控脚本会返回空数据且无提示。

### 准备三个终端窗口

需同时运行三个独立命令提示符:

**macOS用户:** 打开终端并按两次 `Cmd+T` 创建三个标签页。

**Linux用户:** 在大多数终端应用中按 `Ctrl+Shift+T` 或右键选择"新建标签页"。

分别标记为:
- 终端1:运行监控脚本
- 终端2:注入故障
- 终端3:观察延迟

### 脚本说明

#### 版本1:仅监控错误率 ([源码地址](https://github.com/Osomudeya/devops-scripting-labs.git))

#!/usr/bin/env bash

canary_watch_v1.sh

PROMETHEUS="http://localhost:9090" DEPLOYMENT="myapp-canary" NAMESPACE="default" ERROR_THRESHOLD="0.05" CHECK_INTERVAL=15 STRIKE_LIMIT=3

strikes=0

echo "金丝雀监控运行中 (v1 - 仅监控错误率)" echo "当错误率超过 \({ERROR_THRESHOLD} 连续 \){STRIKE_LIMIT} 次检查时触发回滚" echo ""

while true; do ts=$(date '+%Y-%m-%dT%H:%M:%S')

error_query='sum(rate(http_requests_total{app="myapp-canary",status=~"5.."}[1m])) / sum(rate(http_requests_total{app="myapp-canary"}[1m]))'

error_rate=\((curl -sf "\){PROMETHEUS}/api/v1/query" \ --data-urlencode "query=${error_query}" | \ python3 -c " import sys, json d = json.load(sys.stdin) result = d['data']['result'] print(result[0]['value'][1] if result else '0') " 2>/dev/null)

error_rate=${error_rate:-0} above=\((echo "\)error_rate > $ERROR_THRESHOLD" | bc -l)

echo "[\(ts] 错误率=\){error_rate} | 阈值=\({ERROR_THRESHOLD} | 超限=\)([ "$above" = "1" ] && echo YES || echo NO)"

if [ "$above" = "1" ]; then strikes=$((strikes + 1)) echo " 警报次数 \({strikes}/\){STRIKE_LIMIT}" if [ "\(strikes" -ge "\)STRIKE_LIMIT" ]; then echo " 触发回滚" kubectl rollout undo deployment/"\({DEPLOYMENT}" -n "\){NAMESPACE}" exit 0 fi else strikes=0 fi

sleep "${CHECK_INTERVAL}" done

code

#### 版本2:监控错误率和响应时间

#!/usr/bin/env bash

canary_watch_v2.sh

PROMETHEUS="http://localhost:9090" DEPLOYMENT="myapp-canary" NAMESPACE="default" ERROR_THRESHOLD="0.05" LATENCY_THRESHOLD="2.0" CHECK_INTERVAL=15 STRIKE_LIMIT=3

strikes=0

echo "金丝雀监控运行中 (v2 - 错误率 + P99延迟)" echo "错误阈值: \({ERROR_THRESHOLD} | 延迟P99阈值: \){LATENCY_THRESHOLD}s" echo ""

while true; do ts=$(date '+%Y-%m-%dT%H:%M:%S')

error_query='sum(rate(http_requests_total{app="myapp-canary",status=~"5.."}[1m])) / sum(rate(http_requests_total{app="myapp-canary"}[1m]))' error_rate=\((curl -sf "\){PROMETHEUS}/api/v1/query" \ --data-urlencode "query=${error_query}" | \ python3 -c " import sys, json d = json.load(sys.stdin) result = d['data']['result'] print(result[0]['value'][1] if result else '0') " 2>/dev/null)

code

latency_query='histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{app="myapp-canary"}[1m])) by (le))' latency=\(curl -sf "${PROMETHEUS}/api/v1/query" \ --data-urlencode "query=${latency_query}" | \ python3 -c " import sys, json d = json.load(sys.stdin) result = d['data']['result'] print(result[0]['value'][1] if result else '0') " 2>/dev/null)

error_rate=${error_rate:-0} latency=${latency:-0}

error_breach=\(echo "error_rate > $ERROR_THRESHOLD" | bc -l) latency_breach=\(echo "latency > $LATENCY_THRESHOLD" | bc -l)

triggered_by="" [ "$error_breach" = "1" ] && triggered_by="error_rate(${error_rate})" [ "$latency_breach" = "1" ] && triggered_by="${triggered_by:+${triggered_by}, }latency_p99(${latency}s)"

echo "[${ts}] error_rate=${error_rate} | latency_p99=${latency}s | breach=${triggered_by:-none}"

if [ "$error_breach" = "1" ] || [ "$latency_breach" = "1" ]; then strikes=$((strikes + 1)) echo " Strike ${strikes}/${STRIKE_LIMIT} | Triggered by: ${triggered_by}" if [ "$strikes" -ge "$STRIKE_LIMIT" ]; then echo "" echo " ROLLBACK TRIGGERED" echo " Signal: ${triggered_by}" kubectl rollout undo deployment/"${DEPLOYMENT}" -n "${NAMESPACE}" exit 0 fi else strikes=0 fi

sleep "${CHECK_INTERVAL}" done

code

### 脚本工作原理

`错误率查询` 向 Prometheus 提问:_"过去一分钟内,Canary 部署收到的请求中有多少比例返回了错误?"_ 结果 `0.0` 表示没有错误,`0.06` 则表示 6% 的请求失败,超过 5% 的阈值。这在输出中表现为:

error_rate=0.06 | threshold=0.05 | breach=YES

code

`延迟查询` 询问:_"当前 Canary 部署最慢的 1% 请求有多慢?"_ 结果 `5.234` 意味着每 100 个请求中就有 1 个耗时超过 5 秒。这在输出中显示为:

latency_p99=5.234s | breach=latency_p99(5.234s)

code

V1 版本仅执行错误率查询,而 V2 版本同时执行两项查询。同一 Canary 部署面对相同问题时,不同版本会给出不同结论。

三击规则意味着单次异常不会触发回滚——连续三次异常才会触发。权衡点在于:三次检查(每次间隔 15 秒)导致 45 秒的暴露窗口期,之后自动回滚生效。

当达到三次异常时,监视脚本会执行:

kubectl rollout undo deployment/myapp-canary -n default

code

这一行代码触发回滚操作。它存在于 `canary_watch_v2.sh` 中并自动运行——无需人工干预。脚本自主检测、决策并执行操作。

### 故意制造故障

**在终端 1** 启动 v1 监视器:

./canary_watch_v1.sh

code

你会看到每隔 15 秒重复输出:

Canary monitor running (v1 - only monitoring error rate). Rollback triggers if error rate exceeds 0.05 for 3 consecutive checks.

[2026-05-17T11:53:12] error_rate=0 | threshold=0.05 | breach=NO [2026-05-17T11:53:27] error_rate=0 | threshold=0.05 | breach=NO [2026-05-17T11:53:42] error_rate=0 | threshold=0.05 | breach=NO

code

`breach=NO` 表明 Canary 状态健康。保持该终端运行,并切换到终端 2。

**在终端 2** 注入延迟到 Canary:

./break_it.sh

code

这会导致所有对 Canary 的请求耗时 5 秒。虽然响应状态码仍为 200(无错误),但速度显著下降。你将看到:

Injecting latency into the canary deployment... deployment "myapp-canary" successfully rolled out Latency injection is active.

The canary pod is Running and passing its readiness probe. Every request to the canary now takes 5 seconds. Error rate: 0% | P99 latency: ~5s

code

回到终端 1 观察:v1 监视器持续显示 `breach=NO`。尽管 Canary 每个请求耗时 5 秒,但监测系统认为一切正常。这就是问题所在。

**在终端 3** 查看用户实际体验:

./check_latency.sh

code

TIMESTAMP STABLE (ms) CANARY (ms) STATUS --------- ----------- ----------- ------ 2026-05-17T11:55:14 18ms 5008ms CANARY DEGRADED 2026-05-17T11:55:20 7ms 5008ms CANARY DEGRADED 2026-05-17T11:55:27 6ms 5008ms CANARY DEGRADED

code

稳定环境响应时间为 6-18 毫秒,而 Canary 响应时间超过 5 秒。使用 Canary 的用户每次页面加载需等待 5 秒。此时终端 1 的 v1 监视器仍显示 `breach=NO`。

这个案例揭示:监测指标与用户体验完全脱节。脚本本身没有问题,只是关注了错误的指标。

现在验证修复方案。按 Ctrl+C 终止终端 1 的 v1 进程,启动 v2:

./canary_watch_v2.sh

code

在终端 2 重新注入延迟:

./break_it.sh

code

观察终端 1:v2 检测到延迟超标并触发回滚:

Canary monitor running (v2 - monitoring error rate + P99 latency). Error threshold: 0.05 | Latency P99 threshold: 2.0s

[2026-05-15T14:30:00] error_rate=0.0 | latency_p99=0.082s | breach=none [2026-05-15T14:30:15] error_rate=0.0 | latency_p99=5.234s | breach=latency_p99(5.234s) Strike 1/3 | Triggered by: latency_p99(5.234s) [2026-05-15T14:30:30] error_rate=0.0 | latency_p99=5.891s | breach=latency_p99(5.891s) Strike 2/3 | Triggered by: latency_p99(5.891s) [2026-05-15T14:30:45] error_rate=0.0 | latency_p99=6.102s | breach=latency_p99(6.102s) Strike 3/3 | Triggered by: latency_p99(6.102s)

ROLLBACK TRIGGERED Signal: latency_p99(6.102s)

deployment.apps/myapp-canary rolled back

code

错误率始终为 0,但 v2 仍因延迟超标触发回滚。这就是增加一个指标带来的差异。

回滚完成后,确认 Canary 处于休眠状态但未被删除:

kubectl rollout history deployment/myapp-canary -n default

code

REVISION CHANGE-CAUSE 1 <none> 2 <none>

code

两次修订。回滚操作将修订版本2缩放至零并恢复了修订版本1。没有任何内容被删除,如果确定此次回滚属于误报,你可以重新部署。

### 脚本无法替你做出的决策

V2基于延迟指标触发回滚,期间未出现任何错误。在重新部署前,请判断延迟是新代码的真实性能退化,还是像首次使用时数据库缓存预热这样的临时波动?这两种情况会产生相同的信号。只有你知道根据变更内容哪种可能性更大。

误报回滚会拖慢部署速度并削弱对自动化系统的信任。正确的阈值取决于你的用户群体和系统特性。

脚本强制执行的是你配置的规则。

### 清理环境

./teardown.sh

code

## 现在你能做的事情

手册中的每个用例都是针对标准工具未能捕捉到的具体问题设计的解决方案。以下是你的收获:

- 你能在账单生成前发现AWS成本激增,并知道服务标签只是AWS的归因标识,而非指向真实成本原因。应从运营层面的变化入手分析,而非依赖账单标签。
- 你可以通过单一跟踪ID重建跨多个服务的完整失败请求时间线,并明白缺失的服务节点本身即是证据,而不仅是数据空白。
- 你可以通过对比Terraform预期状态与AWS实际资源,检测基础设施漂移。清洁结果仅表明Terraform管理的资源同步,不代表整个AWS账户无异常。
- 你可以在应用层验证密钥轮换,区分就绪探针通过与应用能否实际连接数据库的区别。
- 你可以构建基于正确信号的金丝雀回滚触发器,并理解为何仅监控错误率会让缓慢失效的部署持续运行,迫使用户等待。

这五个用例揭示了同一模式:当系统实际已损坏时,标准工具仍报告一切正常。成本脚本显示清洁状态,Pod处于Running状态,金丝雀零错误——并非工具出错,而是它们只检查了容易监测的指标。这些脚本填补了标准工具忽略的盲区。

**GitHub仓库:**[https://github.com/Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs.git)

我每周撰写DevOps相关文章,涵盖真实系统案例、面试技巧、简历优化及事故复盘——[**订阅通讯**](https://osomudeya.kit.com/23db7ca59f)。

* * *

* * *

免费学习编程。freeCodeCamp的开源课程已帮助4万多人成为开发者。[立即开始](https://www.freecodecamp.org/learn)