Home Resume
Homeβ€Ί Blogβ€Ί Week 14 - VPC Flow Logs + Network Intelligence: Th…
AWS Weekly Lab AWS Terraform

The logs everyone enables and nobody reads

VPC Flow Logs get switched on for a compliance checkbox, write tens of gigabytes a month into a bucket nobody can query, and are never opened again β€” until an incident, when the answer is technically in S3 and unreachable in under a day. This week builds the layer that makes them answer questions, and finds out the hard way that the field everyone reaches for cannot do the job.

Verified against current vendor documentation on 22 August 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
AWS Platform Engineering Lab Β· Week 14 of 52

Why β€” The Problem This Solves

Someone turns on flow logs account-wide during a compliance push. The logs land in a bucket. Nobody has permission to read that bucket, and nobody has built a table over it, so nothing is ever queried.

Months later a NAT bill quadruples, or a host starts talking to somewhere it should not. The answer is technically sitting in S3. Getting it out means writing a Glue table definition, working out a partition strategy, and hand-writing SQL against a 30-field record format β€” under incident pressure, by someone who has not done it before.

The specific gap: flow logs are pure cost until someone builds the query layer, and the query layer is the part that always gets deferred. You pay $0.25 per GB ingested whether or not a single query ever runs.

Why teams do not already have this

  • The failure is silent. No alarm fires for "your logs are unqueryable". There is no red status anywhere. You find out mid-incident.
  • It is unglamorous. A table definition, a partition template, and a handful of saved queries. No console button produces it, and it is nobody's sprint goal until it is urgently everybody's problem.
  • The default destination costs double. CloudWatch Logs feels like the natural choice and is $0.50/GB against S3's $0.25/GB β€” and it cannot store Parquet at all.

Is this not just GuardDuty?

Worth settling early, because GuardDuty consumes these exact same flow logs and Week 11 already has it running in this account.

GuardDuty matches flow logs against known-bad IPs and behavioural patterns, and tells you when something matches. It does not give you a queryable table, and it cannot tell you which team's traffic drove your NAT gateway bill. Those are the two things this week is for β€” complementary, not overlapping.

What You Need to Know β€” Skills & Tools

Nothing here is exotic, but a few concepts decide whether this build works or quietly does nothing at all.

Flow log record versions

The record format is versioned and has moved well past what most tutorials show. v3 added pkt-srcaddr, v5 added traffic-path, v8 added reject-reason, and v11 added interface types, the next-hop family, and embedded EC2 tag values.

Vended log pricing

Flow logs bill as "vended logs": $0.25/GB to S3, $0.50/GB to CloudWatch Logs. The destination is a one-field decision with a 2Γ— cost consequence.

Parquet and columnar scanning

Athena bills on data scanned, not stored. Columnar Parquet means a query reads only the columns it names, so the storage format is the cost model rather than an optimisation.

Partition projection

An alternative to a Glue crawler: rather than discovering partitions on a schedule, you state the pattern and Athena computes them at query time. No DPU charge, no lag β€” but it fails silently if the template is wrong.

Service-linked roles

Some AWS features create their own IAM role on first use. The v11 tag fields are one: they are served by AWSServiceRoleForVPCFlowLogs, not by any role you write yourself.

Anomaly detection vs static alarms

CloudWatch can learn a band around a metric and alert on deviation, or compare against a fixed number. Knowing which question each answers is what keeps an alarm set both useful and cheap.

Tools used: Terraform via HCP Terraform (VCS-driven), Python 3.12 on Lambda, Athena SQL, and the AWS CLI for verification.

Architecture β€” How It Fits Together

A small VPC built specifically to produce identifiable traffic, capture into Parquet on S3, and a query layer with a hard spend ceiling on top.

