omnomkar/cloud-secure-pipeline

GitHub: omnomkar/cloud-secure-pipeline

一个安全优先的 AWS 云基础设施作品集项目,用 Terraform 构建零暴露端口、全程最小权限的 k3s 集群与 OIDC CI/CD 流水线,并完整记录了构建过程中的真实问题与解决方案。

Stars: 0 | Forks: 0

# secure-cloud-pipeline 一个注重安全的云基础设施作品集项目。目标是 展示端到端的“默认最小权限”是什么样的——包括网络、 计算、CI/CD 和机密管理——同时将每月成本保持在足够低的水平, 以便作为一个个人项目长期运行。 ## 项目总结 本项目展示了安全优先的云基础设施:全程采用最小权限 IAM,账户中没有任何公有 IP 或开放入站端口, 以及端到端的临时凭证——CI/CD 使用 OIDC,应用使用 IMDS, 通过 SSM 进行 shell 访问,没有静态 AWS 密钥,没有 SSH 密钥。设计上同样 注重成本:使用 NAT 实例而非 NAT Gateway,使用 k3s 而非 EKS,使用手写的清单而非第三方的 Helm chart 进行监控——每一个权衡都在做出决定的地方进行了解释,而不仅仅是列出。 而且这也是真实的调试记录,而不仅仅是一张完成的图表:在构建过程中, 遇到并修复了六个具体的生产级问题——包括 OIDC 信任策略 bug、cgroup v1/v2 不兼容、Helm 命名限制冲突等——详情见下方的 **经验教训**。 为了避免持续产生费用,AWS 上的活动基础设施后来已被销毁, 因此这里没有演示链接。架构图、经验教训部分以及 基础设施代码本身就是它曾正常运行的证据——如果你想重新部署它, 请参阅下文的“重新部署本项目”。 ## 架构图 ``` flowchart LR GHA["GitHub Actions
(OIDC auth)"] ECR[("Amazon ECR")] SM[("AWS Secrets
Manager")] subgraph VPC["AWS VPC — zero public IPs on any workload"] direction LR NAT["NAT Instance
(public subnet)"] subgraph K3S["k3s EC2 Node (private subnet)"] direction TB APP["App Pod"] PROM["Prometheus"] GRAF["Grafana"] CRON["ecr-refresh CronJob"] end end GHA -- "push image (OIDC)" --> ECR GHA -- "ssm send-command (deploy)" --> K3S K3S -- "outbound only, via NAT" --> NAT CRON -- "refresh ECR pull token" --> ECR APP -- "GetSecretValue (scoped IAM role)" --> SM ``` 此图中的任何位置都不存在入站安全组规则——进入私有子网的唯一 路径是 GitHub Actions 的 `ssm:SendCommand` 调用, 该调用本身也被限制在一个实例和一个 SSM 文档中(请参阅下文的“为什么使用 OIDC 而不是访问密钥”)。 ## 经验教训 在构建过程中出现了六个真实问题,每个问题都在代码中得到了修复, 而不是采取绕过的方式: - **k3s 安装在查找 SELinux RPM 时失败。** 安装脚本试图 安装与主机操作系统匹配的 `k3s-selinux` RPM;Amazon Linux 2023 在指定的 k3s 版本中没有发布对应的 RPM,因此该步骤没有 任何可安装的有效内容。通过在 `live/templates/k3s-user-data.sh.tpl` 中向安装程序传递 `INSTALL_K3S_SKIP_SELINUX_RPM=true` 修复——反正 SELinux 强制执行并不在 本项目的威胁模型中,因此跳过它没有任何成本。 - **cgroup v1/v2 与 Kubernetes 1.36 不匹配。** k3s v1.36+ 完全放弃 了对 cgroup v1 的支持,但 Amazon Linux 2——本项目其他所有地方使用的 AMI, 包括 NAT 实例——默认使用 cgroup v1,因此 k3s 无法在它上面干净地启动。通过查看 k3s 自己的发布说明并与 AMI 的默认 cgroup 驱动程序进行对比诊断了问题,然后通过在 `live/k3s_instance.tf` 中为 k3s 节点指定它自己的、固定为 Amazon Linux 2023(默认使用 cgroup v2)的 AMI 查找来修复, 与 NAT 实例的 AMI 分开。 - **AL2023 的 "minimal" AMI 变体没有 SSM agent。** AWS 发布了 多个 AL2023 AMI 系列;minimal 变体去除了预装的 agents——包括 SSM agent——以缩小镜像大小。在一个没有任何入站安全组规则且没有 SSH 的节点上,缺少 SSM agent 意味着 根本没有进入的路径:实例启动了,但从未作为“Managed Instance”出现。通过将 AMI 过滤器固定为标准的(非 minimal) AL2023 系列修复,该系列预装了该 agent。 - **尽管信任策略正确,GitHub OIDC 角色仍被拒绝。** IAM 角色的信任策略拥有正确的 OIDC provider,正确的 `sub`/`aud` 条件,范围限定在正确的仓库和分支——而 AWS 仍然拒绝了 所有的扮演角色尝试。`Action` 是 `sts:AssumeRole`,即用于普通跨账户角色链接的调用,而不是 `sts:AssumeRoleWithWebIdentity`,这是 `Federated` OIDC 主体必须调用的特定 STS API;IAM 会根据请求的精确操作来匹配信任策略, 因此这个听起来几乎相同的操作直接就是拒绝, 而不是权限缺口。在 `live/oidc.tf` 中已修复。 - **自管理的集群上不存在 EKS 专属的 ECR 认证。** 尽管节点的 IAM 角色拥有正确的 ECR 拉取 权限,Pod 依然陷入了 `ImagePullBackOff`,因为假设 containerd 会像 EKS 的 ECR 凭证助手那样自动获取该角色。该集成是 EKS 特有的——原生的 containerd/k3s 没有类似功能。通过在 `deployment.yaml` 中添加显式的 `ecr-pull-secret` `imagePullSecrets` 条目来修复, 因为 ECR token 会在大约 12 小时后过期,所以会自动刷新(参见下文的“保持 `ecr-pull-secret` 有效”)。 - **Helm CronJob 名称溢出与应用本身的 Service 冲突。** 应用于 `ecr-refresh` CronJob 的 chart 标准 `--...` 命名模式,将 生成的名称推到了超过 Kubernetes 对象名称限制的程度;另外,CronJob 的 pod 模板重用了 chart 共享的 `selectorLabels` 助手, 而这正是应用的 `Service` 选择的确切标签集——因此一个正在运行的 CronJob pod 开始接收原本发往该应用的流量,尽管它根本没有监听 5000 端口。通过在追加后缀之前使用 `trunc 40 | trimSuffix "-"` 截断 CronJob/ServiceAccount 名称,并赋予 CronJob 的 pod 它们自己 独特的标签而不是重用应用的选择器标签来修复——这两处都在 `helm/secure-cloud-pipeline/templates/ecr-secret-cronjob.yaml` 中。 ## 架构细节 - **计算**:运行在一台 EC2 实例上的单节点 [k3s](https://k3s.io/),部署着 Python Flask 应用,而不是使用 EKS——避免了每月约 73 美元的 EKS 控制平面 费用,同时仍使用真实的 Kubernetes 原语。 - **网络**:k3s 实例位于单可用区 (AZ) `us-east-1` 的一个**私有子网**中。连接互联网的唯一途径是公有子网中的一台小型 NAT **实例** (在 `t3.nano` 上每月约 5-10 美元)——而不是托管的 NAT Gateway, 后者的成本将超过它所服务的工作负载。 - **访问**:没有 SSH,没有任何地方开放入站端口。所有的 shell 访问都通过 **AWS SSM Session Manager** 进行, 它只需要出站的 HTTPS 连接和一个 IAM 角色——没有会丢失的密钥,没有需要打补丁的堡垒机。 - **CI/CD**:GitHub Actions 通过 **OIDC** 向 AWS 进行身份验证(GitHub 中不存储长期的 AWS 凭证)。它构建并将镜像推送到 **Amazon ECR**,然后通过发送命令到 **SSM send-command** 来进行部署——Kubernetes API server 从未暴露给 互联网。 - **部署**:应用清单被打包为 **Helm charts**。 - **机密管理**:应用机密存储在 **AWS Secrets Manager** 中; 实例的 IAM 角色仅限读取它需要的特定机密。 - **监控**:自托管的 **Prometheus + Grafana**,作为工作负载运行在同一个 k3s 节点上(无需为额外的可观测性基础设施付费)。 - **Terraform state**:远程状态存储在 **S3 bucket** 中(启用版本控制、 加密、阻止公共访问),并使用 **DynamoDB 表**进行锁定。 ## 仓库布局 ``` bootstrap/ Standalone Terraform that creates the S3 bucket + DynamoDB table used as the remote backend for everything else. Run once, with local state, before touching live/. live/ The actual infrastructure: network, NAT instance, k3s host, ECR repository, and GitHub OIDC IAM role - using the S3 backend bootstrap/ created. app/ The Flask app that will eventually run on the k3s node. Dockerfile Builds app/ into a container image (build context is the repo root - see "The Flask app" section below). helm/ Helm chart that deploys the Flask app onto the k3s node. .github/ GitHub Actions workflow that builds/pushes the image via OIDC and triggers a deploy over SSM (no exposed k8s API). ``` `bootstrap` 和 `live` 是特意分开的 Terraform 根模块: `live` 依赖于必须在 `live` 运行*之前*就存在的后端,因此 如果不解决“鸡生蛋还是蛋生鸡”的问题,它就无法创建自己的后端。 ## 阶段状态 - [x] **阶段 1 — 网络 + NAT 实例 + 状态后端** - `bootstrap/`:S3 状态 bucket + DynamoDB 锁表 - `live/`:VPC、公有/私有子网、IGW、路由表、NAT 实例 (仅限 SSM 访问,强制执行 IMDSv2,加密根卷,无 SSH) - [x] **阶段 2 — Flask 应用 + 容器镜像** - `app/`:包含 `/`、`/health` 和 `/config` 路由的最小化 Flask 应用 - 根目录 `Dockerfile`:非 root 用户、由 gunicorn 提供服务的容器镜像 - [x] **阶段 3 — k3s 节点 + ECR 仓库**(此提交) - `live/ecr.tf`:ECR 仓库(推送时扫描,5 镜像生命周期策略) - `live/k3s_instance.tf`:私有子网中的 k3s 节点(仅限 SSM 访问,零入站安全组,最小权限 ECR 拉取 IAM, 强制执行 IMDSv2,加密根卷,固定 k3s 版本) - [x] **阶段 4 — GitHub Actions OIDC 角色、Helm chart、通过 SSM send-command 部署**(此提交) - `live/oidc.tf`:GitHub OIDC provider + 仅限此 仓库 `main` 分支的 IAM 角色,最小权限 ECR 推送 + SSM 部署策略 - `helm/secure-cloud-pipeline/`:Flask 应用的 Helm chart - `.github/workflows/deploy.yml`:通过 OIDC 构建/推送镜像,然后 通过 SSM 发送脚本进行部署 - 没有暴露的 k3s API - [x] **阶段 5 — Secrets Manager 集成、应用的最小权限 IAM** (此提交) - `live/secrets.tf`:用于应用运行时 消息的 Secrets Manager 机密,以及在 k3s 节点现有 IAM 角色上授予的 `secretsmanager:GetSecretValue` 权限,范围仅限该机密的 ARN - `live/k3s_instance.tf`:`http_put_response_hop_limit = 2`,以便 IMDS 不仅可从主机访问,还能从 pod 内部访问(参见 下文专门的注意事项部分) - `app/app.py`:设置了 `SECRETS_MANAGER_SECRET_NAME` 后,在启动时通过 boto3 从 Secrets Manager 读取机密,如果未设置,则回退到 阶段 2 的行为 - `helm/secure-cloud-pipeline/templates/ecr-secret-cronjob.yaml` + `serviceaccount.yaml`:弥补了阶段 4 `imagePullSecrets` 修复留下的缺口——一个 CronJob 每 6 小时从集群内部刷新一次 `ecr-pull-secret`,其 RBAC 权限仅限该 Secret(参见下文的“保持 `ecr-pull-secret` 有效”) - [x] **阶段 6 — 集群上的 Prometheus + Grafana**(此提交) - `helm/secure-cloud-pipeline/templates/monitoring/prometheus.yaml`: 手写的 Prometheus Deployment + ConfigMap + ClusterIP Service, 带有用于 Kubernetes 服务发现的集群级 RBAC - `helm/secure-cloud-pipeline/templates/monitoring/grafana.yaml`: 手写的 Grafana Deployment,预配置了 Prometheus 数据源和一个 pod CPU/内存仪表板,密码来自 Kubernetes Secret - 两者都只能通过 `kubectl port-forward` 访问——参见下文的“监控: Prometheus + Grafana” 所有六个阶段均已完成。 ## 重新部署本项目 为了避免持续的 AWS 费用,下面描述的基础设施在本项目完成后 已被销毁——这些是从头开始重新部署它的步骤, 而不是访问当前正在运行的任何内容的说明。 1. **创建状态后端**(使用本地状态,因为它正在创建 后端本身): cd bootstrap terraform init terraform apply -var="bucket_name=" 注意 `state_bucket_name` 和 `dynamodb_table_name` 的输出。 2. **将 `live/` 指向该后端**——编辑 `live/providers.tf` 并将 `REPLACE_WITH_YOUR_STATE_BUCKET_NAME` 和 `REPLACE_WITH_YOUR_LOCK_TABLE_NAME` 替换为步骤 1 中的值。 3. **设置您的变量**: cd ../live cp terraform.tfvars.example terraform.tfvars # 编辑 terraform.tfvars — 至少设置 project_name 4. **配置**: terraform init terraform plan terraform apply 5. 要访问 NAT 实例(或者稍后的 k3s 主机)进行调试,请使用 SSM Session Manager——`aws ssm start-session --target `—— 切勿使用 SSH。 ## Flask 应用(阶段 2) `app/app.py` 是一个包含三个路由的最小 Flask 应用: - `GET /` — 返回一个小的 JSON 状态 payload。 - `GET /health` — 返回 `{"status": "healthy"}`;这是 k3s 将在 阶段 3 中指向 liveness/readiness 探针的目标。 - `GET /config` — 返回从 `APP_SECRET_MESSAGE` 环境变量读取的消息,如果未设置则回退到默认值。这是 阶段 4 将要添加的真实 AWS Secrets Manager 读取的占位符—— 形式相同(应用信任的一个环境变量),来源不同。 ### 在本地运行 不使用 Docker,直接使用 Flask 自带的开发服务器: ``` cd app pip install -r requirements.txt python app.py ``` 应用监听 `http://localhost:5000`。 ### 构建容器镜像 `Dockerfile` 位于仓库根目录(而不是 `app/` 中),这样它的构建 上下文就是整个仓库,并且 `.dockerignore` 可以排除诸如 `bootstrap/`、`live/` 和 `.terraform/` 等与应用镜像无关的内容: ``` docker build -t secure-cloud-pipeline-app . docker run -p 5000:5000 secure-cloud-pipeline-app ``` ## k3s 节点和 ECR(阶段 3) `live/ecr.tf` 创建一个 ECR 仓库(`-app`),支持推送时的镜像 扫描,以及一个生命周期策略,该策略会过期掉超过最近 5 个 镜像的任何内容,从而避免随着 CI 推送 新构建而导致存储成本无限制增长。 `live/k3s_instance.tf` 创建实际运行 k3s 的节点: - 它部署在阶段 1 的 `aws_subnet.private` 中——永远不会获得公有 IP, 并且它所有的出站流量(发往 ECR、发往 SSM 终端节点)都通过私有路由表经由 NAT 实例路由出去。 - 它的安全组有意拥有**零入站规则**——SSM Session Manager 仅需要来自实例的出站连接,因此没有任何 需要入站开放的内容。这与 NAT 实例使用的访问模型相同,应用到了工作负载节点上。 - 它的 IAM 角色只能做两件事:向 SSM 注册 (`AmazonSSMManagedInstanceCore`),以及从此 ECR 仓库拉取 (`ecr:BatchGetImage`、`ecr:GetDownloadUrlForLayer` 限定在仓库的 ARN,以及 AWS 对任何 ECR 拉取都要求的不可避免的通配符资源 `ecr:GetAuthorizationToken`)。它不能推送镜像,不能触碰其他 仓库,也不能在账户中执行任何其他操作。 - `user_data` 通过官方安装脚本安装固定为 `var.k3s_version`(默认为 `v1.36.2+k3s1`)的 k3s,禁用捆绑的 Traefik ingress controller(此处未使用),并在脚本成功完成后写入 `/var/log/k3s-install-done`——这是一种通过 SSM 确认安装确实 已完成的方法,而不仅仅是确认实例已启动。 ### 从您的笔记本电脑访问 k3s API 这是您在 `apply` 之后自己运行的手动步骤——Terraform 没有 (也无法)将其自动化,因为这取决于实例确实已经 启动并且 k3s 已经完成安装: ``` # 1. 通过 SSM 将本地端口 6443 转发到 k3s 节点的端口 6443 # (无需入站安全组规则 - SSM 会通过其自身的 # 仅出站代理连接完成此操作): aws ssm start-session \ --target \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["6443"],"localPortNumber":["6443"]}' # 2. 在单独的终端中,从节点获取 kubeconfig(例如通过 # 普通的 `aws ssm start-session` shell):sudo cat /etc/rancher/k3s/k3s.yaml # k3s 默认写入该文件并指向 https://127.0.0.1:6443, # 这与您刚刚转发的端口一致 - 在本地复制它 # 并按原样使用。 kubectl --kubeconfig ./k3s.yaml get nodes ``` ## CI/CD:GitHub Actions OIDC、Helm 和 SSM 部署(阶段 4) ### 为什么使用 OIDC 而不是访问密钥 `live/oidc.tf` 创建了一个信任 `token.actions.githubusercontent.com`(GitHub 自己的 OIDC 签发者)的 `aws_iam_openid_connect_provider`,以及一个工作流可以通过出示来自该签发者的 token 来扮演的 IAM 角色。 该角色的信任策略不仅检查签发者是*谁*——它还会 将 token 的 `sub` 声明与 `repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main` 进行精确比对。 这意味着来自分支、特性分支或完全属于其他人仓库的工作流运行, 在发出任何 API 调用之前就会被 AWS 拒绝。 永远不会生成任何 AWS 访问密钥或秘密访问密钥,也不会作为 GitHub 密钥存储, 也不可能从日志中泄露——每次工作流运行都会获得随作业 过期的凭证。 该角色的内联策略(同样在 `oidc.tf` 中)是有意收窄的: - `ecr:GetAuthorizationToken` 作用于 `*`——不可避免的;AWS 不支持 此操作的资源级范围限制(与阶段 3 中 k3s 节点的拉取 策略相同)。 - `ecr:BatchCheckLayerAvailability` / `PutImage` / `InitiateLayerUpload` / `UploadLayerPart` / `CompleteLayerUpload`——限定在此 ECR 仓库的 ARN 内。该角色只能在此处推送镜像,别无他处。 - `ssm:SendCommand`——在一条语句中限定为*两个*特定的 ARN: k3s 实例本身,以及 `AWS-RunShellScript` 文档。它不能 向账户中的任何其他实例发送命令,也不能使用任何 其他 SSM 文档。 - `ssm:GetCommandInvocation` 作用于 `*`——这个无法再进一步收紧: AWS 会在 `SendCommand` 时生成命令 ID,因此没有 可以提前引用的 ARN。 ### Helm chart `helm/secure-cloud-pipeline/` 将 Flask 应用打包为标准的 chart: 一个带有 `livenessProbe`/`readinessProbe` 且都指向 `GET /health`(与阶段 2 中添加的端点相同)的 `Deployment`,一个 5000 端口上的 `ClusterIP` `Service`(与 Dockerfile 的 `EXPOSE` 匹配),以及用于 常规资源命名的 `_helpers.tpl`。`APP_SECRET_MESSAGE` 目前通过 `values.yaml` 作为明文值接入——阶段 5 将其替换为 Secrets Manager 读取,而无需更改 chart 的结构。资源 requests/limits 有意设置得很小(100m/128Mi requests, 250m/256Mi limits),这样单个 pod 就不会在 `t3.small` 上挤占 k3s 自己的系统 pod。 ### 保持 `ecr-pull-secret` 有效 包含 ECR token 的 Kubernetes 镜像拉取 secret 无法一直保持有效—— ECR token 在约 12 小时后过期,因此创建一次(手动或通过 一次性的 Terraform 配置器)只会推迟 `ImagePullBackOff`,而无法 阻止它。配置器有两倍的问题:它还 依赖于 SSM 端口转发隧道和 `KUBECONFIG` 在 `terraform apply` 运行的确切时刻处于活动状态,这本身就构成了不稳定因素。 相反,`helm/secure-cloud-pipeline/templates/ecr-secret-cronjob.yaml` 每 6 小时在**集群内部**运行一次(轻松处于约 12 小时的 token 有效期内),并从头开始重新创建该 secret: 1. 使用 `aws ecr get-login-password` 获取新的 ECR token。 2. 通过 `kubectl create secret docker-registry ... --dry-run=client -o yaml | kubectl apply -f -` 将其转换为 `kubernetes.io/dockerconfigjson` secret——dry-run-then-apply 模式使其 幂等。它在每次运行时都会原地覆盖 `ecr-pull-secret`, 而不会因为它已经存在而报错,这也意味着**无需手动清理**, 如果在该 CronJob 存在之前手动创建了同名 secret——下一次运行就会接管并管理它。 有两件事使其在不需要任何新的 IAM 或凭证的情况下工作: - **AWS 凭证**:这是一个单节点集群,因此 CronJob 的 pod 运行在与其 IAM 角色已经拥有 `ecr:GetAuthorizationToken`(在阶段 3 中授予,`Resource "*"`——这也是 `get-login-password` 调用的唯一操作)的同一个 EC2 实例上。AWS CLI 的默认 凭证链通过 IMDS 获取该角色,就像应用的 boto3 调用一样,两者都依赖于阶段 5 中 `http_put_response_hop_limit = 2` 的修复来跨越多出的那一个从主机到 pod 的网络跳。这个 CronJob 根本不需要任何新的 IAM 权限,也不需要持有 AWS 凭证的 Kubernetes Secret。 - **集群凭证**:`kubectl` 会从 pod 挂载的 ServiceAccount token 自动检测集群内配置, 因此容器内不需要 kubeconfig 文件——不像 SSM 部署脚本,它直接在主机上运行,并且确实需要 `/etc/rancher/k3s/k3s.yaml`。该 ServiceAccount 的 RBAC(`templates/serviceaccount.yaml`)通过名称确切地限定在 一个 Secret 上:它可以在此命名空间中 `create` Secret(RBAC 的 `resourceNames` 无法限制 `create`——因为还没有 现有对象可以用来检查名称),但 `get`/`update`/`patch` 被明确 绑定到了 `ecr-pull-secret`,因此它无法触碰该命名空间中的任何其他 Secret。 `kubectl` 本身不是 `aws-cli` 基础镜像的一部分,因此 CronJob 在运行时通过 `curl` 获取它——这与 SSM 部署脚本已经用于引导 Helm 的按需二进制文件模式相同,而不是仅仅为了捆绑两个 CLI 而维护 并发布自定义镜像。 一个实际存在的缺陷:CronJob 在首次创建时不会立即运行,而只会在其下一次计划的时间点运行——因此在一个没有预先存在 `ecr-pull-secret` 的全新集群中, 第一次部署的 pod 可能会陷入 `ImagePullBackOff` 长达 6 小时,直到 CronJob 第一次运行。手动触发一次即可跳过该等待(CronJob 名为 `-secure-cloud-pipeline-ecr-refresh`——对于 `deploy.yml` 中使用的 release 名称即为 `secure-cloud-pipeline-secure-cloud-pipeline-ecr-refresh`;运行 `kubectl get cronjob` 来 确认): ``` kubectl create job --from=cronjob/secure-cloud-pipeline-secure-cloud-pipeline-ecr-refresh \ ecr-refresh-bootstrap ``` ### 部署实际上是如何发生的 没有暴露给互联网的 Kubernetes API,因此工作流无法直接从 GitHub 的 runner 执行 `kubectl apply` 或 `helm upgrade`。有两件事使其可行: 1. **`ssm:SendCommand`** *在* k3s 节点本身上运行 shell 脚本, 通过用于手动调试访问的同一个 SSM 通道——完全 没有产生新的网络暴露。 2. 该脚本无法直接从 GitHub runner 接收文件(SSM 只传递命令,而不是工作目录),因此与其为了搬运 chart 文件而建立一个 S3 bucket,不如直接从 GitHub 的公共 tarball endpoint (`codeload.github.com///tar.gz/`)拉取确切提交的源码,解压 `helm/` 目录,并针对其运行 `helm upgrade --install`。这 也保证了部署的 chart 始终与构建镜像的提交相匹配——没有单独的“chart 版本”可能会发生不同步。 脚本通过 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 指向集群, 因为它是*在*节点上运行,所以无需任何端口转发即可工作——该文件已经指向 `127.0.0.1:6443`。它还会检查 `helm`,如果缺少则通过官方安装脚本进行安装,而不是 将其烘焙到阶段 3 的 `user_data` 中——否则将会迫使替换掉已经运行的实例,仅仅是为了添加一个 CLI 工具。 在 `ssm send-command` 返回命令 ID 后,工作流会每 10 秒(最多 5 分钟)轮询一次 `ssm get-command-invocation`,直到状态 变为 `Success`,或者在 `Failed`/`Cancelled`/ `TimedOut` 时大声地将作业标记为失败——节点上的部署脚本错误会变成红色的 GitHub Actions 运行记录,而不是静默的无效操作。 ### 需要手动设置(由 GitHub 完成这部分,而不是 Terraform) Terraform 可以创建 IAM 角色,但它无法深入 GitHub 的 UI 来 告诉工作流使用哪个角色——这是 `terraform apply` 之后的一次性手动步骤: 1. 在 `live/` 中运行 `terraform output github_actions_role_arn`。 2. 在 GitHub 仓库中:**Settings → Secrets and variables → Actions → Variables 选项卡**(不是 Secrets——这个 ARN 不是敏感信息,除了已经在 此仓库的 `main` 分支上运行工作流的人之外,其他任何人都无法使用它)→ **New repository variable**。 3. 添加这些仓库变量: - `AWS_ROLE_ARN` — `github_actions_role_arn` 的输出。 - `AWS_REGION` — 与 `var.region` 相同的值(例如 `us-east-1`)。 - `ECR_REPOSITORY` — 仅仓库*名称*(例如 `secure-cloud-pipeline-app`),而不是完整的 URL;从 `ecr_repository_url` 输出中推导出来。 - `K3S_INSTANCE_ID` — `k3s_instance_id` 的输出。 - `SECRETS_MANAGER_SECRET_NAME` — `app_secret_name` 的输出(在 阶段 5 中添加;见下文)。 设置好这些变量后,只要推送到 `main` 分支并触及 `app/**` 或 `Dockerfile`,就会自动触发完整的构建-推送-部署流水线。 Secrets Manager 和 IMDS 跳数限制的陷阱(阶段 5) ### 将应用连接到 Secrets Manager `live/secrets.tf` 创建一个 `aws_secretsmanager_secret`(名为 `-app-secret`)和一个包含 `var.app_secret_value` 的版本——这是一个必需的、`sensitive = true` 且没有默认值的变量,仅在你自己的被 gitignore 忽略的 `terraform.tfvars` 中设置。它还授予 k3s 节点 来自阶段 3 的*现有* IAM 角色确切的一个新权限: `secretsmanager:GetSecretValue`,范围限定在该机密的 ARN 内。该 授权是一个独立的 `aws_iam_role_policy` 资源,而不是被合并到 `k3s_instance.tf` 的 `k3s_instance_ecr` 中——同一个角色,附加了两个策略, 因此 ECR 拉取策略和机密读取策略保持独立 可读,不需要一起修改。 `app/app.py` 在导入时读取一次 `SECRETS_MANAGER_SECRET_NAME`(而不是 每次请求时读取——该值在不重新部署的情况下不会改变,而且 Secrets Manager 按调用收费): - 如果该环境变量**已设置**,它会通过 boto3 获取机密,并将其用作 `APP_SECRET_MESSAGE`。如果获取因任何原因失败(缺少 权限、机密名称错误、无网络路径),应用将**在启动时崩溃**, 而不是静默回退——当设置了这个环境变量时, 能够正常从 Secrets Manager 读取就不是可选的,用占位符字符串掩盖权限 问题只会推迟发现它的时间。 - 如果**未设置**,应用将回退到阶段 2 的行为 (`APP_SECRET_MESSAGE` 环境变量,然后是字面默认值)——不需要任何 AWS 访问权限, 因此它仍然可以独立运行以进行本地测试。 Helm chart 将其作为普通的环境变量传入 (`values.yaml` 中的 `secretsManager.secretName`,在部署时像 `image.repository` 一样通过 `--set` 设置)—— `deployment.yaml` 始终会设置 `SECRETS_MANAGER_SECRET_NAME`,默认为空字符串,应用将其视为与“未设置”相同。 ### 陷阱:Pod 需要比主机更高的 IMDS 跳数限制 这个问题很容易让人浪费好几个小时,因此在此单独提出。EC2 的 实例元数据服务 (IMDS)——当没有现成的访问密钥时,boto3 使用它来查找凭证——有一个**跳数限制**,与 `http_tokens`(IMDSv2 开关)分开。默认的跳数限制是 **1**, 这仅允许直接在主机自身的网络命名空间中发起的 IMDS 请求。 一旦 boto3 在 *pod 内部*运行,它就是在从*不同的*网络命名空间发出同样的请求——距离 IMDS 终端节点比主机本身 远了一个跳点。如果跳数限制为 1,多出的那一跳会导致数据包被静默丢弃。boto3 不会将“IMDS 跳数限制过低”暴露为错误——它只会报告类似“Unable to locate credentials”的信息, 这与缺少/错误配置的 IAM 角色看起来一模一样,并会引导你 去调试错误的对象。 修复方法是 `live/k3s_instance.tf` 的 `metadata_options` 块中的一行代码: `http_put_response_hop_limit = 2`。这是覆盖 主机 → pod 所需的最小值,且不多加——它不会将 IMDS 暴露给该实例自身工作负载之外的任何内容。 ### 本阶段添加的手动设置 1. 在运行 `terraform apply` 之前,在你真实的 `terraform.tfvars` 中设置 `app_secret_value`(如果还没有的话,从 `terraform.tfvars.example` 复制一份)——它没有默认值,因此如果缺少它,`apply` 会提示输入。 2. 在 `apply` 之后,运行 `terraform output app_secret_name` 并将其作为 第 5 个 GitHub 仓库变量 `SECRETS_MANAGER_SECRET_NAME` 添加(与其他四个一样,位于 Settings → Secrets and variables → Actions → Variables 选项卡)。 ## 监控:Prometheus + Grafana(阶段 6) ### 为什么不使用 kube-prometheus-stack Helm chart `kube-prometheus-stack` 是在 Kubernetes 上运行 Prometheus 的标准方式, 但它是为能够承受其分量的集群而构建的: Alertmanager、kube-state-metrics、作为 DaemonSet 的 node-exporter、多个 CRD,以及负责协调这一切的 Prometheus Operator——轻轻松松就会多出 6 个以上的 pod。这是一个 `t3.small`(2 个 vCPU,2GiB 内存),已经运行了 k3s 自身的 系统 pod、应用 pod 和 `ecr-refresh` CronJob;没有多余的内存余量留给 operator 套件,而且它增加的大部分功能 (告警、长期指标存储、多集群服务发现) 在单节点演示集群中毫无用处。它也将成为本项目中 唯一一个第三方 Helm chart,而项目里的所有其他工作负载—— `deployment.yaml`、`ecr-secret-cronjob.yaml`——都是手写的清单, 这使得端到端的阅读以及推断每一部分实际需要的 RBAC 变得更加困难。 因此,`templates/monitoring/prometheus.yaml` 和 `templates/monitoring/ grafana.yaml` 是手写的,采用与该 chart 其余部分相同的风格,缩减到只有两个 pod 执行确切的一项任务:收集 基本的 CPU/内存指标并在一个仪表板上展示它们。 与完整的套件相比,**有意省略的内容**: - **没有 Alertmanager**——对于作品集项目来说,没有待命轮换或寻呼机; 无处发送的告警规则只是累赘。 - **没有 node-exporter DaemonSet**——k3s 的 kubelet 已经通过其内置的 cAdvisor endpoint (`/metrics/cadvisor`) 暴露了 CPU/内存,这正是 `prometheus.yml` 唯一的 抓取作业所指向的目标。在单节点集群上,为此使用 DaemonSet 是多余的(它只会调度一个 pod,与普通的 Deployment 相同),而且又多了一个需要调整大小和打补丁的工作负载。 - **没有 kube-state-metrics**——它暴露的是 Kubernetes 的*对象*状态 (Deployment 副本数、pod 阶段等),而不是本阶段要求的 CPU/内存使用率。 跳过它是因为它超出了范围,而不是因为它很难。 - **没有长期指标存储**——`prometheus.yaml` 使用带有 6 小时保留标志的 `emptyDir`, 而不是 PersistentVolumeClaim。这个单节点集群没有配置基于 EBS 的 StorageClass, 而且 pod 重启后保留下来的指标历史并不是一个“证明它有效”的仪表板的目的——完整的推理请参见 `prometheus.yaml` 和 `grafana.yaml` 中的注释。 - **没有公共访问权限**——与本项目中的一切相同:两个 Service 都是 `ClusterIP`,从不使用 `LoadBalancer`,也没有 Ingress。 访问任一 UI 的唯一方式是下方的 `kubectl port-forward` 命令,从已经拥有集群访问权限的机器上运行(关于该访问最初是如何 设置的,请参见上文的“从您的笔记本电脑访问 k3s API”)。 ### RBAC:为什么 Prometheus 获得 ClusterRole,而 ecr-refresh CronJob 没有 `ecr-secret-cronjob.yaml` ServiceAccount 仅触碰一个命名空间中的一个 Secret, 因此命名空间级的 `Role`/`RoleBinding` 是正确且最窄的 范围。Prometheus 在结构上是不同的:Kubernetes 服务发现意味着*发现*集群中的每一个节点,并代理到每一个节点的 kubelet 去抓取 `/metrics/cadvisor`——没有办法使用命名空间的 Role 来表达“监视节点并代理到其 kubelet”,因为节点根本不是命名空间对象。`prometheus.yaml` 的 `ClusterRole` 准确授予对 `nodes`、`pods`、 `services` 和 `endpoints`(用于发现)的 `get`/`list`/`watch`,以及对 `nodes/proxy` 子资源(用于实际到达 kubelet 指标)的 `get` 权限——只读、集群范围的,因为这项工作本身就是集群范围的, 而且仅此而已。 ### Grafana 的管理员密码 `grafana.yaml` 使用 Helm 的 `randAlphaNum` 函数生成管理员密码,而不是在提交的文件中硬编码。不过,直接调用 `randAlphaNum` 会在每次 `helm upgrade` 时生成一个*新*密码,从而将您锁在已经登录的集群之外——因此模板首先对先前安装的现有 Secret 执行 `lookup`,如果找到,则重用其密码,仅在真正首次安装时才生成新密码。要固定特定的密码 (例如,为了方便演示),在部署时传递 `--set monitoring.grafana.adminPassword=`,与本 chart 中其他地方的 `image.repository` 和 `awsRegion` 模式相同。 ### 在本地访问两个 UI 与 k3s API 本身具有相同的访问模型(参见上文的“从您的笔记本电脑访问 k3s API”)——一旦您拥有了一个适用于该集群的 `kubeconfig`,就可以直接使用 `kubectl` port-forward 每个 Service。两个 Service 名称都遵循与上方的 `ecr-refresh` CronJob 相同的 `-secure-cloud-pipeline-...` 模式(并且受制于相同的 `trunc 40` 截断注意事项)——首先运行 `kubectl get svc` 以确认确切的 名称,而不是盲目猜测: ``` kubectl get svc # Prometheus kubectl port-forward svc/ 9090:9090 # Grafana kubectl port-forward svc/ 3000:3000 ``` 然后打开 `http://localhost:9090` (Prometheus) 和 `http://localhost:3000` (Grafana——以 `admin` 身份登录;密码在 `*-grafana-admin` Secret 中:`kubectl get secret -o jsonpath='{.data.admin-password}' | base64 -d`,使用 `kubectl get secret` 查找确切的 Secret 名称)。“Pod CPU & Memory”仪表板是 预先配置好并默认选中的——无需手动设置数据源或 导入仪表板。 ## 成本说明 - 使用 NAT 实例(`t3.nano`)而非 NAT Gateway:NAT Gateway 的计费约为 ~$0.045/小时,外加每 GB 的数据处理费用;`t3.nano` 在按需实例上的费用是固定的每月约 ~$3-4(使用预留/Spot 实例则更少),代价是您需要自己负责修补和保证可用性,而不是由 AWS 来管理。 - 使用单台 EC2 实例上的 k3s 而非 EKS:没有每月 $73 的控制平面费用,代价是没有托管的高可用控制平面——对于作品集项目可以接受,但不适用于生产环境。 - Prometheus + Grafana 作为两个额外的 pod 运行在现有的 `t3.small` 上——没有 新的 AWS 资源,因此也没有新的每月费用,就像托管的 可观测性服务(Amazon Managed Prometheus/Grafana、Datadog 等)那样。 ## 关于 `.terraform.lock.hcl` 的说明 这个文件是故意提交的(没有被 gitignore):它锁定了确切的 provider 版本,使得 `terraform init` 在每台机器和 CI 上都能解析到相同的版本,而不是在两次运行之间可能漂移到更新的 provider 发行版。
标签:AWS, DPI, ECS, K3s, Terraform, 基础设施, 子域名突变, 漏洞利用检测, 漏洞探索, 自定义请求头, 请求拦截, 逆向工具