<?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: Jorel Fermin</title>
    <description>The latest articles on DEV Community by Jorel Fermin (@jorelfermin).</description>
    <link>https://dev.to/jorelfermin</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%2F4085254%2F995c7342-a719-4496-9e30-49e3af5e9a3e.jpg</url>
      <title>DEV Community: Jorel Fermin</title>
      <link>https://dev.to/jorelfermin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jorelfermin"/>
    <language>en</language>
    <item>
      <title>One panic shouldn't kill the scan: isolating rules with catch_unwind</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Tue, 20 Oct 2026 20:48:07 +0000</pubDate>
      <link>https://dev.to/jorelfermin/one-panic-shouldnt-kill-the-scan-isolating-rules-with-catchunwind-14ao</link>
      <guid>https://dev.to/jorelfermin/one-panic-shouldnt-kill-the-scan-isolating-rules-with-catchunwind-14ao</guid>
      <description>&lt;p&gt;Cloud Cost Analyzer runs 92 rules against your cloud account in a single scan. A rule is a real unit of work: it pulls a resource's config and metrics, evaluates them against a cost model, and maybe emits a finding. They're independent, and every one of them leans on libraries I don't control: cloud SDKs, response parsers, date and math crates.&lt;/p&gt;

&lt;p&gt;Any of that can meet an input it didn't anticipate and panic: an &lt;code&gt;unwrap()&lt;/code&gt; that couldn't fail until some resource shape I'd never seen, an index out of bounds deep in a dependency, an overflow on a field that's normally small. Usually nobody wrote bad code. A library just hit an edge case nobody dependency-audited for.&lt;/p&gt;

&lt;p&gt;The question isn't whether a rule will panic. It's what happens to the other 91 when rule&lt;/p&gt;

&lt;h1&gt;
  
  
  57 does. A cost report that dies two-thirds of the way through because one rule met one weird EBS volume isn't much use to anyone.
&lt;/h1&gt;

&lt;h2&gt;
  
  
  "So handle it with &lt;code&gt;Result&lt;/code&gt;"
&lt;/h2&gt;

&lt;p&gt;The obvious objection: this is Rust, so model the failure as a &lt;code&gt;Result&lt;/code&gt; and there's nothing to catch. The rules do exactly that for every failure they can see coming. Can't reach the metrics API? &lt;code&gt;Result&lt;/code&gt;. Missing a field the check needs? &lt;code&gt;Result&lt;/code&gt;. Expected failures travel the normal error path and always have.&lt;/p&gt;

&lt;p&gt;A panic is the failure I didn't foresee. You can't write &lt;code&gt;?&lt;/code&gt; for a bug you don't know exists, and you certainly can't for one buried three crates deep in a dependency that panics on input its author never tested. &lt;code&gt;Result&lt;/code&gt; handles the errors you anticipated. &lt;code&gt;catch_unwind&lt;/code&gt; covers the ones you couldn't, and it only exists in my code because &lt;code&gt;Result&lt;/code&gt; can't reach into code I didn't write.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;std::panic::catch_unwind&lt;/code&gt; runs a closure and hands you back a &lt;code&gt;Result&lt;/code&gt; instead of letting the panic tear through your stack. Here's the real dispatch, one rule at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;eval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;catch_unwind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;AssertUnwindSafe&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="nf"&gt;.evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="nf"&gt;.unwrap_or_else&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;rule_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resource_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="py"&gt;.resource_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"rule panicked during evaluate; skipping its finding"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;None&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule panics, we log which rule and which resource, return &lt;code&gt;None&lt;/code&gt; for that one finding, and the loop moves on to the next rule. One panic costs one finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;AssertUnwindSafe&lt;/code&gt; actually promises
&lt;/h2&gt;

