<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Jorel Fermin]]></title><description><![CDATA[Ex-AWS engineer writing practical, no-fluff guides to AWS, cloud infrastructure, and cutting cloud costs.]]></description><link>https://jorelfermin.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a88809ccddad76368cbb487/091e8649-4eb9-466e-a287-3e48eb4a55be.jpg</url><title>Jorel Fermin</title><link>https://jorelfermin.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 17 Oct 2026 15:06:53 GMT</lastBuildDate><atom:link href="https://jorelfermin.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Social cards in pure Rust: no headless Chrome]]></title><description><![CDATA[Cloud Cost Analyzer (CCA) lets you share a read-only scan as a link. Someone drops that link in Slack or on LinkedIn, and instead of a bare URL I wanted it to unfurl into a proper card: the brand mark]]></description><link>https://jorelfermin.hashnode.dev/social-cards-in-pure-rust-no-headless-chrome</link><guid isPermaLink="true">https://jorelfermin.hashnode.dev/social-cards-in-pure-rust-no-headless-chrome</guid><category><![CDATA[Rust]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Jorel Fermin]]></dc:creator><pubDate>Fri, 11 Sep 2026 01:33:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a88809ccddad76368cbb487/76546c4e-e87d-41eb-8b58-7868e4384cbc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cloud Cost Analyzer (CCA) lets you share a read-only scan as a link. Someone drops that link in Slack or on LinkedIn, and instead of a bare URL I wanted it to unfurl into a proper card: the brand mark, the monthly savings we found, the number of findings.</p>
<p>That was two separate problems. Figuring them out took me somewhere I didn't expect, because neither one wanted a headless browser, but both needed a card. One of them even ate an afternoon because the fonts wouldn't cooperate. Here's the whole thing.</p>
<p>The dashboard is a single-page app, rendered entirely in the browser (<code>ssr: false</code>). That's fine for humans. It's not fine for the crawlers Slack, LinkedIn, and X send at your link, because those don't run JavaScript. They fetch the HTML, find an empty shell with no <code>og:</code> meta tags, and give up. You get the bare URL in the Slack thread.</p>
<p>So the crawler gets served something different. A CloudFront function reads the <code>User-Agent</code> on the viewer request: known bots get 302'd to a small server-rendered <code>/embed</code> page that carries the Open Graph tags and a <code>meta refresh</code> back to the real app. Humans get the SPA untouched. The crawler reads a fully-formed page with <code>og:title</code>, <code>og:image</code>, and friends; a human clicking the same link lands on the interactive dashboard.</p>
<p>This is user-agent-based dynamic rendering for social crawlers, not SEO cloaking. The <code>/embed</code> page carries the same title and numbers a human would see, and it <code>meta refresh</code>es straight to the real app, so nobody is handed content that contradicts what the link actually shows.</p>
<p>That's the easy half, structurally. Now comes the picture, which is where I had a choice to make.</p>
<p>The default path for "turn some text on a branded background into a PNG" is to render HTML in headless Chrome and screenshot it. It works. It also means shipping a browser alongside your service, keeping it patched, and paying its cold-start and memory cost on a code path that exists only to make links look nice. In a Rust backend that's a lot of weight to carry for a nice Slack unfurl.</p>
<p>I built the card as an SVG string and rasterized it in-process with <a href="https://crates.io/crates/resvg"><code>resvg</code></a> and <code>tiny-skia</code>, both pure Rust. The whole renderer is about ten lines:</p>
<pre><code class="language-rust">static FONTDB: LazyLock&lt;Arc&lt;usvg::fontdb::Database&gt;&gt; = LazyLock::new(|| {
    let mut db = usvg::fontdb::Database::new();
    db.load_font_data(SG_700.to_vec());   // include_bytes! at compile time
    db.load_font_data(SG_500.to_vec());
    db.load_font_data(INTER_600.to_vec());
    Arc::new(db)
});

