<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kiran Gunturu</title>
    <description>The latest articles on DEV Community by Kiran Gunturu (@kiran_gunturu_022085015ec).</description>
    <link>https://dev.to/kiran_gunturu_022085015ec</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4117834%2Ff8c7cc2a-cb83-4e26-9489-fe732741ce1b.JPG</url>
      <title>DEV Community: Kiran Gunturu</title>
      <link>https://dev.to/kiran_gunturu_022085015ec</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kiran_gunturu_022085015ec"/>
    <language>en</language>
    <item>
      <title>AWS Glue Concurrency Is Capped by One Subnet, Not Your Whole VPC</title>
      <dc:creator>Kiran Gunturu</dc:creator>
      <pubDate>Sun, 11 Oct 2026 18:24:36 +0000</pubDate>
      <link>https://dev.to/kiran_gunturu_022085015ec/aws-glue-concurrency-is-capped-by-one-subnet-not-your-whole-vpc-1220</link>
      <guid>https://dev.to/kiran_gunturu_022085015ec/aws-glue-concurrency-is-capped-by-one-subnet-not-your-whole-vpc-1220</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If you run many AWS Glue jobs concurrently inside a VPC, your concurrency&lt;br&gt;
ceiling is the number of free IPs in a &lt;strong&gt;single&lt;/strong&gt; subnet, not the sum of&lt;br&gt;
free IPs across your VPC. Attaching multiple Glue connections does &lt;strong&gt;not&lt;/strong&gt;&lt;br&gt;
pool IPs across subnets; it only provides failover. I proved this with a&lt;br&gt;
series of controlled tests, and the fix is boring but effective: size a&lt;br&gt;
dedicated subnet per workload to &lt;code&gt;workers_per_job x max_concurrency&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I work on a regulated data platform where several products share one&lt;br&gt;
VPC. Each product runs a set of Glue ETL jobs. I started hitting failures&lt;br&gt;
under concurrency with errors like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The specified subnet does not have enough free addresses to satisfy the
request. Please provide a connection with a subnet with ips available.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On paper I had plenty of headroom, ~96 free IPs across six subnets. So&lt;br&gt;
why were jobs failing at 8-10 concurrent runs? The answer is in how Glue&lt;br&gt;
consumes IPs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mental model: ENIs = workers = IPs
&lt;/h2&gt;

&lt;p&gt;When a Glue job connects to VPC resources, Glue creates &lt;strong&gt;Elastic Network&lt;br&gt;
Interfaces (ENIs)&lt;/strong&gt; in your subnet. The number of ENIs tracks the number of&lt;br&gt;
workers (DPUs) for the job, and &lt;strong&gt;each ENI consumes one private IP&lt;/strong&gt; in the&lt;br&gt;
subnet.&lt;/p&gt;

&lt;p&gt;So a single job with 4 workers ~= 4 IPs. Ten such jobs running at once ~= 40&lt;br&gt;
IPs, all drawn from &lt;strong&gt;one&lt;/strong&gt; subnet. That's the part that surprises people.&lt;/p&gt;
&lt;h2&gt;
  
  
  The myth: "multiple connections give me more IPs"
&lt;/h2&gt;

&lt;p&gt;A Glue job can have multiple connections attached, and each connection can&lt;br&gt;
point to a different subnet. It's tempting to assume Glue will spread ENIs&lt;br&gt;
across those subnets to pool IP capacity. It won't.&lt;/p&gt;

&lt;p&gt;Here's what actually happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Glue evaluates connections &lt;strong&gt;in the order they're listed&lt;/strong&gt; and picks the
first suitable one. All ENIs for that run are created in &lt;strong&gt;that one
subnet&lt;/strong&gt;, in a single AZ.&lt;/li&gt;
&lt;li&gt;Additional connections are &lt;strong&gt;failover only&lt;/strong&gt;: Glue rolls over to the next
connection if the first has a networking/AZ problem. It is not
load-balancing and not high availability.&lt;/li&gt;
&lt;li&gt;Multiple connections to the same subnet with different DB URLs just means
"reach different databases," not "get more IPs."&lt;/li&gt;
&lt;li&gt;Glue does &lt;strong&gt;not&lt;/strong&gt; switch connections dynamically mid-run to chase free IPs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Net effect: your effective IP budget is the free-IP count of whichever single&lt;br&gt;
subnet Glue lands on, usually your largest one.&lt;/p&gt;
&lt;h2&gt;
  
  
  The experiments
&lt;/h2&gt;

