📋 In This Post
Lab Overview
A courier charges by the parcel. You send a hundred parcels, you pay for a hundred. Then you discover the invoice is by weight, and the hundred envelopes you sent last month cost rather less than the hundred boxes you sent this month.
Nothing about the count changed. The count was never the thing being charged for.
The problem. Capacity conversations about a cache start with requests per second, because that is the number on every dashboard. ElastiCache Serverless bills in ECPUs, and an ECPU is charged per kilobyte moved. The request rate and the bill are related only through a quantity nobody is looking at.
The objective. Issue the same number of requests three times, against values of 1 KB, 10 KB and 100 KB, and read the billed ECPUs for each from CloudWatch.
The outcome. 20,000 requests billed 20,803, 200,830 and 2,000,960 ECPUs — a 96× spread with the request count held constant. The byte model predicts all three to within 4%, which is the point: the model is sound and the request count is the useless number.
| Difficulty | Beginner to intermediate |
| Measured cost | $0.078 from Cost Explorer, of which $0.065 was the idle storage floor |
| Deployment | Terraform via HCP Terraform with OIDC dynamic credentials |
| Region | us-east-1 — a serverless cache is reachable only from its own VPC |
| Full lab guide | GitHub — prerequisites, Terraform, commands, validation, cleanup |
Cloud Services and Concepts
| Service | Purpose here | What it is |
|---|---|---|
| Amazon ElastiCache Serverless | The cache under test, and the thing doing the billing | A managed in-memory cache with no nodes to size. You are charged for data stored and for the work your requests do, rather than for a server by the hour. |
| Valkey | The engine — version 9.0, the current default | An open-source key-value store, forked from Redis in 2024 and now the engine AWS prices lowest. Same commands, same clients. |
| AWS Lambda | The load generator, inside the VPC because the cache has no public endpoint | Runs code on demand with no server to manage. Attaching it to a VPC is what lets it reach private resources. |
| Amazon CloudWatch | ElastiCacheProcessingUnits — the only figure here that is a measurement rather than a calculation |
AWS's metrics service. Most services publish numbers into it without being asked. |
| Amazon VPC | Required — a serverless cache is reachable only from inside one | A private network inside your account. Nothing in it is reachable from the internet unless you arrange it. |
| HCP Terraform | Remote applies using OIDC, so no AWS keys are stored anywhere | HashiCorp's hosted Terraform. It runs plans and applies on its own machines and keeps the state file. |
An ECPU is the higher of two numbers
AWS defines it in two parts. A simple read or write "requires 1 ECPU for each kilobyte (KB) of data transferred". A command that needs more processing consumes proportionally more. And when both apply, "ElastiCache calculates the ECPUs required based on the higher of the two dimensions" — not the sum.
For a plain GET the byte dimension wins, which makes the
arithmetic simple and the budgeting counter-intuitive: a 100 KB value
costs a hundred times a 1 KB value to read, and the request counter
shows the same number either way.
Valkey's floor is 100 MB, not 1 GB
Storage is billed per GB-hour with a minimum, and the minimum differs by engine: 100 MB for Valkey against 1 GB for Redis OSS and Memcached. At $0.084/GB-hour that is about $6.13 a month for a cache that holds almost nothing, which is the figure behind AWS's "starts at $6/month". The same page says there is "no minimum fee". Both statements are true: there is no fee floor, but there is a storage floor.
Architecture and Workflow
- 20,000 reads of 1 KB values. The byte dimension implies 20,000 ECPUs.
- 20,000 reads of 10 KB values. Same request count, ten times the bytes.
- 20,000 reads of 100 KB values. Same request count again, a hundred times the bytes.
Each run writes its value once, then reads it back 20,000 times sequentially. A cache hit is the cheapest thing the service does, so this is the best case for a request-based estimate — and it still misses by two orders of magnitude.
Prerequisites
| Requirement | Why | How to check |
|---|---|---|
| Terraform ≥ 1.10 | cloud {} block and AWS provider v6 | terraform version |
| HCP Terraform workspace, CLI-driven | The Lambda package is built locally and uploaded with the configuration | terraform init |
| AWS permissions for ElastiCache, Lambda, VPC, IAM | The apply creates all four | aws sts get-caller-identity |
| Python 3.11+ with pip | Builds the package — valkey-py is not in the Lambda runtime | python --version |
| AWS CLI v2 | Teardown is verified against the API | aws --version |
Implementation
What was deployed
| Resource | How many | Its job |
|---|---|---|
| ElastiCache Serverless cache | 1 | Valkey 9.0, storage capped at 2 GB and 15,000 ECPUs/second |
| Lambda function | 1 | The load generator, VPC-attached |
| VPC and subnets | 1 / 2 | Two AZs, because the cache requires a subnet in more than one |
| Security groups | 2 | Client egress only; cache accepts 6379 from the client alone |
| IAM role and policy | 1 / 1 | Logs, plus the EC2 permissions Lambda needs to build its VPC network interface |
| CloudWatch log group | 1 | 7-day retention |
Ten resources, applied in about seven minutes.
The one thing that is not obvious in the Terraform
Encryption in transit is not a choice. AWS: "If you are using ElastiCache Serverless, encryption in transit is always enabled", and it cannot be turned off. The client therefore has to speak TLS or it will not connect at all:
_client = valkey.Valkey(host=HOST, port=PORT, ssl=True,
ssl_cert_reqs=None, socket_timeout=10)
Port 6379 is the primary endpoint. 6380 is a read-optimised reader endpoint, left closed here so the measurement has one path.
Validation and Evidence
Three runs, 20,000 reads each, separated by five idle minutes so no
measurement window overlaps another. Billed ECPUs are read from
CloudWatch's ElastiCacheProcessingUnits after every run has
finished and published.
| Test | Expected | Actual | Status |
|---|---|---|---|
| 20,000 reads at 1 KB | ~20,000 ECPUs | 20,803 billed | ✅ |
| 20,000 reads at 10 KB | ~200,000 ECPUs | 200,830 billed | ✅ |
| 20,000 reads at 100 KB | ~2,000,000 ECPUs | 2,000,960 billed | ✅ |
| Spread at constant request count | about 100× | 96× | ✅ |
BytesUsedForCache | under the 100 MB floor | a few hundred KB | ✅ |
Security and Cost
What this means for a platform team
Size a cache from payload, not from request rate. The request-per-second figure on the dashboard is the number everyone quotes in a capacity review, and on ElastiCache Serverless it carries almost no information about cost. Two services with identical traffic profiles can differ by two orders of magnitude on the invoice because one of them caches rendered HTML and the other caches integers.
The practical control is the value itself. Caching a 100 KB serialised object where a 1 KB identifier would do is not a style preference at this price; it is the dominant term.
On security, two things are worth copying regardless of engine: the cache has no public endpoint and cannot be given one, and encryption in transit is mandatory and cannot be disabled. Neither needed configuring — which is the right default for a datastore.
Cost
| Line | Rate (us-east-1, verified 13 November 2026) |
|---|---|
| Storage | $0.084 per GB-hour, minimum 100 MB billed for Valkey |
| ECPUs | $0.0023 per million |
| VPC | Free — no NAT gateway, the client sits with the cache |
Measured: $0.078 from Cost Explorer, for a cache that existed about thirteen hours. Cost Explorer splits it in a way worth reading closely:
| Usage type | Quantity | Cost |
|---|---|---|
CachedData:Valkey | 0.773 GB-hours | $0.0649 |
ElastiCacheProcessingUnits:Valkey | 5,633,206 ECPUs | $0.0130 |
Storage was 83% of the bill, and almost all of that was the 100 MB floor on a cache holding a few hundred kilobytes.
The ECPU figure is larger than this experiment: the three measured runs account for 2.2 million of those 5.6 million, with the rest from earlier attempts that were discarded. Following this lab once costs about half a cent in ECPUs, plus the storage floor for as long as the cache exists.
That ratio is the lesson in miniature. The workload everyone would have budgeted for cost a fifth of the idle floor nobody would have thought to budget for.
Troubleshooting and Lessons
Problem. The first run died with
ReadTimeoutError: Read timeout on endpoint URL while invoking
the Lambda.
Root cause. Two layers. boto3 stops waiting for a
response after 60 seconds by default, well short of the function's
300-second timeout; and 200,000 sequential round-trips genuinely exceeded
that 300 seconds. CloudWatch recorded Duration: 300,000 ms
and one error.
Resolution. An explicit read_timeout on the
boto3 client, and 20,000 requests rather than 200,000.
Lesson. A serverless cache answers in about a
millisecond and a loop of 200,000 of them does not finish in five
minutes. Per-operation latency and total runtime are different
questions.
Problem. The first measurement reported 10 KB
values billing fewer ECPUs than 1 KB values.
Root cause. The CloudWatch windows were five minutes
wide and overlapped each other, so each figure included a neighbouring
run. A second attempt fixed the overlap but still queried too early and
caught only the first of the two minutes each run spans.
Resolution. Idle gaps between runs, and the measurement
taken after all three have finished publishing.
Lesson. Bytes moved set a floor on ECPUs, so a billed
figure below that floor is impossible rather than surprising. The runner
now refuses to print any result below it, which is what caught both
attempts.
Problem. The remote apply failed with
No valid credential sources found.
Root cause. The workspace is created on first
terraform init and starts with no variables, so an HCP run
has no way to reach AWS.
Resolution. Set TFC_AWS_PROVIDER_AUTH and
TFC_AWS_RUN_ROLE_ARN on the workspace, which is how every
other week in this series authenticates.
Lesson. A new workspace is not a configured workspace,
and the error names the symptom rather than the cause.
Cleanup and Key Takeaways
Teardown is verified against the API rather than the destroy output — a destroy reports what Terraform removed, not what is left in the account. The cache and the function are both checked by name.
Key takeaways
Request count is the wrong input
20,000 requests billed 20,803 or 2,000,960 ECPUs depending only on value size. Capacity conversations that start with requests per second start in the wrong place.
Payload size is the design decision
At 1 ECPU per kilobyte, what you choose to put in the cache is the dominant cost term. Caching an identifier instead of a rendered object is a pricing choice.
"No minimum fee" is not "no floor"
Valkey bills a minimum 100 MB of storage — about $6.13 a month — against 1 GB for Redis OSS. For a small cache that minimum matters more than the headline discount.
A measurement that is impossible is not a measurement
Bytes moved set a floor on ECPUs. Knowing that floor is what exposed two bad measurement windows that otherwise produced plausible-looking tables.
References
- The full lab guide
- Amazon ElastiCache pricing — ECPUs, and the minimum billed storage per engine
- Metrics for Valkey and Redis OSS serverless caches —
ElastiCacheProcessingUnits - Getting started with ElastiCache Serverless — TLS is always on
- Terraform —
aws_elasticache_serverless_cache - valkey-py — the client, not present in the Lambda runtime
Comments