fn render_png(svg: &amp;str) -&gt; Result&lt;Vec&lt;u8&gt;, ServiceError&gt; {
    let opt = usvg::Options { fontdb: FONTDB.clone(), ..Default::default() };
    let tree = usvg::Tree::from_str(svg, &amp;opt)?;
    let mut pixmap = tiny_skia::Pixmap::new(CARD_W, CARD_H).ok_or(ServiceError::Render)?;
    resvg::render(&amp;tree, tiny_skia::Transform::identity(), &amp;mut pixmap.as_mut());
    Ok(pixmap.encode_png()?)
}
</code></pre>
<p>A few things in there I'd do again:</p>
<ul>
<li><p><strong>The fonts are compiled into the binary</strong> with <code>include_bytes!</code>. No font files to ship, no system-font lookup that can differ between my laptop and the container. The binary just renders the same card everywhere, full stop.</p>
</li>
<li><p><code>LazyLock&lt;Arc&lt;Database&gt;&gt;</code> parses the font data once per process. Each render clones the <code>Arc</code> (cheap) into its own <code>usvg::Options</code>, so building a card is string formatting plus a rasterize. That's the whole cost model.</p>
</li>
<li><p><strong>The layout is single-column, left-aligned, on purpose.</strong> <code>resvg</code> doesn't expose a text-measurement API - you can't ask it how wide a string is before drawing it. Any centered element would need that. Committing to a left-aligned column means I never have to measure anything at all; the SVG is static positions with the numbers interpolated in.</p>
</li>
</ul>
<p>The handler builds the SVG, calls <code>render_png</code>, and returns it with a one-hour <code>Cache-Control</code>. There's a <code>CCA_OG_SAMPLE</code> env var that dumps a real card to disk so I can eyeball it, and the test just asserts the output starts with the PNG magic bytes and has the right dimensions. That's deliberately not much - I'm not going to pixel-diff a card, but I do want to hear about the day the renderer stops producing a valid PNG.</p>
<p>Then I spent an afternoon wondering why everything was in the wrong font.</p>
<p>The cards looked structurally fine - right size, right colors, text in the right places - but the typeface was not what I wanted. Headings were supposed to be Space Grotesk. They were coming out in resvg's fallback face. Getting the <code>font-family</code> string "more correct" did not help, which took me a while to be okay with.</p>
<p>The SVG references the font the normal way:</p>
<pre><code class="language-xml">&lt;text font-family="Space Grotesk" font-weight="700" ...&gt;
&lt;text font-family="Space Grotesk" font-weight="500" ...&gt;
</code></pre>
<p>The actual problem was upstream, in the font files themselves, which I only found when I finally opened one. I'd grabbed static TTFs from Fontsource, which ships each weight as a separate file - and, the part that got me, each file declares its <strong>own family name</strong> in the OpenType <code>name</code> table. The 700 file is not "Space Grotesk, weight 700." Depending on the face it calls itself "Space Grotesk" for one weight and "Space Grotesk Medium"/"Space Grotesk Light" for others. The filenames and the internal family names don't necessarily agree.</p>
<p><code>resvg</code> (via <code>usvg</code>) matches fonts the CSS way: find a family named exactly "Space Grotesk," then pick the closest weight within that family. When the only family that literally exists in the database is "Space Grotesk Light," a request for <code>font-family="Space Grotesk"</code> matches nothing and silently falls back. No error, no warning - just the wrong font, which looked close enough that I kept debugging the SVG instead of the font.</p>
<p>The fix was normalizing the <code>name</code> table so both faces report the same family and distinguish themselves by weight class instead. A few lines of <code>fonttools</code> does it:</p>
<pre><code class="language-python">from fontTools.ttLib import TTFont
f = TTFont("space-grotesk-700.ttf")
for rec in f["name"].names:
    if rec.nameID in (1, 16):        # family name
        rec.string = "Space Grotesk"
