force-new-deployment 不会让新配置生效:ECS 节点 CPU 预留打满之后
缘起
我们 prod 的 ECS 集群是 EC2 启动类型,四台节点,跑着十几个 Java 微服务。
某天发现有两个 task 一直起不来,原因是四台机器的 CPU 都被预留满了。为此提了一个 PR,把 prod 所有服务 task definition 里容器的 cpu 减半,已经合进了 DevOps 仓库的 main。
问题是,按我们的流程,task definition 的改动要走 CD 才能生效。而 CD 是从 dev 开始,经过 qa、preprod,最后才到 prod。这次又涉及所有服务,一个个从 dev 推到 prod,太慢了。
于是我的第一反应是:能不能直接用命令行,对 prod 的服务来一次 force-new-deployment?
答案是:命令行可以,但 force-new-deployment 不行。
先看现场
动手之前,先只读地看了一下集群的状态。
1 | aws --no-cli-pager ecs describe-container-instances \ |
结果:
- 四台节点注册的 CPU 都是 4096,剩余都是 0。内存还有余量。
- 卡在
PROVISIONING的两个 task,都是 Availability Zone rebalancing 发起的:gateway-service(CPU 512)和domain-report-service(CPU 256)。这两个服务各有 2 个 task 在跑,但都挤在1b,rebalancing 想往1a补一个,补不进去。 - 线上所有服务的 CPU 还是减半之前的值。其中最大的是
job-portfolio-service,每个 task 2048,1a和1b各一个。 - 所有服务的部署策略都是
minimumHealthyPercent=50、maximumPercent=200,开了 circuit breaker。
第一个误区:force-new-deployment 只会按旧配置再来一遍
ECS service 指向的是某一个具体的 task definition revision,比如 <family>:8。仓库里的 JSON 文件,ECS 根本不知道它的存在。
我们的 CD 做的事情是:
- 用仓库里的 JSON 渲染出新的 task definition(填进镜像地址等);
register-task-definition,得到一个新 revision;update-service,把服务指向这个新 revision。
而 update-service --force-new-deployment 的作用,只是让服务用当前的 task definition 重新拉起一批 task。它不会去读仓库,也不会产生新 revision。对这次的场景来说,它只会按旧的 CPU 值把 task 再起一遍,什么问题都解决不了。
所以命令行要做的,其实是手工把 CD 的第 2、3 步走一遍。
这里还有一个细节:不能直接拿仓库里的 JSON 去注册。我们仓库里的 task definition,镜像地址是 {{IMAGE_URI}} 这样的占位符,由 CD 在部署时填入。直接注册的话,镜像就错了。
正确的做法是以线上正在跑的 revision 为底,只改 cpu:
1 | aws --no-cli-pager ecs describe-task-definition \ |
describe-task-definition 返回的内容里,有几个只读字段(taskDefinitionArn、revision、status、registeredAt 等)不能原样传回 register-task-definition,要先用 jq 删掉。这样注册出来的新 revision,镜像、环境变量、角色都和线上一致,只有 CPU 变了。
第二个误区:滚动部署本身也要空间
新 revision 有了,下一步是 update-service 把服务切过去。
但这里又有一个问题:部署策略是 200/50,意思是 ECS 会先多起一份新 task,等它健康了再停旧的。可现在四台机器剩余 CPU 全是 0,新 task 一样放不进去。放不进去的结果,要么一直卡着,要么触发 circuit breaker 回滚。
节点资源打满的时候,滚动部署本身就是需要额外空间的。
我原本的打算是临时把策略改成 maximumPercent=100、minimumHealthyPercent=50。对于 desired 为 2 的服务,这意味着先停一个旧 task,再起一个新 task,不需要额外的 CPU。滚完再改回 200/50。
顺序上,先滚 job-portfolio-service:它从 2048 降到 1024,停掉 1a 上那一份,就能腾出足够的 CPU,让那两个卡住的 rebalancing task 放进去。
结果 update-service 直接被拒了:
1 | An error occurred (InvalidParameterException) when calling the UpdateService operation: |
开了 Availability Zone rebalancing 的服务,不允许 maximumPercent 小于或等于 100。整个请求被拒绝,服务没有任何变化,只是新 revision 已经注册好了,还没挂上去。
要绕过去,就得先关掉 rebalancing,再改成 100/50 切 revision:
1 | aws --no-cli-pager ecs update-service \ |
等稳定之后再恢复。注意恢复的时候,部署策略和 rebalancing 要在同一次调用里改回去。如果先打开 rebalancing,此时 maximumPercent 还是 100,就会再撞一次上面那个错:
1 | aws --no-cli-pager ecs update-service \ |
转折:问题自己松开了
不过,这套绕法最后没用上。
在准备给另外两个服务关 rebalancing 之前,我感觉好像已经没有 pending 的 task 了,于是又看了一眼现场。现场已经变了:
- 那两个卡在
PROVISIONING的 task 已经停掉了,原因是TaskFailedToStart: RESOURCE:CPU; job-portfolio-service已经切到了新 revision,部署完成,rebalancing 还是开着的,策略还是 200/50;- 四台机器的剩余 CPU 变成了 256、0、768、1024,加起来正好 2048,就是
job-portfolio-service两个 task 各从 2048 降到 1024 腾出来的; gateway-service和domain-report-service也已经是1a、1b各一个,AZ 均衡了。
也就是说,只要最大的那个服务降下来,腾出的空间就够后面的服务按正常的 200/50 滚动了。剩下的事情,就是把其余 15 个服务一个个切到减半后的 revision。
剩下 15 个服务:一个串行脚本
最后写了一个脚本,逻辑很简单,但有几个点是刻意设计的:
- 写目标值,不写”除以 2”。 每个服务的目标 CPU 直接写死在脚本里。这样脚本中途失败、修好后整段重跑,已经切过去的服务不会被再减一半。
- 已经达标的服务跳过。 先读线上当前 revision 的 CPU,等于目标值就跳过。
- 以线上 revision 为底。 原因前面说过,仓库里的 JSON 有占位符。
- 每个服务都等稳定。
aws ecs wait services-stable。 - 检查有没有被回滚。 circuit breaker 回滚之后,服务也会”稳定”,只不过稳定在旧 revision 上。所以等完之后,再确认服务当前的 revision 是不是刚注册的那个。
- 串行,失败即停。 不并行,免得几个服务的新 task 同时去抢那 2048 的 CPU。
1 | set -euo pipefail |
顺序按 CPU 从大到小排,先处理腾空间最多的服务。desired 为 0 的服务,切 revision 只是改个指针,不会真的起 task。
每个 desired 为 2 的服务大约要几分钟:容器健康检查的 startPeriod 是 120 秒,旧 task 还要等连接 drain 完。15 个服务串行跑下来,比从 dev 一路推 CD 到 prod 还是快得多。
收尾
还有两件事。
第一,Terraform 不会出现 drift。我们 prod 的 ECS service 是 Terraform 建的,但设置了忽略 task definition 的变化(lifecycle { ignore_changes = [task_definition, desired_count] }),task definition 由 CD 负责。所以手工切 revision,Terraform 那边看不出任何差异。
第二,仓库要和线上保持一致。CPU 减半的 PR 已经合进了 main,下次这些服务正常走 CD,注册出来的 revision 和现在手工注册的 CPU 是一致的。另外我们还有一份灾备区域用的 task definition,约定是改 prod 的时候同步改它,这次也补了一个提交。
多说几句
这次有几点值得记下来。
第一,force-new-deployment 不等于”重新部署最新配置”。 它只是用当前 revision 重新拉起 task。适合的场景是:镜像 tag 没变但镜像内容变了(比如用了 latest),或者想让 task 重新读一次 Secrets Manager、S3 上的环境变量文件。想让 task definition 的改动生效,必须注册新 revision,再 update-service 指过去。
第二,EC2 启动类型下,容器的 cpu 是调度用的预留值。 集群”满了”,指的是预留满了,不代表机器的 CPU 真的被用光。ECS 调度时只看预留:剩余预留放不下,task 就起不来。这也是为什么这次把预留减半,就能直接解决问题。至于预留该设多少,要结合实际使用量来定,那是另外一件事。
第三,节点资源打满的时候,滚动部署本身也需要空间。 200/50 的策略意味着先加后减,没有余量就滚不动。要么平时留一点余量,要么让集群能自动扩容,要么像这次一样,先挑一个能腾出最多空间的服务下手。
第四,开了 Availability Zone rebalancing 的服务,maximumPercent 必须大于 100。 “先停后起”这条路,对这类服务是走不通的,除非先把 rebalancing 关掉。恢复的时候,部署策略和 rebalancing 要在同一次调用里改。
第五,绕过 CD 不可怕,可怕的是绕完之后仓库和线上对不上。 这次能放心地直接改 prod,前提是:改动本身已经在仓库里合并了;新 revision 以线上为底,只改了一个字段;Terraform 不管 task definition。手工操作只是让本来就会发生的事情提前发生。