在 EKS Fargate 上做微服务蓝绿发布:把切换点放在 ALB 上
一套可直接运行的参考实现:Terraform 建平台,Kustomize 管切换。
源码仓库: github.com/geekchow/blue-green-eks-fargate-alb
------本文涉及的所有内容都在这个仓库里,clone 下来即可直接运行。English version:
blog/blue-green-eks-fargate-alb.en.md
缩写对照表
| 缩写 | 英文全称 | 中文 |
|---|---|---|
| ACM | AWS Certificate Manager | AWS 证书管理器 |
| ALB | Application Load Balancer | 应用负载均衡器 |
| API | Application Programming Interface | 应用程序接口 |
| ARN | Amazon Resource Name | 亚马逊资源名称 |
| AZ | Availability Zone | 可用区 |
| CIDR | Classless Inter-Domain Routing | 无类别域间路由 |
| CRD | Custom Resource Definition | 自定义资源定义 |
| DNS | Domain Name System | 域名系统 |
| EKS | Elastic Kubernetes Service | 弹性 Kubernetes 服务 |
| ENI | Elastic Network Interface | 弹性网络接口 |
| IAM | Identity and Access Management | 身份与访问管理 |
| IMDS | Instance Metadata Service | 实例元数据服务 |
| IRSA | IAM Roles for Service Accounts | 服务账号的 IAM 角色 |
| LP | Live Proving | 线上验证 |
| NAT | Network Address Translation | 网络地址转换 |
| NLB | Network Load Balancer | 网络负载均衡器 |
| OIDC | OpenID Connect | 开放身份连接 |
| SNS | Simple Notification Service | 简单通知服务 |
| SSE | Server-Sent Events | 服务器发送事件 |
| TG | Target Group | 目标组 |
| TTL | Time To Live | 生存时间 |
| VPC | Virtual Private Cloud | 虚拟私有云 |
一、问题背景
三个 API(Application Programming Interface,应用程序接口)跑在使用 Fargate 算力的
EKS(Elastic Kubernetes Service,弹性 Kubernetes 服务)集群上,通过
aws-load-balancer-controller 对外暴露。我们希望做蓝绿发布:新版本与老版本并存部署,用生产的基础设施
和生产的配置去验证它,确认无误后一次性把流量全部切过去;万一新版本在真实流量下出问题,能在几秒内撤回这个决定。
本文实现的思路是:
把新版本部署到
blue命名空间,用一个只匹配 LP(Live Proving,线上验证)域名的 Ingress 暴露它,并把 ingress order 设成较小的值,让它被优先匹配。通过 LP 域名对新版本做健康检查。确认没问题后,
解除主机名限制------此时这条低 order 的规则也会匹配生产流量,所有请求都落到 blue,green 退役。
如果真实用户进来之后才发现问题,就把主机名限制加回去,流量立刻回到 green。
这个设计是对的,本文大部分篇幅是在解释它为什么对。但要让它撑过第二次发布,还需要补两件事,下面都会讲到。
二、为什么在负载均衡器上切,而不是在 DNS 上切
最容易想到的替代方案,是让 api.example.com 指向当前的生产腿,切换时改 DNS(Domain Name System,域名系统)
记录。不要这样做。 对任何"可能需要撤回"的操作来说,DNS 都是一个糟糕的开关:
- 回滚被 TTL(Time To Live,生存时间)卡住。 TTL 设 60 秒,就意味着你决定中止之后,至少还有一分钟的
用户在继续访问坏掉的那条腿。 - TTL 只是建议值。 企业解析器、设了
networkaddress.cache.ttl=-1的 JVM、各种移动端客户端,
都可能把过期结果缓存几个小时。你无法给"还有人留在旧腿上"设一个时间上界。 - 集群内看不到这个状态。 你没法问 Kubernetes "现在哪条腿是生产的"。
改在 ALB(Application Load Balancer,应用负载均衡器)上路由,这些问题全部消失:两个域名永久 解析到
同一个 负载均衡器,发布过程中 DNS 从头到尾不变。切换只是改写监听器规则;而由于 ALB 是
按请求 、而不是按连接来匹配规则的,这个变更会作用在已经建立的连接上的下一个请求 ------
不需要重新解析,也不需要重连。
严格地说(后面所有结论都建立在这个细节上):规则变更确实需要数秒才能传播到 ALB 的所有节点,
所以这个切换是"很快",而不是字面意义上的"原子"。但那是以传播时间为上界的数秒 ,
对比之下 DNS 是在你无法控制的各级缓存上无上界地等 。
第八节会完整推演一个持有 keep-alive 长连接的客户端到底会经历什么。
支撑这一点的机制是控制器的 IngressGroup:多个位于不同命名空间的 Ingress 对象共享同一个 ALB。
yaml
alb.ingress.kubernetes.io/group.name: public-apis
alb.ingress.kubernetes.io/group.order: "1" # 数值越小越先被匹配
每条腿向这个组贡献自己的 Ingress,group.order 决定这条腿的监听器规则的相对优先级。先匹配者胜出 ,
所以 order 较小的腿挡在另一条腿前面时,可以遮蔽它------可以完全遮蔽,也可以只遮蔽特定的主机名。
这种有选择的遮蔽,就是蓝绿开关本身。
三、路由全景
#mermaid-svg-flYPjH9kzKTj9c6M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-flYPjH9kzKTj9c6M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-flYPjH9kzKTj9c6M .error-icon{fill:#552222;}#mermaid-svg-flYPjH9kzKTj9c6M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-flYPjH9kzKTj9c6M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-flYPjH9kzKTj9c6M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-flYPjH9kzKTj9c6M .marker.cross{stroke:#333333;}#mermaid-svg-flYPjH9kzKTj9c6M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-flYPjH9kzKTj9c6M p{margin:0;}#mermaid-svg-flYPjH9kzKTj9c6M .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-flYPjH9kzKTj9c6M .cluster-label text{fill:#333;}#mermaid-svg-flYPjH9kzKTj9c6M .cluster-label span{color:#333;}#mermaid-svg-flYPjH9kzKTj9c6M .cluster-label span p{background-color:transparent;}#mermaid-svg-flYPjH9kzKTj9c6M .label text,#mermaid-svg-flYPjH9kzKTj9c6M span{fill:#333;color:#333;}#mermaid-svg-flYPjH9kzKTj9c6M .node rect,#mermaid-svg-flYPjH9kzKTj9c6M .node circle,#mermaid-svg-flYPjH9kzKTj9c6M .node ellipse,#mermaid-svg-flYPjH9kzKTj9c6M .node polygon,#mermaid-svg-flYPjH9kzKTj9c6M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-flYPjH9kzKTj9c6M .rough-node .label text,#mermaid-svg-flYPjH9kzKTj9c6M .node .label text,#mermaid-svg-flYPjH9kzKTj9c6M .image-shape .label,#mermaid-svg-flYPjH9kzKTj9c6M .icon-shape .label{text-anchor:middle;}#mermaid-svg-flYPjH9kzKTj9c6M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-flYPjH9kzKTj9c6M .rough-node .label,#mermaid-svg-flYPjH9kzKTj9c6M .node .label,#mermaid-svg-flYPjH9kzKTj9c6M .image-shape .label,#mermaid-svg-flYPjH9kzKTj9c6M .icon-shape .label{text-align:center;}#mermaid-svg-flYPjH9kzKTj9c6M .node.clickable{cursor:pointer;}#mermaid-svg-flYPjH9kzKTj9c6M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-flYPjH9kzKTj9c6M .arrowheadPath{fill:#333333;}#mermaid-svg-flYPjH9kzKTj9c6M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-flYPjH9kzKTj9c6M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-flYPjH9kzKTj9c6M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-flYPjH9kzKTj9c6M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-flYPjH9kzKTj9c6M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-flYPjH9kzKTj9c6M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-flYPjH9kzKTj9c6M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-flYPjH9kzKTj9c6M .cluster text{fill:#333;}#mermaid-svg-flYPjH9kzKTj9c6M .cluster span{color:#333;}#mermaid-svg-flYPjH9kzKTj9c6M div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-flYPjH9kzKTj9c6M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-flYPjH9kzKTj9c6M rect.text{fill:none;stroke-width:0;}#mermaid-svg-flYPjH9kzKTj9c6M .icon-shape,#mermaid-svg-flYPjH9kzKTj9c6M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-flYPjH9kzKTj9c6M .icon-shape p,#mermaid-svg-flYPjH9kzKTj9c6M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-flYPjH9kzKTj9c6M .icon-shape .label rect,#mermaid-svg-flYPjH9kzKTj9c6M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-flYPjH9kzKTj9c6M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-flYPjH9kzKTj9c6M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-flYPjH9kzKTj9c6M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Route 53 --- 两条记录永远指向同一个 ALB
不匹配
匹配
匹配
命名空间 green --- 在位腿,v1.9.0
orders-api
payments-api
catalog-api
命名空间 blue --- 候选腿,v2.0.0
orders-api
payments-api
catalog-api
api.example.com
生产域名
lp-api.example.com
线上验证域名
ALB · IngressGroup public-apis · HTTPS:443
优先级 1 --- 来自 blue 的 Ingress
host == lp-api.example.com ?
优先级 10 --- 来自 green 的 Ingress
host == api.example.com ?
按顺序读这两条规则,整个设计就自然浮现出来了:
- 访问
lp-api.example.com的请求命中优先级 1,进入 blue。 - 访问
api.example.com的请求不 命中优先级 1,穿透到优先级 10,进入 green。
这两件事在同一时刻 成立,走的是同一个 负载均衡器、同一张 证书、同一个 监听器。
这正是线上验证有意义的原因:你验证的不是一个近似的预发环境,而是真实的入口链路、真实的 TLS 终止,
唯一的差别只有主机名。
切换时,blue 的规则不再限制在 LP 域名上,它现在也匹配 api.example.com------而因为它被优先匹配,
green 的规则再也不会被走到。green 没有被修改、没有重启、没有缩容,只是不再被问到而已。
四、状态机:为什么两个状态不够
原始设计里 blue 是 order 1、green 是 order 10。对第一次发布,这完全没问题。但看看第二次。
blue 赢了,blue 现在在 order 1。下一个版本得找地方放,于是放到 green------而 green 在 order 10,
排在 blue 后面 。green 在那个位置永远遮蔽不了 blue,也就永远无法被提升。
前排的槽位被上一次发布的赢家占住了,而且再也没被腾出来。
解法是:不要把 order 当作颜色的属性,而要当作角色的属性。 两个槽位,三种角色:
| 角色 | group.order |
Ingress 规则 | 承接的流量 |
|---|---|---|---|
| candidate(候选) | 1(前排) | 仅 LP 域名 | LP 流量;生产流量穿透给在位腿 |
| promoted(已提升) | 1(前排) | 生产 + LP | 全部流量------它遮蔽了在位腿 |
| incumbent(在位) | 10(后排) | 生产 + LP | 只要前面没人挡着,就是生产流量 |
静息状态下只存在一条腿,它是在位腿。一次发布会在前排临时创建一条候选腿;这条候选腿要么被提升,要么被丢弃。
提升并观察(soak)一段时间之后,finalize(收尾) 步骤会让老腿退役,并把赢家从 promoted
轮转到 incumbent,从而腾出前排槽位给下一次发布。
#mermaid-svg-B697F4XYIeKtwgih{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-B697F4XYIeKtwgih .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-B697F4XYIeKtwgih .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-B697F4XYIeKtwgih .error-icon{fill:#552222;}#mermaid-svg-B697F4XYIeKtwgih .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-B697F4XYIeKtwgih .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-B697F4XYIeKtwgih .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-B697F4XYIeKtwgih .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-B697F4XYIeKtwgih .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-B697F4XYIeKtwgih .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-B697F4XYIeKtwgih .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-B697F4XYIeKtwgih .marker{fill:#333333;stroke:#333333;}#mermaid-svg-B697F4XYIeKtwgih .marker.cross{stroke:#333333;}#mermaid-svg-B697F4XYIeKtwgih svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-B697F4XYIeKtwgih p{margin:0;}#mermaid-svg-B697F4XYIeKtwgih defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-B697F4XYIeKtwgih g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-B697F4XYIeKtwgih g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-B697F4XYIeKtwgih g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-B697F4XYIeKtwgih g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-B697F4XYIeKtwgih g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-B697F4XYIeKtwgih .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-B697F4XYIeKtwgih .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-B697F4XYIeKtwgih .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-B697F4XYIeKtwgih .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-B697F4XYIeKtwgih .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-B697F4XYIeKtwgih .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-B697F4XYIeKtwgih .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-B697F4XYIeKtwgih .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B697F4XYIeKtwgih .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-B697F4XYIeKtwgih .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B697F4XYIeKtwgih .edgeLabel .label text{fill:#333;}#mermaid-svg-B697F4XYIeKtwgih .label div .edgeLabel{color:#333;}#mermaid-svg-B697F4XYIeKtwgih .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-B697F4XYIeKtwgih .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-B697F4XYIeKtwgih .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-B697F4XYIeKtwgih .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-B697F4XYIeKtwgih .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-B697F4XYIeKtwgih .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-B697F4XYIeKtwgih .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-B697F4XYIeKtwgih #statediagram-barbEnd{fill:#333333;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-B697F4XYIeKtwgih .cluster-label,#mermaid-svg-B697F4XYIeKtwgih .nodeLabel{color:#131300;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-B697F4XYIeKtwgih .note-edge{stroke-dasharray:5;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-note text{fill:black;}#mermaid-svg-B697F4XYIeKtwgih .statediagram-note .nodeLabel{color:black;}#mermaid-svg-B697F4XYIeKtwgih .statediagram .edgeLabel{color:red;}#mermaid-svg-B697F4XYIeKtwgih #dependencyStart,#mermaid-svg-B697F4XYIeKtwgih #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-B697F4XYIeKtwgih .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-B697F4XYIeKtwgih :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 静息态,green 承接生产流量
把 v2 部署到 blue(order 1,仅 LP)
验证失败 --- 删掉 blue,生产流量全程无感
验证通过 --- 解除主机名限制
回滚 --- 把限制加回去
收尾 --- green 退役,blue 从 1 轮转到 10
下一次发布,两种颜色互换角色
GreenIncumbent
BlueCandidate
BluePromoted
BlueIncumbent
GreenCandidate
注意回滚这条边指向哪里:指回 candidate,而不是某个独立的抢修流程。回滚就是重新进入五分钟前你已经待过的状态 ,
而且出问题的那条腿依然挂在 LP 域名上,你可以在生产已经安全的前提下,用真实请求去调试它。
五、用 Kustomize 表达这个状态机
切换动作应该是一个小的、可评审、可回退的 diff,而不是一段去改动线上对象的脚本。
所以状态机被建模成一个 颜色 × 角色 的 overlay 矩阵:
k8s/
├── base/ 三个 API + 一个同时带两条 host 规则的 Ingress
│ ├── apis/{orders,payments,catalog}/{deployment,service}.yaml
│ ├── nginx/api.conf.template
│ └── ingress.yaml
├── legs/
│ ├── blue/kustomization.yaml namespace: blue, leg=blue, APP_VERSION
│ └── green/kustomization.yaml namespace: green, leg=green, APP_VERSION
├── components/
│ ├── candidate/ order 1,删掉生产 host 规则
│ ├── promoted/ order 1,保留两条 host 规则
│ └── incumbent/ order 10,保留两条 host 规则
└── overlays/
├── blue-candidate/ blue-promoted/ blue-incumbent/
└── green-candidate/ green-promoted/ green-incumbent/
legs/ 负责身份,components/ 负责角色,六个 overlay 各自只有六行,把两者组合起来:
yaml
resources:
- ../../legs/blue
components:
- ../../components/candidate
开关本身
base 里的 Ingress 同时列出两个主机名,生产的排在前面:
yaml
rules:
- host: api.example.com # rules[0] --- 生产
http: { paths: [...] }
- host: lp-api.example.com # rules[1] --- 线上验证
http: { paths: [...] }
而 candidate 组件把生产那条删掉:
yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
- target: { group: networking.k8s.io, version: v1, kind: Ingress, name: apis }
patch: |-
- op: remove
path: /spec/rules/0
- op: replace
path: /metadata/annotations/alb.ingress.kubernetes.io~1group.order
value: "1"
这就是全部的"主机名限制"。promoted 就是同一段补丁去掉 remove 那一步。
提升和回滚,就是这两个文件之间的差别。
~1不是笔误。 在 JSON Pointer(RFC 6901)里,键名中的字面/要转义成~1(~转义成~0)。注解的 key 里全是斜杠,所以每一个针对注解的 JSON 6902 补丁都需要这个转义。
不转义的话,报错会是含糊的 "missing value",而不是明显的"路径写错了"。
离线校验这个矩阵
scripts/render-check.sh 会渲染全部六个 overlay 并逐格断言,不需要集群,也不需要 AWS 账号,
适合直接放进持续集成:
OVERLAY NS ORDER HOSTS RESULT
blue-candidate blue 1 lp-api.example.com ok
blue-promoted blue 1 api.example.com,lp-api.example.com ok
blue-incumbent blue 10 api.example.com,lp-api.example.com ok
green-candidate green 1 lp-api.example.com ok
green-promoted green 1 api.example.com,lp-api.example.com ok
green-incumbent green 10 api.example.com,lp-api.example.com ok
它还会检查一件手改时很容易搞砸的事:IngressGroup 里所有成员的 ALB 级注解必须逐字节一致。见第九节。
六、一次完整的发布
green (v1.9.0) blue (v2.0.0) ALB LB 控制器 EKS API 运维 green (v1.9.0) blue (v2.0.0) ALB LB 控制器 EKS API 运维 #mermaid-svg-J1WsRm3hgSfjyf9C{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J1WsRm3hgSfjyf9C .error-icon{fill:#552222;}#mermaid-svg-J1WsRm3hgSfjyf9C .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J1WsRm3hgSfjyf9C .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J1WsRm3hgSfjyf9C .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J1WsRm3hgSfjyf9C .marker.cross{stroke:#333333;}#mermaid-svg-J1WsRm3hgSfjyf9C svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J1WsRm3hgSfjyf9C p{margin:0;}#mermaid-svg-J1WsRm3hgSfjyf9C .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-J1WsRm3hgSfjyf9C text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-J1WsRm3hgSfjyf9C .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-J1WsRm3hgSfjyf9C .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-J1WsRm3hgSfjyf9C #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-J1WsRm3hgSfjyf9C .sequenceNumber{fill:white;}#mermaid-svg-J1WsRm3hgSfjyf9C #sequencenumber{fill:#333;}#mermaid-svg-J1WsRm3hgSfjyf9C #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-J1WsRm3hgSfjyf9C .messageText{fill:#333;stroke:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-J1WsRm3hgSfjyf9C .labelText,#mermaid-svg-J1WsRm3hgSfjyf9C .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .loopText,#mermaid-svg-J1WsRm3hgSfjyf9C .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-J1WsRm3hgSfjyf9C .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-J1WsRm3hgSfjyf9C .noteText,#mermaid-svg-J1WsRm3hgSfjyf9C .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-J1WsRm3hgSfjyf9C .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-J1WsRm3hgSfjyf9C .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-J1WsRm3hgSfjyf9C .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-J1WsRm3hgSfjyf9C .actorPopupMenu{position:absolute;}#mermaid-svg-J1WsRm3hgSfjyf9C .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-J1WsRm3hgSfjyf9C .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-J1WsRm3hgSfjyf9C .actor-man circle,#mermaid-svg-J1WsRm3hgSfjyf9C line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-J1WsRm3hgSfjyf9C :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 静息态 --- green 在位,order 10 LP ->> blue,生产 ->> green,同时成立 green 在 order 10 原封不动 --- 热的回滚目标 观察期,盯指标 apply -k overlays/blue-candidate 1 创建 Pod(注入就绪门控) 2 注册 blue 目标,新增规则 @1,host 仅 LP 3 目标健康 4 就绪门控 ->> True 5 GET https://lp-api/...(线上验证) 6 转发 7 200, X-Leg: blue 8 apply -k overlays/blue-promoted 9 规则 @1 现在也匹配生产 host 10 全部生产流量 11 收尾:先删 green,再把 blue 从 1 轮转到 10 12
对应的脚本:
bash
./scripts/status.sh # 当前真实状态
./scripts/promote.sh blue # 部署、LP 验证、暂停确认、切换
./scripts/rollback.sh blue # 立即撤回
./scripts/finalize.sh blue # 观察期门禁、green 退役、槽位轮转
promote.sh 在三个条件同时满足之前不会切换:所有 Deployment 完成滚动、每个 Pod 的 ALB 就绪门控都是 True、
三个 API 在 LP 域名上都返回 200 且 X-Leg: blue。然后它会停下来征求确认。
在提示之前它会打印那个"分叉"状态------这是整套机制里最有说服力的一段输出:
live https://api.example.com/orders -> green
lp https://lp-api.example.com/orders -> blue
同一时刻、同一个负载均衡器、同一张证书、同一个监听器,两个不同的答案。
七、回滚
bash
./scripts/rollback.sh blue
它重新应用 blue-candidate,把"仅 LP"的限制恢复回去。blue 的 order 1 规则不再匹配 api.example.com,
请求穿透到 green 的 order 10 规则;而 green 全程都在运行、健康、已注册,于是下一个请求就恢复由它服务。
它之所以快,关键在于没有发生什么 :没有 DNS 变更、没有拉镜像、没有起 Pod、没有扩容、没有目标注册、
没有健康检查爬坡。唯一的变更是一条监听器规则的匹配条件。脚本花在验证回滚上的时间,远多于执行回滚本身。
blue 故意不删除 。它依然可以通过 LP 域名访问,你可以在生产已经安全之后,
对着真实的入口链路复现这个故障。
唯一会失去这个性质的时刻是 finalize.sh ,它会让老腿退役。在那之后,恢复手段就只剩向前修复,
或者从头重新部署上一个版本。脚本在执行前会明确提示这一点。选观察期长度时请把这一条放在心上 ------
多让两条腿并行跑一小时的成本,远小于"想撤撤不回来"的成本。
八、连接复用:切换对一条正在使用的 keep-alive 连接做了什么
任何认真想过 HTTP 的人,都应该会提出这个质疑:
HTTP/1.1 默认保持连接不断开,HTTP/2 更是把所有请求复用在一条连接上。这些连接会跨过切换点继续存在。
那用户不就会一直卡在老腿上,直到连接超时为止吗?而且一个浏览器同时开着好几条连接,
岂不是会在新旧版本之间来回跳?
如果连接是路由的单位,这个推论完全正确。 在工作于四层的 NLB(Network Load Balancer,网络负载均衡器)
上它就是对的:被均衡的对象就是连接本身,已经建立的连接会继续流向它原本流向的地方。
但 ALB 不是这样工作的。
ALB 是一个完整的代理,它按请求路由
这里有两个互相独立的连接池,彼此解耦:
客户端 <--keep-alive--> ALB <--连接池--> 目标 Pod
(前端连接) (后端连接)
客户端的 TCP 与 TLS 连接终止在 ALB 上 ,它根本到不了 Pod。对于这条连接上到达的每一个请求 ,
ALB 都会解析该请求、拿它去匹配监听器规则、再选出目标组。host-header 条件是每个请求都重新求值一次 的,
而不是在建连时求值一次就固定下来。
所以在同一条没有断开的连接上跨越切换点:
第 N 个请求 在连接 C 上 -> 规则:lp-only@1, live@10 -> green
<<< 提升:blue 的规则现在也匹配生产 host >>>
第 N+1 个请求 在连接 C 上 -> 规则:live+lp@1 -> blue
同一条 TCP 连接、同一个 TLS 会话,不重连、不握手,客户端感知不到任何事件。
keep-alive 连接与路由无关,因为路由从来就不是它的属性。
后端一侧同样是独立的:ALB 自己维护着通往 blue 和 green 目标组的连接池。切换之后,
它只是不再从 green 的池里取连接而已。那些连接会闲置下来并自然老化关闭,没有任何请求被中途掐断。
一个有用的推论:切换过程完全不涉及目标摘除。 第九节里的 deregistration_delay 和 preStop sleep
是为 Pod 滚动更新准备的;切换本身只改了一个规则条件,因此不会发生摘流,也不需要摘流。
这个担心在哪些情况下确实成立
三种情况,其中两种是真实存在的,值得精确地知道。
1. 规则传播不是原子的。 一个 ALB 不是一台机器,而是一组节点(每个可用区至少一个,并会随负载扩展)。
规则变更需要在很短的时间内(通常是数秒)传播到所有节点。客户端通过 DNS 被分散到不同节点上,
而浏览器会开好几条连接、可能落在不同节点上。在这个窗口期内,同一个用户确实可能一部分响应来自 blue、
一部分来自 green。
所以"来回跳"是真的存在的------但它的边界是规则传播时间(秒级) ,
而不是 连接寿命或 idle_timeout。AWS 并没有对这个时间给出硬性上界。
实际的应对不是"等连接都断掉",而是:不要在几秒之内连续做提升和回滚;
以及确保"短暂一两秒的混合响应"是可以承受的------对向后兼容的 API 版本来说,它是可以承受的。
2. WebSocket 与 SSE(Server-Sent Events)是被钉死的。 连接一旦完成 Upgrade,
它就不再是一串可以逐个路由的请求,而变成了通往某个特定目标的隧道。这些连接会一直留在老腿上,
直到自己断开、客户端重连为止。这是原始质疑完全成立的地方。
如果你有长连接流式业务,那么切换对你的请求/响应流量是"按请求"的,对你的流式连接却是"按连接寿命"的
------这两者要分开规划。
3. 两条腿共用同一个 group.order------这才是真正会导致抖动的 bug。
如果两个 Ingress 最终都声明了同一个 order,它们之间的 ALB 规则优先级是未定义的,
而且可能在不同节点上不一致。这会产生真实的、持续的、不可预测的、永远不会收敛的 来回抖动,
看起来恰好就是那个质疑描述的故障模式。这正是 finalize.sh 要先删掉退役的腿、再 把赢家从
order 1 轮转到 order 10 的原因,也是 promote.sh 在两条腿都已在前排时拒绝启动的原因。
真正会遗留下来的问题:版本错配(version skew)
把传输层的问题澄清掉,并不会让底层的担忧消失------只是把它挪了个位置。
真正要担心的不是连接,而是会话。
一个浏览器会话在 green v1.9 上开始,之后它发出的请求会在会话进行中、页面没有刷新的情况下
落到 blue v2.0 上。如果你回滚,同一个会话又会回到 v1.9。
按请求路由不只是允许 这种情况发生,它是保证这种情况会发生。
要用的纪律和数据库迁移(第十一节)是同一套:
- API 契约至少要向后兼容一个版本。
- 如果某个客户端确实必须锁定版本,就在路径或请求头里显式协商 ------
不要指望连接亲和性,你并没有这个东西。 - 单页应用的静态资源用 CloudFront/S3 提供、并使用内容哈希文件名,不要从腿里出,
这样会话中途的切换才不会让前端 bundle 和后端 API 脱节。 - 把"现在提升、一小时后回滚"当成一个对用户可见的决定,而不是一次免费的撤销。
如果对你来说会话一致性比即时回滚更重要 ,那这套设计形状就不对。
你要的是加权目标组配合粘性 Cookie,代价是撤销过程会从"立即"变成"渐进"。
亲眼验证一下
curl 对同一 host 的重复 URL 会复用同一条连接,所以你可以直接在单条连接 上观察这次翻转。
在做提升的过程中运行:
bash
curl -sv -o /dev/null -D - \
$(printf 'https://api.example.com/orders %.0s' $(seq 1 300)) 2>&1 \
| grep -iE 'Re-using existing connection|^x-leg'
你会看到全程都在打印 Re-using existing connection,而 X-Leg 的值在列表中间某处从 green
变成了 blue。一条连接,两条腿------这正是整件事的关键。
九、Fargate 与 ALB 上真正会咬人的细节
下面每一条,第一次遇到时都会以令人困惑的方式失败。
target-type: ip 是必须的。 Fargate 没有工作节点,也就没有 NodePort 可供转发,
ALB 必须直接注册 Pod 的 ENI(Elastic Network Interface,弹性网络接口)地址。
子网发现标签。 公有子网上没有 kubernetes.io/role/elb = 1(私有子网上没有
kubernetes.io/role/internal-elb = 1),控制器就无法判断把 ALB 放在哪里,报错 "unable to discover subnets"。
配置在 terraform/vpc.tf。
控制器必须显式传入 region 和 vpcId。 在 EC2 节点上它从 IMDS(Instance Metadata Service,实例元数据服务)
读这两个值。Fargate 没有 IMDS,缺了这两个 Helm 值,控制器会以
failed to introspect vpcID from EC2Metadata 反复崩溃。配置在 terraform/lb-controller.tf。
CoreDNS 调度不起来。 官方的 CoreDNS Deployment 带着 eks.amazonaws.com/compute-type: ec2 注解,
在纯 Fargate 集群上这个 addon 会永远停在 DEGRADED。要在 addon 的配置里传 computeType: Fargate,
并确保 kube-system 的 Fargate profile 先存在。配置在 terraform/addons.tf。
Pod 就绪门控,否则你的 rollout status 是假的。 默认情况下 Pod 的探针通过就算 Ready,
而这跟 ALB 有没有注册它毫无关系。给命名空间打标签:
yaml
labels:
elbv2.k8s.aws/pod-readiness-gate-inject: enabled
控制器的 webhook 就会注入就绪门控,Pod 只有在其目标组报告健康之后才算 Ready。
这样 kubectl rollout status 才真正是一句关于流量的陈述------而 promote.sh 正是依赖这一点。
门控是在 Pod 创建时 注入的。事后再给命名空间打标签,对已经存在的 Pod 毫无作用------
这正是 terraform/namespaces.tf 直接带标签创建两个命名空间、而不交给 Kustomize 的原因。
优雅摘流。 三个数字的大小关系必须正确,否则每次滚动都会丢在途请求:
preStop sleep (15s) < deregistration_delay (30s) < terminationGracePeriodSeconds (60s)
Pod 在 preStop 期间继续服务,同时 ALB 把它摘掉;宽限期必须比前两者都长。
ALB 级注解在组内必须一致。 group.name、scheme、listen-ports、certificate-arn、
load-balancer-attributes 描述的是共享的 那个负载均衡器。blue 和 green 只要有一项不一致,
控制器就会拒绝整个组 ------包括那条本来跑得好好的腿。本仓库里两条腿都渲染自同一个 base/ingress.yaml,
从结构上就不可能漂移,render-check.sh 还会再断言一次。
绝不允许两条腿共用同一个 order。 group.order 相同时,ALB 规则优先级是未定义的,流量会不可预测地分裂。
这就是为什么 finalize.sh 要先删掉退役的腿、再 把赢家从 1 轮转到 10,
也是为什么 promote.sh 在发现两条腿都已经在前排时会拒绝启动。
十、可观测性门禁
刻意分成两层。
常设告警 (terraform/observability.tf)监控整个负载均衡器:HTTPCode_Target_5XX_Count、
HTTPCode_ELB_5XX_Count、p95 TargetResponseTime、UnHealthyHostCount,
可选接到 SNS(Simple Notification Service,简单通知服务)。
这里有个顺序上的小疙瘩,值得直说:ALB 不是 Terraform 创建的 ,是控制器响应第一个 Ingress 时创建的。
所以在全新环境的首次 apply 时它还不存在,告警也就无从引用,因此 enable_alarms 默认是 false,
等 Ingress 起来之后再做第二次 apply 打开它。这是有意为之的两阶段 apply,不是疏漏。
按腿对比 放在 finalize.sh 里,因为 ALB 整体的指标无法告诉你新版本 本身是否有问题。
脚本通过控制器在该命名空间创建的 TargetGroupBinding CRD(Custom Resource Definition,自定义资源定义)
找到已提升那条腿的目标组,再按目标组粒度拉取观察期内的指标:
bash
./scripts/finalize.sh blue --soak-minutes 30 --max-error-rate 0.1 --max-p95 0.8
TARGET GROUP REQUESTS 5XX p95(s)
k8s-blue-ordersap-a1b2c3d4e5 14032 3 0.211
k8s-blue-payments-f6g7h8i9j0 8814 0 0.402
k8s-blue-catalogap-k1l2m3n4o5 21190 1 0.147
==> requests=44036 5xx=4 error_rate=0.0091% worst p95=0.402s
ok soak gate passed
门禁不通过时,脚本拒绝让老腿退役,并提示你回滚------因为老腿还热着,这个建议是可执行的。
另外,零流量也会被判不通过:一个没有任何请求的观察窗口什么都证明不了,却是最容易自欺的方式。
十一、这套设计不能给你什么
把边界说清楚:
- 它不是金丝雀。 流量是 0% → 100% 一步到位。ALB 的加权目标组可以做百分比切分,
但那是另一套机制、另一组失败模式,而且与"回滚只是改一个规则条件"这个性质不兼容。 - 两条腿都要付钱。 从部署到收尾之间,你在跑双份容量。在 Fargate 上这是按秒计费、能被量化的成本,
这也是"观察期要有纪律、而不是无限期"的一个很好的理由。 - 数据库迁移依然是你自己的问题。 blue 和 green 共用同一个数据存储。迁移必须至少向后兼容一个版本
------采用 expand/contract(先扩后收),绝不要把破坏性变更和依赖它的代码放在同一次发布里。
否则回滚 Pod 就等于回滚到一个已经不存在的表结构上。 - 有状态与长连接切不干净。 已经建立在老腿上的 WebSocket、SSE 连接会一直留在那里,直到自己断开。
group.order是一个很小的整数空间 (−1000 到 1000),并且被组内所有成员共享。
两个槽位对蓝绿来说绰绰有余,但它不是一个通用的路由分层机制。
十二、怎么跑起来
源码:https://github.com/geekchow/blue-green-eks-fargate-alb
bash
git clone https://github.com/geekchow/blue-green-eks-fargate-alb.git
cd blue-green-eks-fargate-alb
# 1. 平台
cd terraform
cp terraform.tfvars.example terraform.tfvars # 填 hosted_zone_name、live_host、lp_host
terraform init && terraform apply
eval "$(terraform output -raw configure_kubectl)"
# 2. 把清单指向真实的证书与主机名
# (terraform output certificate_arn / live_host / lp_host -> k8s/base/ingress.yaml)
./scripts/render-check.sh
# 3. 建立在位腿
kubectl apply -k k8s/overlays/green-incumbent
curl -s https://api.example.com/orders # {"api":"orders","leg":"green",...}
# 4. ALB 已存在,打开告警
terraform apply -var enable_alarms=true
# 5. 发布
./scripts/promote.sh blue --stop-after-proving # 手工确认那个"分叉"状态
./scripts/promote.sh blue # 切换
./scripts/rollback.sh blue # 需要时------立即生效
./scripts/finalize.sh blue # green 退役,槽位轮转
# 6. 验证这个循环可以重复
# 修改 k8s/legs/green/kustomization.yaml 里的 APP_VERSION
./scripts/promote.sh green
在把这套东西用到生产之前,第 6 步是最值得先跑一遍的------它恰恰是两状态版本做不到的那一步。
十三、小结
最初的直觉------把新腿限制在验证域名上、用规则优先级把它挡在前面、然后解除限制------完全正确。
它之所以成立,是因为它把开关放在了唯一一个既即时生效 又可被观测的地方:ALB 的监听器规则表。
这份实现补上的,是让它从"一次性事件"变成"一个可持续流程"的部分:
第三个角色,让两种颜色可以无限次互换槽位;一个明确宣告"你将失去回滚目标"的收尾步骤;
就绪门控,让"已滚动完成"真的等于"正在承接流量";以及一个读取按腿指标、而不是盲信绿色滚动状态的观察期门禁。
本文提到的所有内容都在同一个仓库里
------https://github.com/geekchow/blue-green-eks-fargate-alb ------
terraform/ 是平台,k8s/ 是状态机,scripts/ 是操作手册,
render-check.sh 负责在持续集成里让六个 overlay 保持诚实。