f["OS/2"].usWeightClass = 700        # keep the weight distinct
f.save("space-grotesk-700.ttf")
</code></pre>
<p>Now both faces belong to one "Space Grotesk" family with <code>usWeightClass</code> 500 and 700, and <code>font-family="Space Grotesk" font-weight="700"</code> resolves to exactly the face I meant.</p>
<p>One licensing note, since editing a font's name table can be a violation in some situations: the SIL Open Font License lets you modify and rename fonts freely <em>unless</em> the font declares a Reserved Font Name, in which case you're not allowed to keep that name on a modified copy. I checked - the faces I was using don't reserve one - so collapsing them to a single "Space Grotesk" family is fine. If yours does reserve a name, rename to something new instead. It's thirty seconds to confirm before you ship a modified <code>.ttf</code>, and I'd rather not find out the hard way.</p>
<p>Where it landed: a service that turns a shared scan into a branded PNG in-process, fonts baked into the binary, no browser anywhere in the pipeline. Share a scan link and it unfurls with a card showing the monthly savings and the finding count, generated the same way in every environment because there's nothing external left to disagree with itself.</p>
<p>If you're reaching for headless Chrome only to rasterize some text on a background, <code>resvg</code> plus <code>tiny-skia</code> is worth an hour of your time before you commit to it. And check your font's <code>name</code> table before you spend an afternoon wondering why "Space Grotesk" isn't Space Grotesk.</p>
<hr />
<p><em>This is part of a series on building</em> <a href="https://cca.dragonfractal.com/"><em>Cloud Cost Analyzer</em></a> <em>in Rust.</em></p>
]]></content:encoded></item><item><title><![CDATA[Idle load balancers: the ~$16/month each you forgot to delete]]></title><description><![CDATA[Short version: An Application or Network Load Balancer costs ~\(0.0225/hour, about \)16/month, just to exist, plus capacity units. Classic Load Balancers run ~$18/month. Load balancers outlive the ser]]></description><link>https://jorelfermin.hashnode.dev/idle-load-balancers-the-16-month-each-you-forgot-to-delete</link><guid isPermaLink="true">https://jorelfermin.hashnode.dev/idle-load-balancers-the-16-month-each-you-forgot-to-delete</guid><category><![CDATA[AWS]]></category><category><![CDATA[networking]]></category><category><![CDATA[finops]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Jorel Fermin]]></dc:creator><pubDate>Fri, 21 Aug 2026 21:45:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a88809ccddad76368cbb487/bd3049db-0030-417d-9740-7c405452253b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Short version:</strong> An Application or Network Load Balancer costs <strong>~\(0.0225/hour, about \)16/month, just to exist</strong>, plus capacity units. Classic Load Balancers run ~$18/month. Load balancers outlive the services behind them: the app gets torn down, the ALB keeps billing. Here's how to find load balancers with no real traffic or no healthy targets, and remove them safely.</p>
<h2>Why idle load balancers linger</h2>
<p>The hourly base charge is fixed - an ALB with zero requests bills the same ~$16/month as a busy one. Load balancers are usually created early (with an app or an IaC module) and deleted last, if ever. A handful of abandoned ALBs from old environments is real, recurring money.</p>
<h2>Step 1 - List load balancers and their traffic</h2>
<pre><code class="language-bash">aws elbv2 describe-load-balancers \
  --query 'LoadBalancers[].{Name:LoadBalancerName,Type:Type,ARN:LoadBalancerArn}' \
  --output table
</code></pre>
<p>For an ALB, check request volume over the last 7 days (the metric dimension is the tail of the ARN, e.g. <code>app/my-alb/50dc6c495c0c9188</code>):</p>
<pre><code class="language-bash">aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name RequestCount \
  --dimensions Name=LoadBalancer,Value=app/my-alb/50dc6c495c0c9188 \
  --start-time "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 86400 --statistics Sum \
  --query 'Datapoints[].Sum'
</code></pre>
<p>Near-zero request counts over a week is a strong idle signal. (For NLBs, use the <code>AWS/NetworkELB</code> namespace and <code>ActiveFlowCount</code>.)</p>
<h2>Step 2 - Check for empty or unhealthy target groups</h2>
<p>A load balancer with no healthy targets is doing nothing useful:</p>
<pre><code class="language-bash">for tg in $(aws elbv2 describe-target-groups \
  --load-balancer-arn &lt;lb-arn&gt; \
  --query 'TargetGroups[].TargetGroupArn' --output text); do
  echo "== $tg =="
  aws elbv2 describe-target-health --target-group-arn "$tg" \
    --query 'TargetHealthDescriptions[].TargetHealth.State' --output text