&lt;p&gt;I ran a controlled matrix to pin down the behavior. Subnet labels are&lt;br&gt;
generic; the numbers are representative.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Jobs submitted&lt;/th&gt;
&lt;th&gt;Subnet(s)&lt;/th&gt;
&lt;th&gt;Available IPs&lt;/th&gt;
&lt;th&gt;Workers/job&lt;/th&gt;
&lt;th&gt;Glue ver&lt;/th&gt;
&lt;th&gt;IPs used&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Subnet-A&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Subnet-A&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;4, 3, 2&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Subnet-A&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;max 4 (autoscale)&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Subnet-A&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Subnet-A + B&lt;/td&gt;
&lt;td&gt;52 (26+26)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;6 succeeded, 2 failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Subnet-A + B&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;max 4 (autoscale)&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;All succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two multi-subnet tests are the interesting ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test 5:&lt;/strong&gt; Two subnets attached (26 free IPs each). Eight jobs at 4
workers need 32 IPs. Only the first subnet was used: it holds 26 IPs, so 6
jobs got their 24 IPs and the remaining 2 failed for lack of addresses.
&lt;strong&gt;Zero IPs were drawn from the second subnet.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test 6:&lt;/strong&gt; Ten jobs with auto-scaling enabled. All succeeded, but every
ENI still came from a &lt;strong&gt;single&lt;/strong&gt; subnet. Auto-scaling let the jobs fit into
fewer IPs; it did not spread them across subnets.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What I observed
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A Glue job can attach multiple connections; each points to a subnet.&lt;/li&gt;
&lt;li&gt;Glue uses exactly &lt;strong&gt;one&lt;/strong&gt; connection/subnet per job run.&lt;/li&gt;
&lt;li&gt;Connection selection is order-based, with rollover &lt;strong&gt;only&lt;/strong&gt; on failure:
it's failover, not balancing.&lt;/li&gt;
&lt;li&gt;Multiple connections to the same subnet = access to different databases,
not more IPs.&lt;/li&gt;
&lt;li&gt;Multiple connections exist for &lt;strong&gt;fault tolerance/failover&lt;/strong&gt;, never for HA
or IP pooling.&lt;/li&gt;
&lt;li&gt;With auto-scaling &lt;strong&gt;on&lt;/strong&gt;, N concurrent jobs all land in the single subnet
of the first working connection.&lt;/li&gt;
&lt;li&gt;With auto-scaling &lt;strong&gt;off&lt;/strong&gt;, jobs beyond the subnet's IP budget fail
outright.&lt;/li&gt;
&lt;li&gt;Partial free IPs are &lt;strong&gt;not&lt;/strong&gt; shared across subnets.&lt;/li&gt;
&lt;li&gt;Therefore, spreading IPs thinly across many subnets buys you nothing:
Glue won't switch subnets to use them.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The capacity math
&lt;/h2&gt;

&lt;p&gt;Plan per &lt;strong&gt;single subnet&lt;/strong&gt;, not per VPC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IPs needed  ~=  workers_per_job x max_concurrent_jobs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remember AWS reserves 5 IPs in every subnet, and leave a little ENI&lt;br&gt;
headroom. Worked examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 concurrent x 4 workers = 40 IPs&lt;/strong&gt; in one subnet.
A &lt;code&gt;/27&lt;/code&gt; (32 total, ~27 usable) can't do it. You need at least a &lt;code&gt;/26&lt;/code&gt;
(64 total, ~59 usable).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 concurrent x 4 workers = 20 IPs.&lt;/strong&gt;
A &lt;code&gt;/27&lt;/code&gt; (~27 usable) is comfortable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My original layout, six small subnets (&lt;code&gt;/27&lt;/code&gt;s and &lt;code&gt;/28&lt;/code&gt;s) totaling ~96 free&lt;br&gt;
IPs, looked generous but capped me at ~26 concurrent-worker-IPs because Glue&lt;br&gt;
only ever touched one &lt;code&gt;/27&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One dedicated subnet per workload&lt;/strong&gt;, sized to that workload's peak
&lt;code&gt;workers x concurrency&lt;/code&gt;. Workload A gets its own &lt;code&gt;/27&lt;/code&gt;; the heavier
Workload B gets a larger block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stopped treating multiple connections as capacity.&lt;/strong&gt; They're failover.
If I genuinely need cross-VPC reachability, that's &lt;strong&gt;VPC peering&lt;/strong&gt;, which
is about reachability, still not IP pooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Used auto-scaling deliberately.&lt;/strong&gt; It reduces IP pressure but adds latency
for large table ingests, so it's a conscious trade-off, not a free win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluated Glue job queuing.&lt;/strong&gt; AWS's built-in job queuing automatically
handles concurrency limits and retries jobs instead of failing them
immediately, worth enabling if bursty submission is your pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Glue concurrency is bounded by &lt;strong&gt;one subnet's free IPs&lt;/strong&gt;, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ENIs = workers = IPs.&lt;/strong&gt; Budget accordingly.&lt;/li&gt;
&lt;li&gt;Multiple connections = failover, &lt;strong&gt;not&lt;/strong&gt; more capacity.&lt;/li&gt;
&lt;li&gt;Right-size a &lt;strong&gt;dedicated subnet per workload&lt;/strong&gt;; don't scatter IPs across
many small subnets and expect Glue to pool them.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>awsglue</category>
      <category>dataengineering</category>
      <category>networking</category>
    </item>
    <item>
      <title>The fastest Glue job is the one you never start: removing per-job overhead from a 191-job pipeline</title>
      <dc:creator>Kiran Gunturu</dc:creator>
      <pubDate>Sun, 04 Oct 2026 15:15:50 +0000</pubDate>
      <link>https://dev.to/kiran_gunturu_022085015ec/the-fastest-glue-job-is-the-one-you-never-start-removing-per-job-overhead-from-a-191-job-pipeline-aia</link>
      <guid>https://dev.to/kiran_gunturu_022085015ec/the-fastest-glue-job-is-the-one-you-never-start-removing-per-job-overhead-from-a-191-job-pipeline-aia</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/kiran_gunturu_022085015ec/cutting-a-production-aws-glue-pipelines-runtime-by-about-half-plus-two-bugs-that-taught-me-more-3lhd"&gt;last post&lt;/a&gt; I covered making a production AWS Glue pipeline run faster by tuning individual jobs. This post is about the bigger change that followed. It had nothing to do with how the jobs ran. It was about how many times we started them.&lt;/p&gt;