THE OBSERVED NETWORK β€” every resource produces one identifiable shape of traffic generator private subnet, t4g.nano Team=platform-engineering NAT Gateway $0.045/hr + $0.045/GB ~75% of this build's cost the internet 200 distinct source IPs scanned the exposed host in 20 minutes S3 gateway endpoint no hourly, no per-GB β€” FREE exposed public subnet, NO ingress rules egress to S3, free path billed egress β€” and no tag on the charge says who caused it scans every unsolicited packet is REJECTed β€” that denial IS the signal this week analyses VPC flow logs β€” record version 11, 32 fields including instance-tag and the next-hop family S3 β€” Parquet, hive-partitioned, hourly $0.25/GB ingested. CloudWatch Logs would be $0.50/GB and cannot do Parquet. 30-day expiry + noncurrent-version expiry + multipart abort β€” the bill has an end partition projection β€” nothing crawls, nothing schedules Glue table 37 columns, 5 partition keys no crawler, no DPU charge Athena workgroup $5/TB scanned 10 GB per-query hard ceiling 7 saved queries top talkers Β· rejects Β· port scans NAT attribution Β· traffic path on-call, ad hoc flow_analyzer hourly, stateless queries β†’ metrics EventBridge β€” rate(1 hour) CloudWatch β€” mixed on purpose anomaly band traffic volume Β· NAT egress β€” normal is unknown and diurnal static threshold port scans Β· DLQ Β· silence β€” zero is the correct value anomaly alarms bill 3 metrics each ($0.30/mo); static alarms $0.10 SNS β†’ email alarm and recovery SQS dead letter queue without it, a broken analyzer looks like a quiet network Why the next-hop field is the point Measured at the sender's own ENI, NAT-bound traffic records traffic_path 1 ("same VPC"). The NAT's ENI records 8 β€” but carries no instance tag. Only the v11 next_hop_interface_type puts the team and the NAT on one row, which is what turns NAT spend from unallocatable into a GROUP BY.

Every resource in the top band exists to produce one specific, recognisable shape of traffic. That is deliberate β€” it is the data source for the whole build, not scaffolding around it:

  • The generator in the private subnet reaches the internet through the NAT gateway, and reaches S3 through a gateway endpoint. Same instance, two destinations, two very different prices.
  • The exposed instance in the public subnet has a security group with no ingress rules at all. It is internet-reachable and nothing can connect to it. Those denials are what produce the REJECT records this week analyses β€” removing them would not harden anything, it would delete the signal.

The one configuration field that halves the bill

CloudWatch Logs

$0.50/GB ingested, plus $0.03/GB-month storage. Plain text only. The right choice if you need metric filters and sub-minute alerting on individual flows.

Amazon S3

$0.25/GB ingested, plus $0.023/GB-month storage. Supports Parquet, which is what makes querying affordable.

For an organisation producing 500Β GB of flow logs a month, that is $250 against $125 β€” roughly $1,600 a year from changing one field.

Why the field list is generated rather than typed

instance_tag embeds the value of an EC2 tag directly into every flow record, turning "who owns this traffic" from a join against an already-stale inventory into a plain GROUP BY.

The field order is load-bearing

Parquet columns are matched to the Glue schema positionally. If the flow log's format string and the table's column list disagree on order, every query still runs, still succeeds, and returns values under the wrong column names β€” bytes reported as packets, source port under destination port. Nothing errors.

So the field list is declared once in Terraform and generates both the log_format string and the Glue columns, which makes that class of bug unrepresentable rather than merely unlikely.

flow_log_fields = [
  { field = "version",      column = "version",      type = "int" },
  { field = "srcaddr",      column = "srcaddr",      type = "string" },
  # ... 30 more ...
  { field = "instance-tag", column = "instance_tag", type = "string" },
]

log_format   = join(" ", [for f in local.flow_log_fields : "$${${f.field}}"])
glue_columns = [for f in local.flow_log_fields : { name = f.column, type = f.type }]

How We Built It β€” Step by Step

Step 1 β€” Deploy via HCP Terraform

Fifty-eight resources across four modules, VCS-driven, the same pattern as every week since Week 5.

HCP workspace variables showing the two OIDC environment variables
The two environment variables every new HCP workspace needs before its first plan: TFC_AWS_PROVIDER_AUTH and TFC_AWS_RUN_ROLE_ARN. Without them the first plan fails with β€œNo valid credential sources found”.
HCP Terraform run list showing one errored run and one applied run
Both runs, kept deliberately. The first apply errored after creating 55 of 58 resources β€” see Challenges for what it caught.

Step 2 β€” Capture into Parquet, partitioned by hour

Delivery takes about ten minutes and is written in five-minute batches, so an empty bucket immediately after apply is expected rather than broken.

S3 console breadcrumb showing hive-style partition prefixes down to hour
Hive-compatible prefixes, partitioned to the hour. The breadcrumb is the delivered structure the projection template has to reproduce exactly.