done
</code></pre>
<p>Empty output (no targets) or all <code>unhealthy</code> alongside near-zero requests is a confident "delete me."</p>
<h2>Step 3 - Delete safely</h2>
<pre><code class="language-bash">aws elbv2 delete-load-balancer --load-balancer-arn &lt;lb-arn&gt;
</code></pre>
<p><strong>Caveat:</strong> a load balancer with no requests isn't <em>always</em> dead - it might be a disaster-recovery endpoint, a rarely-hit admin panel, or the target of a DNS record something depends on. Before deleting, check Route 53 (and any external DNS) for records pointing at the load balancer's DNS name, and confirm nothing references it:</p>
<pre><code class="language-bash">aws elbv2 describe-load-balancers --load-balancer-arn &lt;lb-arn&gt; \
  --query 'LoadBalancers[].DNSName' --output text
</code></pre>
<p>Then grep your DNS zones for that name. No references + no traffic + no healthy targets = safe to remove.</p>
<h2>Doing it across every account, automatically</h2>
<p>Pulling CloudWatch for every load balancer by hand doesn't scale. It's one of the checks in a read-only CLI I built, Cloud Cost Analyzer - its <code>idle-load-balancer</code> rule flags load balancers serving fewer than 100 requests/day over a 7-day window, alongside 89 other AWS cost rules:</p>
<pre><code class="language-bash">curl -sSL https://releases.dragonfractal.com/install.sh | sh
cca scan --provider aws
</code></pre>
<p>It runs in your environment with read-only access, so your AWS credentials never leave it. Free tier if you want to try it on one account, and dashboards if you want to review or share reports.</p>
<hr />
<p><em>Originally published on the <a href="https://cca.dragonfractal.com/blog/delete-idle-load-balancers">Dragon Fractal Cloud Cost Analyzer blog</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Migrate EBS gp2 volumes to gp3 and cut storage cost by about 20%]]></title><description><![CDATA[Short version: AWS gp3 EBS volumes cost about ~20% less per GB than gp2 ($0.08 vs $0.10 per GB-month in us-east-1) and includes 3,000 IOPS and 125 MB/s of baseline performance for free. You can conver]]></description><link>https://jorelfermin.hashnode.dev/migrate-ebs-gp2-volumes-to-gp3-and-cut-storage-cost-by-about-20</link><guid isPermaLink="true">https://jorelfermin.hashnode.dev/migrate-ebs-gp2-volumes-to-gp3-and-cut-storage-cost-by-about-20</guid><category><![CDATA[AWS]]></category><category><![CDATA[Devops]]></category><category><![CDATA[finops]]></category><category><![CDATA[Cloud]]></category><dc:creator><![CDATA[Jorel Fermin]]></dc:creator><pubDate>Fri, 21 Aug 2026 17:54:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a88809ccddad76368cbb487/1c8f06c7-3190-4f4e-a2c4-35503518eb92.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Short version:</strong> AWS gp3 EBS volumes cost about <strong>~20% less per GB than gp2</strong> ($0.08 vs $0.10 per GB-month in us-east-1) and includes 3,000 IOPS and 125 MB/s of baseline performance for free. You can convert a volume from gp2 to gp3 <strong>online, with no downtime and no snapshot,</strong> with a single <code>modify-volume</code> call. For most volumes, it's a real savings. Here's how to find those volumes, migrate them, and the one caveat to keep in mind.</p>
<h2>Why gp3 is cheaper (and usually faster)</h2>
<p>gp2 pricing couples performance to its size: you get <strong>3 IOPS per GB</strong>, so the only way to get more IOPS on gp2 is to over-provision storage you don't need. gp3 removes that coupling:</p>
<table>
<thead>
<tr>
<th></th>
<th>gp2</th>
<th>gp3</th>
</tr>
</thead>
<tbody><tr>
<td>Storage</td>
<td>$0.10 / GB-month</td>
<td><strong>$0.08 / GB-month</strong> (-20%)</td>
</tr>
<tr>
<td>Baseline IOPS</td>
<td>3 IOPS/GB (burst to 3,000)</td>
<td><strong>3,000 included</strong> at any size</td>
</tr>
<tr>
<td>Baseline throughput</td>
<td>scales with size</td>
<td><strong>125 MB/s included</strong></td>
</tr>
<tr>
<td>Extra IOPS / throughput</td>
<td>not possible</td>
<td>provision independently</td>
</tr>
</tbody></table>
<p><em>(Prices are us-east-1 list; the ratio holds across regions.)</em></p>
<p>For a 500 GB volume, that's <strong>\(50/month to \)40/month, i.e., $120/year saved, per volume</strong>, with equal or better performance. Now multiply this by every gp2 volume in every account.</p>
<h2>Step 1 - Find your gp2 volumes</h2>
<pre><code class="language-bash">aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[].{ID:VolumeId,GiB:Size,AZ:AvailabilityZone,State:State}' \
  --output table