&lt;p&gt;The compiler demands that wrapper, and it's worth understanding why before reaching for it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;catch_unwind&lt;/code&gt; only accepts a closure that's &lt;code&gt;UnwindSafe&lt;/code&gt;. The idea: if a panic unwinds through the middle of your closure, any state it shared with the outside could be left half-updated, and &lt;code&gt;UnwindSafe&lt;/code&gt; is the marker that says there's no such state to observe. My closure captures &lt;code&gt;&amp;amp;rule&lt;/code&gt;, &lt;code&gt;&amp;amp;resource&lt;/code&gt;, and friends by reference, so the compiler can't prove that on its own and refuses to compile.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AssertUnwindSafe&lt;/code&gt; is me telling the compiler I've checked, and that catching this panic won't let anyone observe a broken invariant. That holds here for a concrete reason: on panic I throw the result away. I return &lt;code&gt;None&lt;/code&gt; and never read a partially-built finding. There's no torn state to see because I don't look. That's the bar for reaching for &lt;code&gt;AssertUnwindSafe&lt;/code&gt;. You should be able to point at why the post-panic state is unobservable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting that silently turns all of this off
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;catch_unwind&lt;/code&gt; only works if panics unwind, which is why it isn't a try/catch. Rust has two panic strategies, and you choose per build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cargo.toml — a size-optimized profile&lt;/span&gt;
&lt;span class="c"&gt;# Inherits all release settings, adds panic=abort to remove unwind tables&lt;/span&gt;
&lt;span class="py"&gt;panic&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"abort"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;code&gt;panic = "abort"&lt;/code&gt;, a panic doesn't unwind the stack. It calls the abort handler and kills the process immediately, so &lt;code&gt;catch_unwind&lt;/code&gt; never gets a chance to run. Every one of those careful per-rule guards becomes a no-op, and rule #57 takes the whole process down with it. That happens in the exact build you ship to production, because &lt;code&gt;panic = "abort"&lt;/code&gt; is something you turn on to drop unwind tables and shrink the binary.&lt;/p&gt;

&lt;p&gt;So panic isolation is a whole-binary decision rather than a library feature you add. If you want one bad rule to cost one finding, you have to keep the unwinding runtime, and eat the binary size, on purpose. No assert or &lt;code&gt;catch_unwind&lt;/code&gt; call can buy that back for you. It's the trade you make once, in the profile, and it's invisible in the code that depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more edge: poisoned locks
&lt;/h2&gt;

&lt;p&gt;If a rule panics while holding a &lt;code&gt;Mutex&lt;/code&gt; or &lt;code&gt;RwLock&lt;/code&gt;, that lock is poisoned and the next rule to touch it inherits the panic. The isolation above holds partly because the shared data these rules read is immutable and lock-free, so there's no lock to poison. If yours mutates shared state under a lock, catching the panic isn't enough. You have to decide what a poisoned lock means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;Rules and the second-stage analyzers both run inside that &lt;code&gt;catch_unwind&lt;/code&gt;, under an unwinding profile. When a rule, or a library it leans on, meets an input nobody anticipated, the cost is one missing finding and a log line naming the culprit. The isolation works because the binary is built to allow it, and I can say exactly why the post-panic state is safe to ignore: I never read it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://cca.dragonfractal.com/" rel="noopener noreferrer"&gt;Cloud Cost Analyzer&lt;/a&gt; in Rust.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>errors</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Social cards in pure Rust: no headless Chrome</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Thu, 10 Sep 2026 21:26:54 +0000</pubDate>
      <link>https://dev.to/jorelfermin/social-cards-in-pure-rust-no-headless-chrome-2ok2</link>
      <guid>https://dev.to/jorelfermin/social-cards-in-pure-rust-no-headless-chrome-2ok2</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The dashboard is a single-page app, rendered entirely in the browser (&lt;code&gt;ssr: false&lt;/code&gt;). 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 &lt;code&gt;og:&lt;/code&gt; meta tags, and give up. You get the bare URL in the Slack thread.&lt;/p&gt;

&lt;p&gt;So the crawler gets served something different. A CloudFront function reads the &lt;code&gt;User-Agent&lt;/code&gt; on the viewer request: known bots get 302'd to a small server-rendered &lt;code&gt;/embed&lt;/code&gt; page that carries the Open Graph tags and a &lt;code&gt;meta refresh&lt;/code&gt; back to the real app. Humans get the SPA untouched. The crawler reads a fully-formed page with &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, and friends; a human clicking the same link lands on the interactive dashboard.&lt;/p&gt;