Step 3 β€” Define the table without a crawler

Flow log prefixes are perfectly deterministic β€” region, year, month, day, hour, all knowable in advance. A crawler would pay DPU-time on a schedule to rediscover a structure we can simply state, and would lag behind new partitions until its next run. Partition projection computes them at query time instead: no schedule, no DPU charge, no lag.

AWS Glue table details showing Parquet classification and 37 columns
37 columns: 32 data fields plus 5 partition keys. Nothing crawled this table β€” the definition states where the data is.

Verifying it actually works

This is the step most worth doing properly, because every failure mode in this build is silent. "It deployed" and "it works" are completely different claims here:

  • A projection template that does not match the delivered prefixes returns zero rows and reports SUCCEEDED. A dashboard on top shows a flat, healthy-looking zero line indefinitely.
  • Missing tag permissions produce a column of - rather than an error.
  • A field-order mismatch returns plausible numbers under the wrong column names.

None of these raise anything, and none are findable by re-reading the Terraform. So the build ships a verify_pipeline.sh that compares a real delivered S3 key against the projection template, confirms the subscription is ACTIVE with no delivery error, and runs a query that must return rows.

Athena result showing record count, hours present, and distinct instance tag values
The sanity query, and the one to run before trusting anything else. Records readable through the table means projection resolves; more than one distinct instance_tag means the version 11 tag fields are populating.

Step 4 β€” Attribute NAT spend to a team

NAT gateway charges $0.045 per GB processed, and those charges arrive in Cost Explorer attached to the NAT gateway β€” not to the instance, service, or team whose traffic caused them. There is no tag on the charge that says who did it.

SELECT
    url_decode(instance_tag)  AS owning_team,
    instance_id,
    SUM(bytes)                AS bytes_via_nat,
    ROUND(SUM(bytes) / 1073741824.0 * 0.045, 6) AS estimated_nat_usd
FROM flow_logs
WHERE concat(year,'-',month,'-',day) >= date_format(current_date - interval '1' day, '%Y-%m-%d')
  AND flow_direction = 'egress'
  AND next_hop_interface_type = 'nat_gateway'   -- the whole trick
GROUP BY 1, 2
Athena results attributing NAT gateway bytes to the platform-engineering team
NAT egress with an owner on it. Filtering on the sender's hop rather than the NAT's also avoids double-counting the same bytes at two capture points.
Athena results showing egress grouped by traffic path, including free gateway endpoint rows and billed NAT rows
Path 7 to S3 costs nothing and has an owner. Path 1 rows are NAT-bound and have an owner. Path 8 rows are the NAT's own egress β€” the same bytes, with no team attached.

Scaled up: a team quietly pulling 2Β TB a month through a NAT gateway for something an S3 gateway endpoint would carry for free is $90 a month that Cost Explorer cannot attribute to them.

Step 5 β€” Detect scanning without a SIEM

Within twenty minutes of getting a public IP, the exposed instance had been probed by 200 distinct source addresses β€” ordinary internet background scanning, no provocation needed.

Athena results listing source IPs with reject counts and the ports they probed
Real unsolicited inbound: SSH and telnet sweeps, and probes at 8008, 8161, 9090. The top row with 36 distinct ports is my own generated test signal, included so the port-scan query had something deterministic to catch.

The discriminator between a scan and a broken client is fan-out, not volume β€” a stuck client retries one port forever, a scanner walks the range. And a rejected connection is not evidence of an attack; it is equally often a health check pointed at a port that moved.

Step 6 β€” Turn queries into alarms

An hourly Lambda runs a handful of aggregate queries and publishes the results as CloudWatch custom metrics.

CloudWatch metrics console showing five custom metrics in the FlowLogIntelligence namespace
Five custom metrics from the hourly analyzer. Sparse here because the function had only run twice at capture time.

Anomaly detection band

Traffic volume, NAT egress. Nobody knows a VPC's normal byte volume at deploy time, and it varies by hour of day. A static threshold is either a guess that alarms constantly or a guess that never fires.

Static threshold

Port scans, DLQ depth, analyzer silence. These have a correct value that is a fact rather than a pattern: zero. Anomaly detection would learn a comfortable baseline rate of port scanning and stop reporting it.

Two alarms exist purely to catch the monitoring itself failing: a dead letter queue with a depth alarm, because otherwise a broken analyzer and a quiet network are indistinguishable; and an invocation alarm with treat_missing_data = "breaching", because a missing datapoint is the failure when the question is "did this run at all".

