Lambda Web Functions as queue consumers
Lambda Web Functions bill for the CPU you actually use, and queue consumers spend most of their time waiting on something else. So is an SQS consumer cheaper as a Web Function than as a regular Lambda function?
This is the last of three posts about Lambda Web Functions, which
AWS launched a few hours ago. I got too excited and wrote far more than I
normally would, so the first post covers the platform and the
second covers running Go on it via execve().
Setup¶
The launch post says Event Functions are "ideal for event processing, queue consumers, and invocations triggered by other AWS services", and Web Functions are for HTTP. That's a statement about the programming model, not about billing. A queue consumer that spends 480ms of every 500ms waiting on a downstream API is exactly the workload that concurrent requests per environment and active-CPU billing would help.
Nothing stops you from (indirectly) pointing a queue at an HTTP endpoint. EventBridge Pipes will poll SQS, Kinesis, DynamoDB Streams or Kafka for you and deliver each record to an API destination, which is just an HTTPS URL plus a connection that holds credentials. So I tried it.
For these tests, the consumer is just a dummy: it does some CPU work for 15ms and then sleeps for 480ms. The CloudFormation looks something like:
Connection:
Type: AWS::Events::Connection
Properties:
AuthorizationType: API_KEY
AuthParameters:
ApiKeyAuthParameters:
ApiKeyName: x-api-key
ApiKeyValue: !Ref ApiKey
ApiDestination:
Type: AWS::Events::ApiDestination
Properties:
ConnectionArn: !GetAtt Connection.Arn
HttpMethod: POST
InvocationEndpoint: !Sub "https://${ConsumerEndpoint.DomainName}/event"
InvocationRateLimitPerSecond: 300
Pipe:
Type: AWS::Pipes::Pipe
Properties:
RoleArn: !GetAtt PipeRole.Arn
Source: !GetAtt Queue.Arn
SourceParameters:
SqsQueueParameters:
BatchSize: 1
Target: !GetAtt ApiDestination.Arn
TargetParameters:
InputTemplate: '{"messageId": <$.messageId>, "body": <$.body>}'
HttpParameters:
HeaderParameters:
x-sqs-message-id: $.messageId
Some notes:
- Connections only do API key, basic and OAuth,
not IAM, so the endpoint has to be
ApplicationManagedand your app has to check the shared secret header itself. That's admittedly not great. - API destinations aren't a batchable target, so
BatchSizemust be 1 and every message is its own HTTP request. - If you write
InputTemplate: <$.body>to pass the message through untouched, you get a mangled body:$.bodyis a string, and EventBridge substitutes string values with their JSON quoting removed, so{"n":42}arrives as the unparseable{n:42}. Wrapping it in an object avoids that.
Then I put 2,000 messages each on queues being consumed by a Web Function and two Event Functions: one at 128MB and one at 1,769MB (one full vCPU, so the CPU work runs at full speed). All three drained.
The event functions each spun up ~160 environments, hit 164
concurrent executions and kept pace with my send-message-batch loop,
finishing in 31 seconds.
The Web Function, with no ScalingConfig at all, kept up: it served all 2000
requests in 35 seconds across ten environments each at 14-15 concurrent requests
with no issues. However, when I set maxEnvironments: 4 it returned 92 429
errors and the EventBridge Pipe had to retry those messages. The requests were
shed with 429 Platform.EndpointMaxEnvThrottle while the per-environment limit
dynamically adjusts its concurrency.
I guess the platform decides to err on the side of over-provisioning when there's
a sudden burst of traffic only if there's no max environment count set. I think
for my purposes I will rarely set a limit.
Costs¶
The data comes from the platform's own metering: REPORT lines for the event functions, and
platform.invokeSummary CPU and memory for the Web Function, which meters
about 20ms of CPU per message (my code measures 15ms; the rest is HTTP and
logging) and about 200MB per environment.
For the Web Function I've assumed an environment handling 14 concurrent 500ms requests (because this is what the platform settled on during my tests) so 28 messages per second share that 200MB. Prices are us-east-1 arm64. I've ignored free tier and the cost of SQS, because that applies equally to both.
| per million messages | 128MB event function | 1,769MB event function | Web Function via Pipes |
|---|---|---|---|
| duration | 749ms (CPU-starved) | 505ms | ~500ms, 20ms of CPU |
| compute | $1.26 | $11.81 | $0.55 CPU + $0.03 memory |
| requests | $0.20 | $0.20 | $0.30 |
| Pipes + API destination | $0.40 + $0.20 | ||
| total | $1.46 | $12.01 | $1.48 |
So against the smallest possible event function it's a dead heat, except that the 128MB function takes 50% longer per message because it's getting 7% of a vCPU. Against one with a full vCPU the Web Function is 8× cheaper. A more realistic 512MB event function lands at about $3.80 per million (estimated). And the gap widens with I/O time: make the downstream call take 2 seconds instead of 480ms and the 128MB event function goes to about $4, 512MB to $14 and one vCPU to $48, while the Web Function goes to about $1.56, because the extra time is just more requests sharing the same 200MB. $0.60 of the Web Function's $1.48 is plumbing; the compute itself is $0.58.
Whether this is a good idea is a different question. You lose the 15-minute timeout (API destinations give up after 5 seconds), you lose batching, you lose partial-batch failure reporting, you're capped at 300 invocations per second per API destination unless you raise the quota, and you have to rely on bearer tokens instead of Sigv4. But for a high-volume consumer doing short, I/O-bound work, it's a lot cheaper. I suspect AWS is never going to launch event-source mappings for Web Functions (because then what would Event Functions be for?), so this will never be a good fit, and that feels like a shame to me.