&lt;p&gt;This is user-agent-based dynamic rendering for social crawlers, not SEO cloaking. The &lt;code&gt;/embed&lt;/code&gt; page carries the same title and numbers a human would see, and it &lt;code&gt;meta refresh&lt;/code&gt;es straight to the real app, so nobody is handed content that contradicts what the link actually shows.&lt;/p&gt;

&lt;p&gt;That's the easy half, structurally. Now comes the picture, which is where I had a choice to make.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built the card as an SVG string and rasterized it in-process with &lt;a href="https://crates.io/crates/resvg" rel="noopener noreferrer"&gt;&lt;code&gt;resvg&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;tiny-skia&lt;/code&gt;, both pure Rust. The whole renderer is about ten lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;FONTDB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LazyLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fontdb&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;LazyLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fontdb&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.load_font_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SG_700&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// include_bytes! at compile time&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.load_font_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SG_500&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.load_font_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INTER_600&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;render_png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServiceError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fontdb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FONTDB&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;usvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Tree&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pixmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tiny_skia&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CARD_W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CARD_H&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ServiceError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nn"&gt;resvg&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;tiny_skia&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="nf"&gt;.as_mut&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pixmap&lt;/span&gt;&lt;span class="nf"&gt;.encode_png&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&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;A few things in there I'd do again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fonts are compiled into the binary&lt;/strong&gt; with &lt;code&gt;include_bytes!&lt;/code&gt;. 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LazyLock&amp;lt;Arc&amp;lt;Database&amp;gt;&amp;gt;&lt;/code&gt;&lt;/strong&gt; parses the font data once per process. Each render clones the &lt;code&gt;Arc&lt;/code&gt; (cheap) into its own &lt;code&gt;usvg::Options&lt;/code&gt;, so building a card is string formatting plus a rasterize. That's the whole cost model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The layout is single-column, left-aligned, on purpose.&lt;/strong&gt; &lt;code&gt;resvg&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The handler builds the SVG, calls &lt;code&gt;render_png&lt;/code&gt;, and returns it with a one-hour &lt;code&gt;Cache-Control&lt;/code&gt;. There's a &lt;code&gt;CCA_OG_SAMPLE&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Then I spent an afternoon wondering why everything was in the wrong font.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;font-family&lt;/code&gt; string "more correct" did not help, which took me a while to be okay with.&lt;/p&gt;