&lt;p&gt;All costs below use Glue's standard rate of &lt;strong&gt;$0.44 per DPU-hour&lt;/strong&gt;, billed per second with a 1-minute minimum per job run (Glue 2.0+).&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;It's an Oracle-to-vendor feed with five layers, orchestrated by Step Functions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Glue jobs&lt;/th&gt;
&lt;th&gt;Worker config&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;G.1X × 4 (4 DPU)&lt;/td&gt;
&lt;td&gt;Oracle → S3, one job per source table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transformation&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;G.1X × 4 (4 DPU)&lt;/td&gt;
&lt;td&gt;Business transforms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;G.1X × 4 (4 DPU)&lt;/td&gt;
&lt;td&gt;Write final output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;G.2X × 6 (12 DPU)&lt;/td&gt;
&lt;td&gt;Package into ~5 GB split files, zip, encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFTP&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Python Shell, 1 DPU&lt;/td&gt;
&lt;td&gt;Ship to the vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Step Functions runs the layers in order, and each layer fans its jobs out through a Map state. Running daily, that's &lt;strong&gt;191 Glue job starts every day&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning helped, up to a point
&lt;/h2&gt;

&lt;p&gt;The optimizations from the last post made a real difference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Before (min)&lt;/th&gt;
&lt;th&gt;After (min)&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion&lt;/td&gt;
&lt;td&gt;23.38&lt;/td&gt;
&lt;td&gt;6.38&lt;/td&gt;
&lt;td&gt;−73%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transform&lt;/td&gt;
&lt;td&gt;10.02&lt;/td&gt;
&lt;td&gt;5.43&lt;/td&gt;
&lt;td&gt;−46%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish&lt;/td&gt;
&lt;td&gt;25.23&lt;/td&gt;
&lt;td&gt;5.15&lt;/td&gt;
&lt;td&gt;−80%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound&lt;/td&gt;
&lt;td&gt;8.00&lt;/td&gt;
&lt;td&gt;5.55&lt;/td&gt;
&lt;td&gt;−31%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFTP&lt;/td&gt;
&lt;td&gt;5.02&lt;/td&gt;
&lt;td&gt;5.54&lt;/td&gt;
&lt;td&gt;noise (bandwidth-bound)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End to end&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1h 15m 13s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.3 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−61%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;26M records, 1.3 GB zip.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What that did to cost
&lt;/h3&gt;

&lt;p&gt;Wall-clock time and cost aren't the same thing here. A layer's wall clock is roughly its slowest job, but you pay for every job in the layer. A single minute of wall clock on a 63-job G.1X × 4 layer can bill up to 63 × 4 = 252 DPU-minutes.&lt;/p&gt;

&lt;p&gt;The estimates below treat every job in a layer as running for the layer's full duration. That makes them an &lt;strong&gt;upper bound&lt;/strong&gt;: jobs that finish early cost less, so actual spend is lower. But the same method applies to every row, so the comparisons hold.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;DPU-hours before&lt;/th&gt;
&lt;th&gt;DPU-hours after&lt;/th&gt;
&lt;th&gt;Cost / run before&lt;/th&gt;
&lt;th&gt;Cost / run after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion&lt;/td&gt;
&lt;td&gt;98.2&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;$43.21&lt;/td&gt;
&lt;td&gt;$11.79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transform&lt;/td&gt;
&lt;td&gt;42.1&lt;/td&gt;
&lt;td&gt;22.8&lt;/td&gt;
&lt;td&gt;$18.52&lt;/td&gt;
&lt;td&gt;$10.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish&lt;/td&gt;
&lt;td&gt;106.0&lt;/td&gt;
&lt;td&gt;21.6&lt;/td&gt;
&lt;td&gt;$46.63&lt;/td&gt;
&lt;td&gt;$9.52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound&lt;/td&gt;
&lt;td&gt;1.6&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;td&gt;$0.70&lt;/td&gt;
&lt;td&gt;$0.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFTP&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;$0.44&lt;/td&gt;
&lt;td&gt;$0.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;248.9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$109.49&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$31.87&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;SFTP ran on G.2X × 6 before being moved to a 1-DPU Python Shell job.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Estimated cost per run fell by about &lt;strong&gt;71%&lt;/strong&gt;, more than runtime did. That's because nearly all of the cost sits in the three 63-job layers, and those improved the most. Outbound and SFTP are single jobs, so even their slow minutes are cheap.&lt;/p&gt;

&lt;p&gt;The run went from over an hour to under 30 minutes. But while going through the run history, I found two days that showed where the rest of the time was going.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run that processed almost nothing
&lt;/h2&gt;

&lt;p&gt;On two days the source sent almost no data: &lt;strong&gt;5,822 records, a 75 KB file&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Those runs still took &lt;strong&gt;25 and 27 minutes&lt;/strong&gt;. Ingestion alone took 5 minutes to read a few thousand rows across 63 jobs. Nearly all of that time was spent before any data was touched.&lt;/p&gt;