CloudWatch alarms list showing the anomaly and static alarms for this build
Five alarms: two anomaly bands, three static.
SNS notification email for the port-scan-detected alarm entering ALARM state
The detection path, end to end: flow log β†’ Athena β†’ custom metric β†’ alarm β†’ SNS β†’ inbox. Account IDs redacted.
SNS notification email for the analyzer-not-running alarm returning to OK
Better evidence than I would have staged deliberately. analyzer-not-running uses treat_missing_data = "breaching", so it correctly alarmed while nothing had run yet β€” then cleared eighteen minutes later when the analyzer reported in.

Step 7 β€” Cap the query spend

Athena bills $5 per TB scanned with no built-in spend limit, so the workgroup carries a hard per-query ceiling, enforced at workgroup level where no client can opt out of it.

Athena workgroup details showing a 10 GB data scanned limit
A 10Β GB per-query ceiling. Queries exceeding it are cancelled, not billed to completion.

Every saved query filters on partitions, and the numbers in the screenshots above show why it matters: the real queries in this post scanned 0.01–0.03Β MiB each.

Challenges β€” What Actually Went Wrong

One wrong availability zone, wearing three disguises

The first apply failed with three unrelated-looking errors at once: NatGateway NotAvailableInZone, gp3 VolumeTypeNotAvailableInZone, and t4g.nano not offered.

One cause. data.aws_availability_zones with state = "available" also returns opted-in Local Zones. This account has the Dallas Local Zone enabled, so names[0] resolved to us-east-1-dfw-1a, and Local Zones carry a deliberately reduced subset of regional services.

The fix filters on zone type and intersects with the instance type's actual offering list. Worth keeping the second half even with the filter: among genuine AZs, us-east-1e offers neither Graviton nor gp3.

traffic_path cannot attribute NAT cost

The NAT attribution query returned zero rows and the analyzer published a permanent nat_bytes = 0. Both looked like a broken pipeline. Neither was.

traffic_path is relative to the capture point, not the journey. Measured at the sending instance's own ENI, traffic heading to a NAT gateway records traffic_path = 1 β€” "another resource in the same VPC" β€” because from that ENI, that is literally what the next hop is. The NAT gateway's own ENI separately records traffic_path = 8 for the same bytes, but a NAT gateway is not an instance and carries no tag. And traffic_path = 2, the documented "internet or NAT gateway" value, never appeared in real data at all.

The v11 field next_hop_interface_type is what actually solves it, putting the instance's tag and the NAT destination on the same record.

An S3-destination flow log cannot take a delivery role

InvalidParameter: DeliverLogsPermissionArn is not applicable for s3 delivery. A delivery role applies only to the CloudWatch Logs destination; for S3 the permissions come from the bucket policy.

The role was there to grant ec2:DescribeTags for the v11 tag fields, which was the wrong mechanism entirely. Calling CreateFlowLogs with TagFieldSpecifications makes VPC Flow Logs create the service-linked role AWSServiceRoleForVPCFlowLogs automatically, and that role carries the tag-reading permissions. The only grant that matters is iam:CreateServiceLinkedRole on whatever runs Terraform.

Tag values arrive percent-encoded

platform-engineering is delivered as platform%2Dengineering. The field documentation does say special characters are percent-encoded, but it is easy to skim past and the symptom is subtle β€” values look almost right, while any GROUP BY on a tag containing a hyphen, space or slash quietly splits. Every tag column needs url_decode().

Security β€” Controls at Every Layer

One deliberate exception runs through this build: the exposed instance is internet-reachable on purpose. Its security group has no ingress rules at all, so nothing can connect β€” and those denials are what generate the REJECT records the detection queries analyse. It holds no data and was destroyed with the rest of the stack.

  • No SSH, no key pairs, no inbound ports. Instance access is SSM Session Manager only.
  • IMDSv2 required on both instances, and encrypted root volumes.
  • Bucket hardening: all public access blocked, BucketOwnerEnforced ownership, SSE-S3 with bucket keys, and a bucket policy that denies any non-TLS request.
  • Confused-deputy protection: the log delivery statement is scoped by both aws:SourceAccount and aws:SourceArn, so another account cannot point its flow logs at this bucket.
  • Least-privilege IAM. The analyzer's Athena permissions are scoped to the single workgroup that carries the scan ceiling β€” a broad athena:* would let it run queries outside the guardrail the whole design depends on. PutMetricData supports no resource-level permission, so it is constrained by a namespace condition instead.
  • Spend treated as a security control. The 10Β GB per-query ceiling is enforced at workgroup level, so a shared workgroup cannot be turned into an unbounded bill by someone who has not read the docs.