&lt;p&gt;The SVG references the font the normal way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"Space Grotesk"&lt;/span&gt; &lt;span class="na"&gt;font-weight=&lt;/span&gt;&lt;span class="s"&gt;"700"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"Space Grotesk"&lt;/span&gt; &lt;span class="na"&gt;font-weight=&lt;/span&gt;&lt;span class="s"&gt;"500"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;strong&gt;own family name&lt;/strong&gt; in the OpenType &lt;code&gt;name&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resvg&lt;/code&gt; (via &lt;code&gt;usvg&lt;/code&gt;) 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 &lt;code&gt;font-family="Space Grotesk"&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix was normalizing the &lt;code&gt;name&lt;/code&gt; table so both faces report the same family and distinguish themselves by weight class instead. A few lines of &lt;code&gt;fonttools&lt;/code&gt; does it:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fontTools.ttLib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TTFont&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TTFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space-grotesk-700.ttf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nameID&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# family name
&lt;/span&gt;        &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Space Grotesk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OS/2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;usWeightClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;700&lt;/span&gt;        &lt;span class="c1"&gt;# keep the weight distinct
&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space-grotesk-700.ttf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both faces belong to one "Space Grotesk" family with &lt;code&gt;usWeightClass&lt;/code&gt; 500 and 700, and &lt;code&gt;font-family="Space Grotesk" font-weight="700"&lt;/code&gt; resolves to exactly the face I meant.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;unless&lt;/em&gt; 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 &lt;code&gt;.ttf&lt;/code&gt;, and I'd rather not find out the hard way.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you're reaching for headless Chrome only to rasterize some text on a background, &lt;code&gt;resvg&lt;/code&gt; plus &lt;code&gt;tiny-skia&lt;/code&gt; is worth an hour of your time before you commit to it. And check your font's &lt;code&gt;name&lt;/code&gt; table before you spend an afternoon wondering why "Space Grotesk" isn't Space Grotesk.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://cca.dragonfractal.com/" rel="noopener noreferrer"&gt;Cloud Cost Analyzer&lt;/a&gt; in Rust.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>opengraph</category>
      <category>resvg</category>
      <category>devops</category>
    </item>
    <item>
      <title>Idle load balancers: the ~$16/month each you forgot to delete"</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 18:35:59 +0000</pubDate>
      <link>https://dev.to/jorelfermin/idle-load-balancers-the-16month-each-you-forgot-to-delete-548f</link>
      <guid>https://dev.to/jorelfermin/idle-load-balancers-the-16month-each-you-forgot-to-delete-548f</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; An Application or Network Load Balancer costs &lt;strong&gt;~$0.0225/hour, about $16/month, just to exist&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why idle load balancers linger
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - List load balancers and their traffic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-load-balancers &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'LoadBalancers[].{Name:LoadBalancerName,Type:Type,ARN:LoadBalancerArn}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an ALB, check request volume over the last 7 days (the metric dimension is the tail of the ARN, e.g. &lt;code&gt;app/my-alb/50dc6c495c0c9188&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudwatch get-metric-statistics &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; AWS/ApplicationELB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metric-name&lt;/span&gt; RequestCount &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dimensions&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;LoadBalancer,Value&lt;span class="o"&gt;=&lt;/span&gt;app/my-alb/50dc6c495c0c9188 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start-time&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'7 days ago'&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--end-time&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--period&lt;/span&gt; 86400 &lt;span class="nt"&gt;--statistics&lt;/span&gt; Sum &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Datapoints[].Sum'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Near-zero request counts over a week is a strong idle signal. (For NLBs, use the &lt;code&gt;AWS/NetworkELB&lt;/code&gt; namespace and &lt;code&gt;ActiveFlowCount&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Check for empty or unhealthy target groups
&lt;/h2&gt;

&lt;p&gt;A load balancer with no healthy targets is doing nothing useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;tg &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws elbv2 describe-target-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;lb-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TargetGroups[].TargetGroupArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$tg&lt;/span&gt;&lt;span class="s2"&gt; =="&lt;/span&gt;
  aws elbv2 describe-target-health &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TargetHealthDescriptions[].TargetHealth.State'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output (no targets) or all &lt;code&gt;unhealthy&lt;/code&gt; alongside near-zero requests is a confident "delete me."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 - Delete safely
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 delete-load-balancer &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;lb-arn&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; a load balancer with no requests isn't &lt;em&gt;always&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-load-balancers &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;lb-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'LoadBalancers[].DNSName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then grep your DNS zones for that name. No references + no traffic + no healthy targets = safe to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it across every account, automatically
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;idle-load-balancer&lt;/code&gt; rule flags load balancers serving fewer than 100 requests/day over a 7-day window, alongside 89 other AWS cost rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://releases.dragonfractal.com/install.sh | sh
cca scan &lt;span class="nt"&gt;--provider&lt;/span&gt; aws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://cca.dragonfractal.com/blog/delete-idle-load-balancers" rel="noopener noreferrer"&gt;Dragon Fractal Cloud Cost Analyzer blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>networking</category>
      <category>finop</category>
      <category>devops</category>
    </item>
    <item>
      <title>Migrate EBS gp2 volumes to gp3 and cut storage cost by about 20%</title>
      <dc:creator>Jorel Fermin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 17:27:10 +0000</pubDate>
      <link>https://dev.to/jorelfermin/migrate-ebs-gp2-volumes-to-gp3-and-cut-storage-cost-by-about-20-2fk7</link>
      <guid>https://dev.to/jorelfermin/migrate-ebs-gp2-volumes-to-gp3-and-cut-storage-cost-by-about-20-2fk7</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; AWS gp3 EBS volumes cost about &lt;strong&gt;~20% less per GB than gp2&lt;/strong&gt; ($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 &lt;strong&gt;online, with no downtime and no snapshot&lt;/strong&gt; with a single &lt;code&gt;modify-volume&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gp3 is cheaper (and usually faster)
&lt;/h2&gt;

&lt;p&gt;gp2 pricing couples performance to its size: you get &lt;strong&gt;3 IOPS per GB&lt;/strong&gt;, so the only way to get more IOPS on gp2 is to over-provision storage you don't need. gp3 removes that coupling:&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;gp2&lt;/th&gt;
&lt;th&gt;gp3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;$0.10 / GB-month&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.08 / GB-month&lt;/strong&gt; (-20%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline IOPS&lt;/td&gt;
&lt;td&gt;3 IOPS/GB (burst to 3,000)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3,000 included&lt;/strong&gt; at any size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline throughput&lt;/td&gt;
&lt;td&gt;scales with size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;125 MB/s included&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra IOPS / throughput&lt;/td&gt;
&lt;td&gt;not possible&lt;/td&gt;
&lt;td&gt;provision independently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Prices are us-east-1 list; the ratio holds across regions.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a 500 GB volume, that's &lt;strong&gt;$50/month to $40/month, i.e., $120/year saved, per volume&lt;/strong&gt;, with equal or better performance. Now multiply this by every gp2 volume in every account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - Find your gp2 volumes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-volumes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume-type,Values&lt;span class="o"&gt;=&lt;/span&gt;gp2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Volumes[].{ID:VolumeId,GiB:Size,AZ:AvailabilityZone,State:State}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it per region. EBS is regional, so a volume only shows up in its own region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;region &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-regions &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Regions[].RegionName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$region&lt;/span&gt;&lt;span class="s2"&gt; =="&lt;/span&gt;
  aws ec2 describe-volumes &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$region&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume-type,Values&lt;span class="o"&gt;=&lt;/span&gt;gp2 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Volumes[].{ID:VolumeId,GiB:Size}'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 - Migrate (online, no downtime)
&lt;/h2&gt;

&lt;p&gt;Converting the type is a live &lt;code&gt;modify-volume&lt;/code&gt;. The volume stays attached and readable/writable the whole time; it briefly enters an &lt;code&gt;optimizing&lt;/code&gt; state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-volume &lt;span class="nt"&gt;--volume-id&lt;/span&gt; vol-0abc123def456 &lt;span class="nt"&gt;--volume-type&lt;/span&gt; gp3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To convert everything in a region at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;vol &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-volumes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume-type,Values&lt;span class="o"&gt;=&lt;/span&gt;gp2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Volumes[].VolumeId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"migrating &lt;/span&gt;&lt;span class="nv"&gt;$vol&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  aws ec2 modify-volume &lt;span class="nt"&gt;--volume-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$vol&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--volume-type&lt;/span&gt; gp3
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The one caveat: high-IOPS / high-throughput volumes
&lt;/h2&gt;

&lt;p&gt;Because gp2 IOPS scale with size, a &lt;strong&gt;large&lt;/strong&gt; 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 &lt;strong&gt;and&lt;/strong&gt; provision matching performance on gp3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-volume &lt;span class="nt"&gt;--volume-id&lt;/span&gt; vol-0abc123def456 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--volume-type&lt;/span&gt; gp3 &lt;span class="nt"&gt;--iops&lt;/span&gt; 6000 &lt;span class="nt"&gt;--throughput&lt;/span&gt; 250
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;VolumeReadOps&lt;/code&gt;/&lt;code&gt;VolumeWriteOps&lt;/code&gt; before assuming you need the headroom. Volumes under ~1 TB with normal workloads are a straight 20% win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it across every account, automatically
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://releases.dragonfractal.com/install.sh | sh
cca scan &lt;span class="nt"&gt;--provider&lt;/span&gt; aws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://cca.dragonfractal.com/blog/migrate-ebs-gp2-to-gp3" rel="noopener noreferrer"&gt;Dragon Fractal Cloud Cost Analyzer blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>finops</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