&lt;p&gt;Every Glue job pays the same fixed cost before it does any work:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    subgraph FIXED["Fixed cost: paid on every job start, regardless of data volume"]
        A["Provision workers"] --&amp;gt; B["Install libraries"]
        B --&amp;gt; C["Start Spark context"]
        C --&amp;gt; D["Open JDBC connection"]
    end
    D --&amp;gt; E["Read and write data&amp;lt;br/&amp;gt;scales with volume"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;That cost doesn't depend on data volume. A job reading 5,000 rows pays the same startup as a job reading 5 million. Glue 2.0+ also bills a 1-minute minimum per job run, so even a very short job costs a full minute of DPU time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the empty run cost
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Records&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;DPU-hours&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Near-empty day&lt;/td&gt;
&lt;td&gt;5,822&lt;/td&gt;
&lt;td&gt;25.3 min&lt;/td&gt;
&lt;td&gt;73.3&lt;/td&gt;
&lt;td&gt;$32.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal day&lt;/td&gt;
&lt;td&gt;26M&lt;/td&gt;
&lt;td&gt;1h 15m&lt;/td&gt;
&lt;td&gt;248.9&lt;/td&gt;
&lt;td&gt;$109.49&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Both runs are pre-tuning, estimated the same way as above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The near-empty run cost about &lt;strong&gt;29% of a full run&lt;/strong&gt; while processing &lt;strong&gt;0.02% of the data&lt;/strong&gt;. That 29% is roughly what we paid every day just to start 191 jobs.&lt;/p&gt;

&lt;p&gt;We were paying that startup cost 191 times a day. Tuning had reduced the time spent on actual work, which only made the fixed overhead a bigger share of what was left. Further tuning wasn't going to change that, because the overhead comes from starting the job, not from the work it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Removing the overhead: start fewer jobs
&lt;/h2&gt;

&lt;p&gt;The downstream consumer didn't need the feed every day. So instead of making each job start faster, we reduced how often the jobs start.&lt;/p&gt;

&lt;p&gt;We made the cadence configurable: &lt;strong&gt;daily, weekly, or monthly&lt;/strong&gt;. A weekly or monthly run processes the whole period's data in one pass through the same pipeline.&lt;/p&gt;

&lt;p&gt;While building this, we took a closer look at the transformation layer. The architecture called for it as a separate stage, but in practice its 63 jobs weren't applying any business transformations. They read the ingested data and passed it through to publish unchanged. That meant 63 extra rounds of provisioning, library installs, and Spark startup every run, just to move data from one S3 prefix to another. In the daily run that layer still took 5.4 minutes. So weekly and monthly runs skip it, and each one starts &lt;strong&gt;128 jobs instead of 191&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    SF(["Step Functions"]) --&amp;gt; ING["Ingestion&amp;lt;br/&amp;gt;63 Glue jobs&amp;lt;br/&amp;gt;Oracle → S3"]
    ING --&amp;gt; CAD{"Cadence?"}
    CAD --&amp;gt;|"Daily"| TR["Transform&amp;lt;br/&amp;gt;63 Glue jobs&amp;lt;br/&amp;gt;pass-through only"]
    TR --&amp;gt; PUB["Publish&amp;lt;br/&amp;gt;63 Glue jobs"]
    CAD --&amp;gt;|"Weekly / Monthly&amp;lt;br/&amp;gt;skip transform"| PUB
    PUB --&amp;gt; OUT["Outbound&amp;lt;br/&amp;gt;1 Glue job&amp;lt;br/&amp;gt;~5 GB splits, zip, encrypt"]
    OUT --&amp;gt; SFTP["SFTP&amp;lt;br/&amp;gt;1 Glue job"]
    SFTP --&amp;gt; V(["Vendor"])

    classDef removed fill:#eeeeee,stroke:#999999,stroke-dasharray:5 5,color:#666666
    class TR removed&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Here's what that does to the number of job starts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cadence&lt;/th&gt;
&lt;th&gt;Jobs / run&lt;/th&gt;
&lt;th&gt;Glue job starts / month&lt;/th&gt;
&lt;th&gt;Glue job starts / year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;5,921&lt;/td&gt;
&lt;td&gt;69,715&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;~567&lt;/td&gt;
&lt;td&gt;~6,674&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;1,536&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;xychart-beta
    title "Glue job starts per month"
    x-axis ["Daily", "Weekly", "Monthly"]
    y-axis "Job starts" 0 --&amp;gt; 6500
    bar [5921, 567, 128]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Weekly means &lt;strong&gt;~90% fewer job starts&lt;/strong&gt; than daily. Monthly means &lt;strong&gt;~98% fewer&lt;/strong&gt;. Every one of those removed starts is a round of provisioning, library installs, and Spark startup that no longer happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  The billing floor of a job start
&lt;/h3&gt;

&lt;p&gt;Because Glue bills at least one minute per job run, every job start has a minimum price, even if the job does no work at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job type&lt;/th&gt;
&lt;th&gt;Minimum billed per start&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;G.1X × 4 (4 DPU)&lt;/td&gt;
&lt;td&gt;$0.029&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G.2X × 6 (12 DPU)&lt;/td&gt;
&lt;td&gt;$0.088&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python Shell (1 DPU)&lt;/td&gt;
&lt;td&gt;$0.007&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Multiply that by the job count and cadence, and you get the least the pipeline can cost just for starting its jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cadence&lt;/th&gt;
&lt;th&gt;Floor per run&lt;/th&gt;
&lt;th&gt;Floor per month&lt;/th&gt;
&lt;th&gt;Floor per year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily (191 jobs)&lt;/td&gt;
&lt;td&gt;$5.64&lt;/td&gt;
&lt;td&gt;$174.80&lt;/td&gt;
&lt;td&gt;$2,058&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly (128 jobs)&lt;/td&gt;
&lt;td&gt;$3.79&lt;/td&gt;
&lt;td&gt;$16.79&lt;/td&gt;
&lt;td&gt;$198&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly (128 jobs)&lt;/td&gt;
&lt;td&gt;$3.79&lt;/td&gt;
&lt;td&gt;$3.79&lt;/td&gt;
&lt;td&gt;$45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are lower bounds. Every job runs longer than a minute, so actual cost is higher, but the floor alone shows the pattern. The transform layer's share of the daily floor was $1.85 per run, about $675 a year, just to start 63 jobs that didn't change the data.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the pass-through layer cost
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Per daily run&lt;/th&gt;
&lt;th&gt;Per month (31 runs)&lt;/th&gt;
&lt;th&gt;Per year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transform layer (63 jobs, no business logic)&lt;/td&gt;
&lt;td&gt;$10.04&lt;/td&gt;
&lt;td&gt;$311&lt;/td&gt;
&lt;td&gt;$3,663&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Post-tuning estimate, same method as above.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it did to runtime
&lt;/h2&gt;

&lt;p&gt;Dropping the pass-through layer is a win on its own, but I wanted to measure the cadence change by itself. So the baseline here is a daily run without transform: 29.3 − 5.43 ≈ &lt;strong&gt;23.9 minutes&lt;/strong&gt;. Every gain below comes from running less often, not from removing a layer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cadence&lt;/th&gt;
&lt;th&gt;Avg run time&lt;/th&gt;
&lt;th&gt;Avg records / run&lt;/th&gt;
&lt;th&gt;Runs / month&lt;/th&gt;
&lt;th&gt;Pipeline time / month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily (excl. transform)&lt;/td&gt;
&lt;td&gt;~23.9 min&lt;/td&gt;
&lt;td&gt;21M&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;~12.3 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;td&gt;65.4 min&lt;/td&gt;
&lt;td&gt;110.5M&lt;/td&gt;
&lt;td&gt;~4.4&lt;/td&gt;
&lt;td&gt;~4.8 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;2h 43m&lt;/td&gt;
&lt;td&gt;540M&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~2.7 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Weekly: average of four full 7-day runs (107–114M records, 51–71 min each). Monthly: average of the July and August runs (505M and 575M records). The daily monthly total is an estimate based on the post-tuning run time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A weekly run processes about &lt;strong&gt;5× the records&lt;/strong&gt; of a daily run in about &lt;strong&gt;2.7× the time&lt;/strong&gt;. A monthly run processes about &lt;strong&gt;26× the records&lt;/strong&gt; in about &lt;strong&gt;6.8× the time&lt;/strong&gt;. If runtime grew with data volume, those ratios would be about the same. They aren't, because the fixed startup cost is paid once per run no matter how much data the run carries.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;xychart-beta
    title "Records processed per minute (millions)"
    x-axis ["Daily (excl. transform)", "Weekly", "Monthly"]
    y-axis "M records / min" 0 --&amp;gt; 4
    bar [0.9, 1.7, 3.3]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The jobs, Spark configs, and code were the same at every cadence. The weekly cadence runs about twice as efficiently as daily, and monthly about four times, only because less of each run is spent starting up.&lt;/p&gt;

&lt;p&gt;Over a month, weekly cadence cuts total pipeline time by about &lt;strong&gt;61%&lt;/strong&gt; compared with daily, and monthly cuts it by about &lt;strong&gt;78%&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it did to cost
&lt;/h3&gt;

&lt;p&gt;Monthly Glue cost by layer at each cadence (daily includes transform, since that's what production ran):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Daily / month&lt;/th&gt;
&lt;th&gt;Weekly / month&lt;/th&gt;
&lt;th&gt;Monthly / month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion&lt;/td&gt;
&lt;td&gt;$365.49&lt;/td&gt;
&lt;td&gt;$204.23&lt;/td&gt;
&lt;td&gt;$67.34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transform&lt;/td&gt;
&lt;td&gt;$311.09&lt;/td&gt;
&lt;td&gt;$0 (skipped)&lt;/td&gt;
&lt;td&gt;$0 (skipped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish&lt;/td&gt;
&lt;td&gt;$295.03&lt;/td&gt;
&lt;td&gt;$114.21&lt;/td&gt;
&lt;td&gt;$94.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound&lt;/td&gt;
&lt;td&gt;$15.14&lt;/td&gt;
&lt;td&gt;$6.73&lt;/td&gt;
&lt;td&gt;$3.83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFTP&lt;/td&gt;
&lt;td&gt;$1.26&lt;/td&gt;
&lt;td&gt;$0.27&lt;/td&gt;
&lt;td&gt;$0.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$988.01&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$325.44&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$166.21&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per year&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$11,633&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$3,832&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1,995&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Upper-bound estimates, same method as above. Daily uses the post-tuning run. Weekly uses the average layer times of the four full weeks. Monthly uses the average of the July and August runs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Compared with daily, weekly cadence cuts estimated Glue cost by about &lt;strong&gt;67%&lt;/strong&gt; and monthly by about &lt;strong&gt;83%&lt;/strong&gt;. Nearly all of the savings come from the 63-job layers: ingestion and publish run far fewer times, and transform drops out completely. The single-job layers barely register either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;The cost is data freshness. With a weekly feed, the vendor can see data up to seven days old. With monthly, up to a month old. That was acceptable for this consumer, which is why the change was an option at all. If your downstream needs daily data, this approach doesn't apply, and reducing per-job overhead means something different, such as consolidating many small table jobs into fewer larger ones.&lt;/p&gt;

&lt;p&gt;The daily-cadence totals above are estimated from a 21M-record run. Low-volume days run shorter, so the real saving is somewhat less than the table shows. It's still the largest single improvement we made to this pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check on any Glue pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find a run with almost no data.&lt;/strong&gt; Its runtime is your fixed overhead floor. If that floor is a large share of a normal run, job tuning will stop paying off quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count job starts, not just runtime.&lt;/strong&gt; 191 jobs × 1 run a day looks fine on a per-run dashboard. At nearly 70,000 job starts a year, the overhead is easier to see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask whether the consumer needs the current cadence.&lt;/strong&gt; Daily is often the default rather than a requirement. The cheapest overhead to remove is on runs nobody needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for pass-through layers.&lt;/strong&gt; A stage can exist in the architecture diagram without changing the data. If a layer's output matches its input, every job in it is pure overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sum DPU-hours across parallel jobs, not wall clock.&lt;/strong&gt; A Map state with 63 jobs finishes when the slowest one does, but you pay for all 63.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Tuning took a daily run from 1h15m to 29 minutes, and that was worth doing. But the change that mattered more was noticing that a lot of each run was startup, not work. Changing the cadence and dropping a pass-through layer took monthly pipeline time from over 12 hours to under 3, cut job starts from 5,921 to 128, and cut estimated monthly Glue cost from about $988 to about $166, without touching any job code.&lt;/p&gt;

&lt;p&gt;When a pipeline is built from many small jobs, the fastest job is often the one you never start.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>serverless</category>
      <category>performance</category>
    </item>
    <item>
      <title>Cutting a production AWS Glue pipeline's runtime by about half, plus two bugs that taught me more</title>
      <dc:creator>Kiran Gunturu</dc:creator>
      <pubDate>Sat, 26 Sep 2026 14:23:42 +0000</pubDate>
      <link>https://dev.to/kiran_gunturu_022085015ec/cutting-a-production-aws-glue-pipelines-runtime-by-about-half-plus-two-bugs-that-taught-me-more-3lhd</link>
      <guid>https://dev.to/kiran_gunturu_022085015ec/cutting-a-production-aws-glue-pipelines-runtime-by-about-half-plus-two-bugs-that-taught-me-more-3lhd</guid>
      <description>&lt;p&gt;I spent the last few months tuning a production pipeline on &lt;strong&gt;AWS Glue and PySpark&lt;/strong&gt;: Oracle to S3 ingestion, then packaging, encryption, and transfer to a downstream analytics platform. The goal was to cut runtime without touching business logic, and across four workstreams it came to roughly a &lt;strong&gt;56% reduction&lt;/strong&gt; end to end.&lt;/p&gt;

&lt;p&gt;Here are the four changes that moved the needle, then two investigations that never became wins but taught me more. All numbers are from my own runs, checked against logs, with anything environment-specific genericized.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ingestion: crossing a concurrency wave boundary
&lt;/h2&gt;

&lt;p&gt;The 63-table ingestion ran through a Step Functions Map at concurrency 30. With 63 items that forces three waves, and the wall clock is the last item to finish, not the slowest job. The run sat at &lt;strong&gt;13:55&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Raising Map concurrency to 50 dropped it to two waves and about &lt;strong&gt;8 minutes&lt;/strong&gt;. Waves are a cliff, so the payoff is in crossing a boundary, not nudging the number. Largest-first packing helps too (start the heavy tables in wave one so none is stuck in the final wave).&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish: stop rewriting the same bytes
&lt;/h2&gt;

&lt;p&gt;The publish layer wrote output, then rewrote it: a rename pass, a newline pass, and a standardize pass, each re-reading the whole thing. It was redoing on the driver what the executors had already produced. Writing the final bytes once, straight from the executors, and parallelizing the reconciliation instead of running it serially, took that stage from about &lt;strong&gt;25 minutes to 7&lt;/strong&gt;. No logic changed, just how many times the data got touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outbound packaging and encryption
&lt;/h2&gt;

&lt;p&gt;The outbound job zips files, GPG-encrypts each ZIP, and uploads with SSE-KMS. It ran on Glue Spark with 6 DPUs on G.2X, and it was slow for a dumb reason: everything ran serially on the driver, with Spark only used to build one logging DataFrame at the end. Six DPUs of executors sat idle while the driver zipped one file at a time.&lt;/p&gt;

&lt;p&gt;Compression was the CPU-bound phase, so I threaded just that and dropped DEFLATE to level 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;build_zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compresslevel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compression dropped from about &lt;strong&gt;9 minutes to 4&lt;/strong&gt;. Level 1 does make the zips slightly larger, but that was an easy trade here since runtime was the constraint, not archive size. It's worth knowing which side you're optimizing for: if a downstream had tight storage or bandwidth limits, I'd push the level back up and accept the slower compression. For this pipeline the faster, slightly bigger output won. Giving each batch its own temp key handled the rest.&lt;/p&gt;

&lt;p&gt;Encryption had two traps. GPG compresses before encrypting by default, but the input was already zipped, so it burned CPU re-compressing incompressible data and the output actually grew (one batch went 5246 MB to 5312 MB). &lt;code&gt;--compress-algo none&lt;/code&gt; fixes it. Then parallelizing encryption the way I did compression OOM-killed the driver, because each multi-GB ZIP was read fully into memory alongside an armored copy. I kept encryption serial as the safe landing. The proper fix, staged separately, streams file-to-file with &lt;code&gt;gpg.encrypt_file&lt;/code&gt; so no whole ZIP is in RAM, gives each call its own &lt;code&gt;GNUPGHOME&lt;/code&gt;, and writes unarmored binary (about 33% smaller). The blocker was never concurrency, it was the memory model.&lt;/p&gt;

&lt;h2&gt;
  
  
  SFTP transfer: when Spark is the wrong tool
&lt;/h2&gt;

&lt;p&gt;A separate job pushed large files to an external SFTP endpoint on Glue Spark (6 DPU, G.2X) and did zero distributed work. It was bandwidth-bound at about 11 MB/s on roughly 5 GB files, and raising connector concurrency did nothing, because the ceiling is throughput, not session overhead. Moving it to a Glue Python Shell job at 1 DPU kept the same throughput and cut annual cost from about &lt;strong&gt;$63 to $11&lt;/strong&gt; at $0.44/DPU-hour. A single-stream, IO-bound transfer is a script, not distributed compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two investigations that never became wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A JDBC partition that made every connection full-scan the table.&lt;/strong&gt; The ingestion runtime made no sense: 8.9M rows took 8 minutes, but 25M in prod ran 2h45m and climbing. I assumed skew, but the bucket-distribution query came back near-uniform, which killed that theory. The real cause was that the partition column is a computed expression Oracle can't index, so every connection full-scans the whole window just to find its slice, and at prod scale those concurrent scans blow the buffer cache and stall. It stayed a diagnosis: the fix is to partition on a prunable column, which I haven't implemented and measured yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hang that fixed itself on rerun.&lt;/strong&gt; The same job hung 2h42m one run, then finished in 14 minutes on an identical rerun. The cause was a JDBC socket read with no timeout: one connection died silently and blocked its task forever while the others finished, and Spark can't close a stage until every task returns. Layered timeouts (&lt;code&gt;queryTimeout&lt;/code&gt;, &lt;code&gt;oracle.jdbc.ReadTimeout&lt;/code&gt;, &lt;code&gt;oracle.net.CONNECT_TIMEOUT&lt;/code&gt;) plus speculation break and reschedule a dead read. One trap: &lt;code&gt;oracle.net.CONNECT_TIMEOUT&lt;/code&gt; is milliseconds as a connection property but seconds bare in the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;The wins were all mechanism, not logic: cross a concurrency boundary, stop touching the same bytes twice, parallelize the phase that fits the resource you have headroom in, and right-size the runtime to the work. The investigations were the other half of the lesson, that the bottleneck is almost never where you first guess. Skew looked obvious until one query killed it, and half the real story lived in the source database where the Spark UI can't see it. Mostly it's proving what the bottleneck actually is before changing anything.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>spark</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building an AWS + Snowflake with Terraform: Lessons from My CI/CD Review</title>
      <dc:creator>Kiran Gunturu</dc:creator>
      <pubDate>Mon, 21 Sep 2026 17:46:40 +0000</pubDate>
      <link>https://dev.to/kiran_gunturu_022085015ec/building-an-aws-snowflake-landing-zone-with-terraform-lessons-from-my-cicd-review-1nia</link>
      <guid>https://dev.to/kiran_gunturu_022085015ec/building-an-aws-snowflake-landing-zone-with-terraform-lessons-from-my-cicd-review-1nia</guid>
      <description>&lt;p&gt;Every analytics platform has an unglamorous foundation: the raw/landing tier where source data first lands before anything clever happens to it. It's easy to click that together by hand once. It's much harder to make it reproducible across dev, UAT, and prod and to prove to yourself that what's in prod is what you reviewed.&lt;/p&gt;

&lt;p&gt;So I built the landing tier of a banking-style (FCC/AML) data platform entirely as Terraform: a single root module orchestrating five child modules, with remote state, environment promotion, and a GitLab pipeline gating every change. Then I reviewed my own code as if it were a PR and found a handful of design gaps that are worth more than the happy-path walkthrough. Both halves are below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the stack provisions
&lt;/h2&gt;

&lt;p&gt;One root config wires together five modules across AWS and Snowflake:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2q02ij90j6c7h20pxwg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2q02ij90j6c7h20pxwg.png" alt="Architecture" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdxbqvl13qo1oeza73opt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdxbqvl13qo1oeza73opt.png" alt="Architecture" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why modular, not one big file
&lt;/h2&gt;

&lt;p&gt;The temptation with Terraform is to drop everything into &lt;code&gt;main.tf&lt;/code&gt;. Splitting into &lt;code&gt;aws_vpc&lt;/code&gt;, &lt;code&gt;aws_ec2&lt;/code&gt;, &lt;code&gt;aws_s3&lt;/code&gt;, &lt;code&gt;aws_glue&lt;/code&gt;, and &lt;code&gt;snowflake_database&lt;/code&gt; modules buys three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explicit dependencies.&lt;/strong&gt; The EC2 module consumes the VPC module's &lt;code&gt;subnet_id&lt;/code&gt; and &lt;code&gt;security_group_id&lt;/code&gt; outputs an implicit dependency Terraform resolves for you. The Glue module carries an explicit &lt;code&gt;depends_on = [module.s3_raw]&lt;/code&gt; so the bucket exists before the job references it. S3 and Snowflake share nothing, so they apply in parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse.&lt;/strong&gt; The same VPC module can back a completely different stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewable blast radius.&lt;/strong&gt; A change to the Glue module can't silently touch networking.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F97jt0ylgjolwvzhbm9x7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F97jt0ylgjolwvzhbm9x7.png" alt="Module Dependency graph" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  State that won't corrupt itself
&lt;/h2&gt;

&lt;p&gt;The backend is S3 for the state file plus a DynamoDB table for locking, both encrypted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-tf-state-bucket"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tf_state/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-tf-lock-table"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DynamoDB lock is the part people skip and regret. Without it, two &lt;code&gt;apply&lt;/code&gt;s running at once — a teammate and a CI job, say can race and corrupt state. The lock makes the second one wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promotion by branch, not by copy-paste
&lt;/h2&gt;

&lt;p&gt;Instead of separate root configs per environment, one config selects its variable file from the branch, driven by &lt;code&gt;workflow:rules&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;main&lt;/code&gt; → &lt;code&gt;main.tfvars&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;uat&lt;/code&gt; → &lt;code&gt;uat.tfvars&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;everything else (dev + feature branches) → &lt;code&gt;dev.tfvars&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First match wins, and merge-request events are dropped up front. The pipeline stages are &lt;code&gt;validate → plan → apply → destroy&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftamcvkdnrpvrmqqddfkp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftamcvkdnrpvrmqqddfkp.png" alt="CI/CD Pipeline" width="799" height="161"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;plan&lt;/code&gt; writes a saved plan file and &lt;code&gt;apply&lt;/code&gt; consumes exactly that artifact, so you apply the plan you reviewed - not a fresh one computed against drifted state.&lt;/p&gt;

&lt;p&gt;Here's the pipeline running for real across the dev, uat, and main branches:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdgbkmudoysh97r419br.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdgbkmudoysh97r419br.png" alt="Pipeline Execution" width="800" height="252"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Now the part that actually taught me something
&lt;/h2&gt;

&lt;p&gt;Reviewing this as a PR, three design gaps stood out. These are the interesting bit, because they're the mistakes that look fine until they bite:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Merge requests produce no pipeline at all.&lt;/strong&gt; Because MR events are killed with &lt;code&gt;when: never&lt;/code&gt;, there's no &lt;code&gt;validate&lt;/code&gt; and no &lt;code&gt;plan&lt;/code&gt; at review time. Reviewers approve blind - they never see what the change would actually do to infrastructure. The fix is a dedicated MR job that runs &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; and posts the plan output on the merge request, so approval is based on evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;main&lt;/code&gt; auto-applies to prod with no gate - but prod can't be destroyed.&lt;/strong&gt; The pipeline applies to production automatically on merge to &lt;code&gt;main&lt;/code&gt;, yet &lt;code&gt;destroy&lt;/code&gt; is manual and blocked on &lt;code&gt;main&lt;/code&gt;. That asymmetry is backwards: teardown is protected, but the far riskier &lt;em&gt;unattended apply to prod&lt;/em&gt; isn't. Production apply is exactly where you want a manual approval gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Secrets in &lt;code&gt;*.tfvars&lt;/code&gt;, and a &lt;code&gt;.gitignore&lt;/code&gt; that misses them.&lt;/strong&gt; The variable files carried credentials, and &lt;code&gt;.gitignore&lt;/code&gt; only excluded &lt;code&gt;*.tfvars.json&lt;/code&gt; - so &lt;code&gt;*.tfvars&lt;/code&gt; was tracked. Credentials belong in CI variables or a secrets manager, the variables should be marked &lt;code&gt;sensitive = true&lt;/code&gt;, &lt;code&gt;*.tfvars&lt;/code&gt; should be ignored, and any exposed secret must be rotated and scrubbed from history.&lt;/p&gt;

&lt;p&gt;A few smaller ones: the Glue module hardcoded its job name and script path instead of using the variables passed in, so it pointed at a bucket the stack never created; the pipeline had no &lt;code&gt;terraform fmt&lt;/code&gt; check or security scanner (tfsec/Checkov); and the manual &lt;code&gt;destroy&lt;/code&gt; used &lt;code&gt;--auto-approve&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're standing up a landing zone of your own, the patterns that held up: split into modules with clear input/output contracts, lock your state, and promote one config across environments by variable file rather than duplicating configs. And the lesson the gaps taught me: &lt;strong&gt;the CI/CD design is the real security boundary, not the Terraform.&lt;/strong&gt; A tidy &lt;code&gt;.tf&lt;/code&gt; file with a pipeline that lets prod apply unreviewed and secrets slip into git isn't safe - it just looks safe.&lt;/p&gt;

&lt;p&gt;Terraform makes infrastructure reproducible. Your pipeline is what makes it &lt;em&gt;reviewable&lt;/em&gt;. Get that second part right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full code
&lt;/h2&gt;

&lt;p&gt;If you want to dig into the actual Terraform - all five modules, the GitLab CI pipeline, and the environment variable files - the complete project is here:&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://gitlab.com/kiran.gntdm-group/terraform" rel="noopener noreferrer"&gt;https://gitlab.com/kiran.gntdm-group/terraform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Questions and feedback are welcome - happy to talk through any of the design choices.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>snowflake</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