</code></pre>
<p>Run it per region. EBS is regional, so a volume only shows up in its own region:</p>
<pre><code class="language-bash">for region in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  echo "== $region =="
  aws ec2 describe-volumes --region "$region" \
    --filters Name=volume-type,Values=gp2 \
    --query 'Volumes[].{ID:VolumeId,GiB:Size}' --output text
done
</code></pre>
<h2>Step 2 - Migrate (online, no downtime)</h2>
<p>Converting the type is a live <code>modify-volume</code>. The volume stays attached and readable/writable the whole time; it briefly enters an <code>optimizing</code> state:</p>
<pre><code class="language-bash">aws ec2 modify-volume --volume-id vol-0abc123def456 --volume-type gp3
</code></pre>
<p>To convert everything in a region at once:</p>
<pre><code class="language-bash">for vol in $(aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[].VolumeId' --output text); do
  echo "migrating $vol"
  aws ec2 modify-volume --volume-id "$vol" --volume-type gp3
done
</code></pre>
<p>No snapshot, no detach, no reboot. (AWS allows one modification per volume per 6 hours, so migrate and then wait before modifying the same volume.)</p>
<h2>The one caveat: high-IOPS / high-throughput volumes</h2>
<p>Because gp2 IOPS scale with size, a <strong>large</strong> gp2 volume may already deliver more than gp3's 3,000 baseline IOPS. A 2 TB gp2 volume provides 6,000 IOPS; if your workload actually uses them, migrate <strong>and</strong> provision matching performance on gp3:</p>
<pre><code class="language-bash">aws ec2 modify-volume --volume-id vol-0abc123def456 \
  --volume-type gp3 --iops 6000 --throughput 250
</code></pre>
<p>The first 3,000 IOPS and 125 MB/s are free; beyond that, extra IOPS are \(0.005/provisioned-IOPS-month and extra throughput \)0.040/MB/s-month. Even fully matched, gp3 is usually still cheaper than the equivalent gp2, but check CloudWatch <code>VolumeReadOps</code>/<code>VolumeWriteOps</code> before assuming you need the headroom. Volumes under ~1 TB with normal workloads are a straight 20% win.</p>
<h2>Doing it across every account, automatically</h2>
<p>Finding gp2 volumes and estimating the savings across every account and region by hand doesn't scale. It's one of the checks in a read-only CLI I built, Cloud Cost Analyzer. It flags every gp2 volume with the estimated monthly savings, alongside 89 other AWS cost rules:</p>
<pre><code class="language-bash">curl -sSL https://releases.dragonfractal.com/install.sh | sh
cca scan --provider aws
</code></pre>
<p>It runs in your environment with read-only access, so your AWS credentials never leave it. Free tier if you want to try it on one account, and dashboards if you want to review or share reports.</p>
<hr />
<p><em>Originally published on the</em> <a href="https://cca.dragonfractal.com/blog/migrate-ebs-gp2-to-gp3"><em>Dragon Fractal Cloud Cost Analyzer blog</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>