Cost

Prices as of August 2026 β€” verify at aws.amazon.com/vpc/pricing and aws.amazon.com/cloudwatch/pricing before quoting them.

ItemRate48-hour build
NAT Gateway$0.045/hr + $0.045/GB~$2.16
2 Γ— t4g.nano$0.0042/hr each~$0.40
Flow log ingest to S3$0.25/GB< $0.10
Athena$5/TB scannedcents
Anomaly alarms Γ— 2$0.30/month each, prorated hourly~$0.02
Total~$3
Destroyed$0

Left running: about $33 a month, roughly three quarters of it NAT gateway. The NAT bills whether or not a single packet flows and produces no usage signal to remind you it exists. On this build, forgetting is the risk β€” not the rate.

The cost controls are in the code rather than in advice: a 30-day expiry on raw logs, a noncurrent-version expiry alongside it (versioning is on, so expiry alone would only create delete markers while the billed bytes stayed), a multipart-upload abort for orphaned partial writes that are invisible in the console listing and billed anyway, and the Athena scan ceiling.

Cleanup

The workspace is VCS-connected, which blocks terraform destroy from a CLI checkout but not a destroy queued through HCP β€” either from the UI (Workspace β†’ Settings β†’ Destruction and Deletion β†’ Queue destroy plan) or the API with is-destroy: true.

./scripts/cleanup.sh

The script does not destroy anything; it verifies a destroy actually finished and reports what survived. It distinguishes three outcomes rather than two β€” gone, still present, and could not verify β€” because an expired session returning an empty result otherwise reads as a clean teardown while having checked nothing at all.

What tends to linger on this build, and why:

  • The NAT gateway β€” the expensive one, with no usage signal to remind you it exists.
  • The Elastic IP β€” billed hourly once detached, and it outlives the NAT gateway it belonged to.
  • Anomaly detectors β€” a separate API from alarms, so they survive alarm deletion and quietly confuse a later rebuild with a stale trained band.

One thing that is not leftover state: AWSServiceRoleForVPCFlowLogs survives the destroy by design. AWS wants all tag-using subscriptions gone and roughly an hour to pass before it can be removed.

References

Key Takeaways

  • Pick S3, not CloudWatch Logs, unless you specifically need metric filters on individual flows. Half the ingest cost, and the only path to Parquet.
  • Verify against delivered data, not against your config. Every failure mode here is silent β€” zero rows and a green "succeeded" look identical to a working system.
  • Do not filter on traffic_path to find NAT cost. Use next_hop_interface_type. The field that looks right is measuring something else.
  • Put a scan ceiling on the workgroup before anyone else touches it. Athena has no default spend limit.
  • Mix your alarm types on purpose. Anomaly detection where normal is genuinely unknown; static thresholds where zero is the right answer. Using anomaly detection everywhere costs 30Γ— more and detects less.
  • Check which AZ you landed in. data.aws_availability_zones returns Local Zones too, and one wrong AZ can look like three unrelated failures.

What I'd do differently in production

  • Send flow logs from every VPC to one account, not one bucket per VPC. This build is single-VPC because it is a lab. At organisation scale the value is cross-account, and the table definition is the same either way.
  • Raise the aggregation interval, or accept the bill. A 1-minute interval gives tighter incident timelines and produces far more billable GB. At real traffic volumes that trade-off deserves a deliberate decision rather than a default.
  • Convert to a partitioned summary table. Querying raw records is fine at lab volume. At terabytes, a nightly rollup of the handful of aggregates the alarms need would cost less than scanning raw Parquet every hour.
  • Give the anomaly bands weeks, not hours. Both detectors here sat at INSUFFICIENT_DATA for the life of the build, which is honest but not useful β€” a band needs real diurnal history before it means anything.
  • Alarm on the cost metric, not just the traffic metric. The analyzer already publishes a projected monthly NAT figure; in production that is the number a platform team actually wants paging them, not raw bytes.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent