the-mgi/notes

Taking over 330 GB a day of uncompressed HTML off the bill

8 min readawskubernetesnetworkingcost
TL;DR

The cluster was sending 333.18 GB a day out to the CDN, about $912 a month at $0.09 a GB, and none of it was compressed. One gzip block on the gateway route took that to 83.13 GB a day. The hop behind it — app pod to gateway pod — was still carrying the full page, and it crosses an availability zone about two thirds of the time at $0.01 a GB in each direction. Three lines in the shared HTTP server took that off too. The two changes together are $786 a month, and node CPU went down rather than up.

In the five days to 21 August the cluster sent 333.18 GB a day out to the internet. At $0.09 a GB that is $29.99 a day, near enough $912 a month. It was the largest single line on the bill.

None of it was compressed.

The sites are server-rendered HTML behind a CDN. The CDN compresses what it sends to a reader, so pages arrive small and nothing looks broken. What leaves AWS is a different leg, and every page went out on it full size. Ask the cluster itself and it says so:

shell
# the origin hostname: DNS straight to the load balancer, CDN not in the path,
# so this is the cluster's own response rather than the edge's
$ curl -sI -H 'Accept-Encoding: gzip, br' https://origin.site-a.example.com/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Vary: Origin

No Content-Encoding. The client asked for gzip and got 86 KB of plain HTML.

Two hops, two meters

A response crosses two metered hops on its way out.

The first is inside the region: app pod to gateway pod. The gateway runs one pod per node and picks a backend pod without regard to where it is, and the load balancer spreads requests evenly over three zones, so about two thirds of that hop leaves the zone it started in. That lands on DataTransfer-Regional-Bytes at $0.01 a GB, charged leaving one zone and charged again entering the next. A GB that crosses a zone costs $0.02.

The second is out of AWS: gateway pod to the CDN, at $0.09 a GB. Nine times the rate on the inner hop, which is why it went first.

Where one page gets compressed, and what each hop costs

Until 22 August · nothing compressed

your VPCthe internetapp podrenders the page86 KBacross a zone$0.01 / GB · each waygateway podone per node86 KBout of AWS$0.09 / GBCDNthen the reader

The same 86 KB of HTML is paid for twice. Once inside the region, where it crosses a zone about two thirds of the time and the meter charges at both ends, and again on the way out of AWS.

22 August · gzip at the gateway

your VPCthe internetapp podrenders the page86 KBacross a zone$0.01 / GB · each waygateway podone per node14 KBout of AWS$0.09 / GBCDNthen the reader

The expensive meter now carries 14 KB. The gateway can only compress what it sends, so the hop behind it still carries the full page.

2 September · gzip in the app

your VPCthe internetapp podrenders the page14 KBacross a zone$0.01 / GB · each waygateway podone per node14 KBout of AWS$0.09 / GBCDNthen the reader

The app compresses once and the gateway passes those bytes straight through. Nothing is compressed twice, and what the reader receives is unchanged.

The gateway was serving everything raw

The gateway is APISIX. Its gzip plugin was not running.

A client asking for identityThe HTTP name for no encoding at all: send Accept-Encoding: identity and the response comes back uncompressed. still gets the plain body, so both columns can be measured at the origin today. The left one is what everybody used to get:

PathPlainGzippedRatio
/85,80114,25216.6%
/rankings91,22519,70921.6%
/leaderboard106,48326,60925.0%
/library83,14518,02121.7%
/stats130,21018,84714.5%
/events76,57613,47117.6%
Total573,440110,90919.3%

80.7% of the bytes, gone for a block of configuration. Server-rendered HTML repeats the same shapes thousands of times a page, and gzip is very good at that.

Two places, or it does nothing

gzip is not an add-on. It is one of 92 plugins in APISIX's own default list, and the Helm chart ships plugins: [] so that those defaults stand. This cluster had set the field, and what it set was three plugins long:

apisix/helmrelease.yaml
 apisix:
   plugins:
     - prometheus
     - traffic-split
     - serverless-pre-function
+    - gzip
That list replaces, it does not extend

The chart's own default is plugins: [], which leaves APISIX's built-in list in place — and gzip is already in it. Setting apisix.plugins replaces that list rather than adding to it, so those three were the only plugins the gateway had. Nothing warns you. The route config below still applies cleanly, reports no error, and compresses nothing.

And the route:

charts/app-deploy/templates/apisix-route.yaml
plugins:
  - name: gzip
    enable: true
    config:
      types:
        - text/html
        - text/plain
        - text/css
        - text/xml
        - application/json
        - application/javascript
        - application/x-javascript
        - application/xml
        - image/svg+xml
      min_length: 1024
      comp_level: 4
      vary: true

The settings that matter:

  • types defaults to ["text/html"] alone, which misses every JSON response. Images and fonts are left out on purpose: they are already compressed, so a second pass spends CPU to add bytes.
  • comp_level defaults to 1. This traffic is billed by the GB, so a few points of ratio are worth more than the CPU. Level 9 doubles the CPU for another one or two percent, which is not.
  • vary emits Vary: Accept-Encoding, so no cache in front can hand a gzipped body to a client that never asked for one.

What the gateway change did

It went live during the 17:00 UTC hour on 22 August. The load balancer's byte counter reads 14.2 GB in the 16:00 hour, 9.6 in the 17:00 and 3.3 in the 18:00.

Out to the internet — GB per dayGB/day
0100200300400gateway gzip15 Aug22 Aug19 Sep15 Aug — 345.4 GB/day16 Aug — 325.9 GB/day17 Aug — 348.7 GB/day18 Aug — 316.5 GB/day19 Aug — 357.1 GB/day20 Aug — 330.4 GB/day21 Aug — 313.2 GB/day22 Aug — 233.7 GB/day23 Aug — 71 GB/day24 Aug — 72 GB/day25 Aug — 69.6 GB/day26 Aug — 119 GB/day27 Aug — 84.1 GB/day28 Aug — 69.2 GB/day29 Aug — 69.2 GB/day30 Aug — 73.7 GB/day31 Aug — 93.6 GB/day1 Sep — 92.1 GB/day2 Sep — 80.5 GB/day3 Sep — 89.2 GB/day4 Sep — 81.9 GB/day5 Sep — 80.8 GB/day6 Sep — 73.9 GB/day7 Sep — 77.5 GB/day8 Sep — 69.4 GB/day9 Sep — 69.1 GB/day10 Sep — 69.7 GB/day11 Sep — 70.9 GB/day12 Sep — 70 GB/day13 Sep — 72 GB/day14 Sep — 72.5 GB/day15 Sep — 74.4 GB/day16 Sep — 71.4 GB/day17 Sep — 73.9 GB/day18 Sep — 67.5 GB/day19 Sep — 67.4 GB/day

Cost Explorer daily usage for DataTransfer-Out-Bytes, 15 August – 19 September 2026. The gateway plugin went on during the 17:00 UTC hour on 22 August, so that day is part old and part new.

WindowOut to the internet
17–21 August (before)333.18 GB/day
23–27 August (after)83.13 GB/day
Difference250.05 GB/day

250.05 GB a day at $0.09 a GB is $22.50 a day. Over an average month of 30.4 days, $684.

That sum is no check on itself: a flat-rate meter's cost is its volume restated.

The check is the load balancer, which counts bytes and requests separately and knows nothing of the bill. Requests did not fall: 12.96 million a day before, 13.52 million after. Bytes per request went from 26.6 KB to 6.4 KB. Traffic did not fall. The responses got smaller.

The first 100 GB a month is free

Egress is free for the first 100 GB each month, across all regions together, which is why the 1st of a month can bill $0.00 for 92 GB. Keep the first days of a month out of a before-and-after window or the free tier reads as a saving.

The hop behind it

The gateway now sends 14 KB to the CDN. The app still hands the gateway 86 KB.

That hop is metered at a tenth of the price, which is why it went second, but it is not small. In the three days before the change the cross-AZ line billed 1,417.71 GB a day, about 709 GB a day of real movement inside the region. Not all of it is this hop. Redis replicationEach Redis replica pulls a full copy of every write from its master. With replicas in other zones that traffic lands on the same cross-AZ meter, and it was the larger share of the line. is the larger share, and the database traffic on that meter had already been compressed on the wire.

A gateway cannot fix this. It compresses what it sends. The hop into it is already over by then.

Compressing in the app can go backwards

A byte saved on the inner hop is worth $0.02, and only two thirds of the time. A byte added on the outer hop costs $0.09, every time. If the app's gzip came out fatter than the gateway's, the change would lose on the expensive meter to save on the cheap one.

The margin is wide. One page drops 72 KB off the inner hop, about 48 KB of it crossing a zone at $0.02 a GB. Against 14 KB going out at $0.09 a GB, the app's gzip would have to run about three quarters fatter than the gateway's before the trade stopped paying. It is not fatter: both sides are zlib at level 4 with the default window and memory settings.

The real question is whether the gateway compresses that output a second time. It does not: the plugin drives nginx's gzip filter, and nginx does not double compress what arrives compressed from the server behind it. The wire agrees:

shell
# the origin again, so the CDN cannot be the thing doing the compressing
$ curl -s -H 'Accept-Encoding: gzip' https://origin.site-a.example.com/ -o page.gz
$ gzip -l page.gz
  compressed uncompressed  ratio uncompressed_name
       14252        85801  83.3% page

14,252 bytes on the wire, 85,801 after one pass, and that second figure is exactly what the same URL returns to a client asking for identity. One layer, not two: the gateway is handing over the app's bytes untouched.

Three lines in the shared server

The apps are SvelteKit behind adapter-node, wrapped in a shared polka server that takes Connect middleware, so compression drops in:

