I/O密集型任务CPU几乎不动但队列积压严重,应使用backlog per task(队列深度/运行中任务数)作为扩缩容指标,而非CPU利用率。
一个 Worker 在每个 Job 中有 90% 的时间都在等待模型 API,此时它只占用 5% 的 CPU,而队列中却堆积了一万条消息。以 CPU 利用率为目标进行跟踪的策略看到这种情况会选择缩容。
目标跟踪的原理是让一个指标始终保持在某个值附近,就像恒温器控制温度一样。要让这种机制产生正确的行为,指标必须在服务跟不上时上升。对于 CPU 密集型服务,CPU 利用率确实会这样做,但对于 I/O 密集型服务,它做的恰恰相反:往一个等待网络请求的任务堆里添加任务,几乎不会改变平均 CPU 占用率,于是策略判断已经做得够多了,队列以全速继续增长,而所有监控面板都一片绿灯。
仅靠队列长度也不能解决问题,因为它没有单位,策略无法据此行动。一万个消息积压在一个任务后面是一场灾难;一万个消息积压在五百个任务后面则是一个普通的周二。真正承载意义的是比率,AWS 将其文档化为 backlog per task(每个任务的积压量)。
这个定义和字面意思完全一致:可获取的消息数除以处于 RUNNING 状态的任务数。Application Auto Scaling 在自定义指标规格中支持 CloudWatch 指标数学运算,所以除法在策略内部完成,你无需自己发布任何指标。AWS 精确指定了两个输入:
ApproximateNumberOfMessagesVisible,位于 AWS/SQS 命名空间,按 QueueName 维度,使用一分钟内的 Sum 统计。
RunningTaskCount,位于 ECS/ContainerInsights 命名空间,按 ClusterName 和 ServiceName 维度,使用一分钟内的 Average 统计。
第二个指标有一个前置条件,很多人因此耗费了大半个下午:RunningTaskCount 来自 Container Insights,而它默认不开启。如果集群上没有启用这个指标,指标就不存在,表达式返回空数据,策略创建的告警会处于 INSUFFICIENT_DATA 状态,什么都不做——这看起来和策略决定不行动一模一样。用以下命令启用它:
aws ecs update-cluster-settings --cluster app --settings name=containerInsights,value=enabled
然后用以下命令确认指标已出现:
aws cloudwatch list-metrics
之后再编写策略。
首先将服务注册为可扩展目标,然后附加策略。ECS 服务的可扩展维度是 ecs:service:DesiredCount,资源 ID 是 service/cluster/service 三段式:
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--scalable-dimension ecs:service:DesiredCount \
--resource-id service/app/embed-worker \
--min-capacity 1 --max-capacity 40
指标数学运算写在一个 JSON 文件里。两条原始指标的 ReturnData 都设为 false;只有表达式返回数据,因为目标跟踪规格必须解析为单一时间序列:
{
"CustomizedMetricSpecification": {
"Metrics": [
{
"Id": "m1",
"Label": "Messages waiting to be processed",
"MetricStat": {
"Metric": {
"MetricName": "ApproximateNumberOfMessagesVisible",
"Namespace": "AWS/SQS",
"Dimensions": [
{ "Name": "QueueName", "Value": "embed-jobs" }
]
},
"Stat": "Sum"
},
"ReturnData": false
},
{
"Id": "m2",
"Label": "Running tasks",
"MetricStat": {
"Metric": {
"MetricName": "RunningTaskCount",
"Namespace": "ECS/ContainerInsights",
"Dimensions": [
{ "Name": "ClusterName", "Value": "app" },
{ "Name": "ServiceName", "Value": "embed-worker" }
]
},
"Stat": "Average"
},
"ReturnData": false
},
{
"Id": "e1",
"Label": "Backlog per task",
"Expression": "m1 / m2",
"ReturnData": true
}
]
},
"TargetValue": 20,
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}
aws application-autoscaling put-scaling-policy \
--policy-name embed-backlog-per-task \
--service-namespace ecs \
--scalable-dimension ecs:service:DesiredCount \
--resource-id service/app/embed-worker \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration file://config.json
调用成功会返回策略 ARN 以及 Application Auto Scaling 创建并拥有的两条 CloudWatch 告警的 ARN——AlarmHigh 和 AlarmLow。不要手动编辑它们,它们是从策略重新生成的。AWS 还文档化了使用指标数学运算时 PutScalingPolicy 负载的 50 KB 上限,这只在表达式覆盖多个队列时才会变得重要。
目标值不是口味问题,它来自延迟预算和实测的每条消息服务时间。两个假设,都应该替换成你自己的数字:
假设 1 —— 最旧消息的可接受年龄是 120 秒。
假设 2 —— 一个任务持续处理一条消息需要 6 秒,包括其上游的模型调用。
一个任务在自己的 backlog_per_task × seconds_per_message 时间内清空自己的那部分积压。令其等于预算,得到 target = 120 / 6 = 20 messages per task,这就是上面策略中的数字。公式的形态才是有用的部分:目标值与每条消息的耗时成反比,所以如果模型变更使每条消息的时间翻倍,目标值必须减半,否则队列年龄会在无声中翻倍。这是该策略带来的维护义务,也是为什么这个常数应该和推导过程放在同一个文件里。
测量分母而不是估算它。如果每个 Job 的服务时间已经被记录(你应该发送它),p50 就是这里需要的数字——p99 会让服务为一个本该用重试和超时处理的尾部请求而扩容。
表达式 m1 / m2 除以运行中的任务数,所以零任务的服务不会产生可用值,策略无法自行恢复——它需要的指标是它正试图改变的东西的函数。两条出路,但它们不等价。保持 --min-capacity 1 是简单的一个:一个预热任务有一点成本,但让分母永远不为零。如果你确实需要归零,将目标跟踪策略与原始队列深度告警的步进缩容策略配对,这样ratio 之外的东西可以把第一个任务放回去。
两个冷却时间在示例中故意设置得不对称。扩容 60 秒,因为不断增长的积压是一个会复利的问题;缩容 300 秒,因为拆除一个正在处理任务的 Worker 会让你丢失该任务的进度,而且如果正处在一个模型调用中,还会丢失你已经付过费的 Token。将较长的缩容冷却时间与 ECS_CONTAINER_STOP_TIMEOUT 以及一个 SIGTERM 处理器配对,该处理器停止接收新消息并完成当前消息。没有这个处理器,自动扩缩容会在无声中将成本节省转化为重复工作,因为 SQS 会重新投递每个可见性超时到期但未被确认的消息。
还有一个值得故意设置的不对称:SQS 可见性超时应该比你最坏情况下的每条消息处理时间多出明显的余量。如果一次模型调用偶尔在 30 秒可见性超时时需要 90 秒,会产生一个在负载下看起来不断增长的队列,而实际上它在每次把同一条消息处理三遍。
Deploying Inference on AWS Fargate
Priority Queues: Free Users Wait, Paying Users Do Not
Autoscaling on GPU: Metrics That Actually Work