shared/server.js
import compression from 'compression';

const app = polka();

if (process.env.RESPONSE_COMPRESSION === 'true') {
  app.use(compression({level: 4, threshold: 1024}));
}

Level 4 matches the gateway. The 1 KB threshold is the library's default: below it, a gzip header costs more than it saves.

RESPONSE_COMPRESSION makes it a per-site switch, set in the deployment rather than the image, so turning it off is a redeploy and not a rebuild:

apps/production/site-a.yaml
env:
  - name: RESPONSE_COMPRESSION
    value: 'true'

One site went first, the other two ten minutes later.

What the app change did

It went live at 21:52 UTC on 2 September for the first site and 22:02 for the other two.

Cross-AZ transfer — GB billed per dayGB/day
05001,0001,5002,000app gzip15 Aug2 Sep19 Sep15 Aug — 1350.3 GB/day16 Aug — 1237.5 GB/day17 Aug — 1367.6 GB/day18 Aug — 1249.7 GB/day19 Aug — 1,380 GB/day20 Aug — 1283.1 GB/day21 Aug — 1240.9 GB/day22 Aug — 1202.7 GB/day23 Aug — 1229.6 GB/day24 Aug — 1235.5 GB/day25 Aug — 1228.7 GB/day26 Aug — 1651.3 GB/day27 Aug — 1546.9 GB/day28 Aug — 1367.5 GB/day29 Aug — 1327.3 GB/day30 Aug — 1260.1 GB/day31 Aug — 1545.9 GB/day1 Sep — 1447.2 GB/day2 Sep — 1285.3 GB/day3 Sep — 1117.6 GB/day4 Sep — 1065.4 GB/day5 Sep — 1058.6 GB/day6 Sep — 1007.6 GB/day7 Sep — 1014.9 GB/day8 Sep — 921.7 GB/day9 Sep — 925.6 GB/day10 Sep — 935.7 GB/day11 Sep — 955.8 GB/day12 Sep — 951.7 GB/day13 Sep — 958.3 GB/day14 Sep — 947.8 GB/day15 Sep — 954.9 GB/day16 Sep — 908.3 GB/day17 Sep — 878.9 GB/day18 Sep — 860.2 GB/day19 Sep — 870.6 GB/day

Cost Explorer daily usage for DataTransfer-Regional-Bytes, 15 August – 19 September 2026. Billed at both ends, so real movement is about half of each figure. The app change landed at 22:00 UTC on 2 September; the drift after 7 September is falling traffic, not the change.

WindowCross-AZ, as billed
30 August – 1 September (before)1,417.71 GB/day
3–5 September (after)1,080.50 GB/day
Difference337.21 GB/day

337.21 GB a day at $0.01 a GB is $3.37 a day. Over 30.4 days, $102. The meter charges at both ends, so that is about 169 GB a day that stopped crossing a zone.

Two other instruments cover the same days. The load balancer served 14.25 million requests a day before and 14.37 million after, carrying 89.7 GB a day against 88.8 GB: traffic did not fall, and what leaves AWS did not move — the invariant the design rested on.

The EC2 network counters on the five nodes, which know nothing about either, went from 1,008.7 GB a day arriving to 776.5 GB. That is 232.2 GB a day less, a 23.0% fall, against a 23.8% fall on the billed line. Two systems counting different things, moving together.

The line keeps drifting down after 7 September. That is traffic, not the change: outbound falls by the same 12% over the same days.

The CPU never showed up

Gzip is not free, and it now runs on every response.

Node CPU averaged 37.9% across the five nodes in the three days before and 36.0% in the three days after, with traffic flat. It went down. Writing 14 KB to a socket instead of 86 KB is less work in the kernel than gzip adds in user space, so compression here is cheaper than the I/O it removes.

What both changes came to

ChangeMeterSaving
gzip at the gateway, 22 AugustDataTransfer-Out-Bytes$684
gzip in the app, 2 SeptemberDataTransfer-Regional-Bytes$102
Total$786

Same pages, same HTML, and no reader sees a difference: the CDN already compressed the last mile.

Turning compression on at the edge of a system fixes the one leg you can see. Every metered hop behind it is still moving the full page, and on Kubernetes there is always one more hop than the diagram in your head.

External sources

Rates were current when this was written. AWS changes them, so check before you budget against them.

  • EC2 on-demand pricing — data transfer between availability zones at $0.01 a GB in each direction, and the 100 GB a month of free egress.
  • APISIX gzip plugin — every attribute and its default, including types defaulting to text/html alone and comp_level to 1.
  • compression middleware — the level and threshold options, and what they default to.
  • APISIX default plugins and the chart's plugins: [] — what a stock install enables, and what setting that field replaces.
  • NGINX compression guide — that nginx does not double compress what a proxied server already compressed, which is why the gateway passes the app's bytes through.
  • polka — the server the built SvelteKit output is wrapped in, and why Connect middleware works in it.