指令碼化 GeoDNS:離開 AWS 轉向 Bunny
Scriptable GeoDNS: Leaving AWS for Bunny
Bunny DNS 以指令碼化地理路由取代 AWS Route 53,成本大幅下降,本文分享實作與效能對比。
- 成本:Route 53 約 US$0.50/區域/月、US$0.70/百萬查詢、US$0.75/健康檢查,Bunny DNS 500 域名免費,基本費用 US$1/月。
AI 伴隨問答
針對這篇報導有疑問?可直接點選快捷問題或向 AI 追問背景脈絡與技術細節:
To optimize the performance metrics of this blog, I relied on Amazon CloudFront to serve the assets,1 and Route 53 for the geo-aware DNS2 to direct readers to the closest host serving the content. But Route 53 is expensive: US$0.50 per zone per month, US$0.70 per million geolocation queries, and US$0.75 per healthcheck per month, plus US$1 per month for the KMS key needed to sign zones with DNSSEC. When I discovered that Bunny DNS is almost free for up to 500 domains and features scriptable answers, I made the jump! Plus, it’s a European company based in Slovenia.
Bunny CDN#
Bunny.net started as BunnyCDN in 2015. They added IPv6
support in 2017. Switching was uneventful. I don’t bring a custom domain: I
serve the content from the b-cdn.net domain. As I depend on content
negotiation to serve AVIF and WebP images, I configure the CDN to cache
each image format separately.
I disable logging to avoid collecting personal information. While the bunny.net GDPR page promises they anonymize data, it is unclear what CloudFront keeps in its own logs when logging is disabled.
The only downside of the bunny.net CDN offering is the lack of HTTP/3. I asked their support: two minutes later, they answered they are working on it but have no ETA to share.
Bunny DNS#
Bunny.net introduced their DNS offering in 2022 and recently reduced its cost to almost zero. They support geographical routing, but a very cool feature is the scriptable DNS: it runs a piece of user-supplied JavaScript on each query to compute the answer.
I run seven small NixOS virtual machines to host this blog around the world: Paris, Nuremberg, Ashburn, Singapore, San Jose, São Paulo, and Sydney. Bunny’s scriptable DNS lets me return the IP addresses of the two nearest servers. The browser falls back to the second server if the first one is down.
const servers = [
{
ipv4: "45.90.160.60",
ipv6: "2a0c:8881:5:100::1",
latitude: 48.88,
longitude: 2.22,
},
/* […] */
{
ipv4: "139.180.162.114",
ipv6: "2001:19f0:5800:1855:5400:6ff:fef0:4c0a",
latitude: -33.87,
longitude: 151.21,
},
];
const families = { A: "ipv4", AAAA: "ipv6" };
const records = { A: ARecord, AAAA: AaaaRecord };
export default function handleQuery(query) {
const type = String(query.request.queryType).toUpperCase();
const family = families[type];
if (!family) return [];
// Get user location
const location = query.request.geoLocation;
// Build a list of candidates
let candidates = servers.map(
(s) => new Server(s[family], s.latitude, s.longitude, 100),
);
// Select the two closest alive servers
const selected = [];
while (selected.length < 2) {
const server = RoutingEngine.getClosestServer(candidates, location, true);
selected.push(server);
candidates = candidates.filter((s) => s.ip !== server.ip);
}
// Return their IP addresses
return selected.map((s) => new records[type](s.ip, 3600));
}
The above script relies on the
RoutingEngine.getClosestServer() helper, which returns the
closest alive3 server to the user.4 Thanks to Doggo, we can
check it works as expected, at least for A:5
$ doggo --gp-from="Amsterdam" A vincent.bernat.ch
LOCATION NAME TYPE TTL ADDRESS
Amsterdam, NL
vincent.bernat.ch. A 3600s 45.90.160.60
vincent.bernat.ch. A 3600s 116.203.18.48
$ doggo --gp-from="Texas" A vincent.bernat.ch
LOCATION NAME TYPE TTL ADDRESS
Houston (TX), US
vincent.bernat.ch. A 3600s 5.161.44.145
vincent.bernat.ch. A 3600s 149.28.219.155
The most annoying drawback of this method is that a user from Buenos Aires gets both São Paulo (1000 mi, 30 ms RTT) and Ashburn (5200 mi, 140 ms RTT). Most resolvers randomize the order, so the user may pull the web page from Ashburn. We could return only the nearest server—and swap the scripted record with the built-in geographic routing instead. But if the server becomes unavailable, the browser has no alternative until the DNS cache expires. It leaves a short window of unavailability, even with a short TTL.
The HTTPS record solution#
The HTTPS DNS record, defined in RFC 9460, solves both the availability and the latency issues. Modern browsers query this record to get additional information to reach an HTTP server, including an alternative port and the application layer protocol—HTTP/2 or HTTP/3. Each record has a priority. We can ask browsers to contact the nearest server first and fall back to the next one if the first is unavailable:
vincent HTTPS 1 first.geo alpn="h3,h2"
vincent HTTPS 2 second.geo alpn="h3,h2"
vincent HTTPS 3 . alpn="h3,h2"
; Bunny DNS must provide the two closest servers as "first.geo"
; and "second.geo" with geographic or latency routing.
first.geo A 45.90.160.60
first.geo AAAA 2a0c:8881:5:100::1
second.geo A 116.203.18.48
second.geo AAAA 2a01:4f8:1c0c:5eb5::1
vincent A 45.90.160.60
vincent A 116.203.18.48
vincent AAAA 2a0c:8881:5:100::1
vincent AAAA 2a01:4f8:1c0c:5eb5::1
This looks like the ideal solution! Unfortunately, Chrome ignores any record
whose target is not ., hence the last record at priority 3.
Moreover, unlike with a CNAME, Bunny DNS does not return the IP addresses for
first.geo and second.geo in the additional section. The resolver needs to
send a first request to get the HTTPS record, and a second to resolve
first.geo.
HTTPS records are still too new for this use case. Back to the drawing board! 📐
The hybrid solution#
As a compromise, the script returns the two nearest servers if they are at a similar distance from the user. Otherwise, it returns only the nearest one with a lower TTL of 2 minutes.
export default function handleQuery(query) {
const location = query.request.geoLocation;
let candidates = servers.map(
(s) => new Server(s.ipv4, s.latitude, s.longitude, 100),
);
// Select the two closest servers
const selected = [];
while (selected.length < 2) {
const server = RoutingEngine.getClosestServer(candidates, location, true);
selected.push(server);
candidates = candidates.filter((s) => s.ip !== server.ip);
}
// Compute the distance to the user for each of them
const [first, second] = selected.map((s) =>
GeoDistance.calculate(s, location),
);
// If the second one is at twice the distance and at more than 1000 miles
// from the first, return only the first one with a TTL of 2 minutes.
if (second > 2 * first && second - first > 1000) {
return [new ARecord(selected[0].ip, 120)];
}
// Otherwise, return both with a TTL of 1 hour.
return selected.map((s) => new ARecord(s.ip, 3600));
}
We can check it with Doggo. From Chicago, we get only the Ashburn server with a TTL of 2 minutes, while from Mexico, we get both Ashburn and San Jose.
$ doggo --gp-from="Chicago" A vincent.bernat.ch
LOCATION NAME TYPE TTL ADDRESS
Chicago (IL), US
vincent.bernat.ch. A 120s 5.161.44.145
$ doggo --gp-from="Mexico" A vincent.bernat.ch
LOCATION NAME TYPE TTL ADDRESS
Queretaro, MX
vincent.bernat.ch. A 3600s 5.161.44.145
vincent.bernat.ch. A 3600s 149.28.219.155
$ doggo --reverse 5.161.44.145 149.28.219.155
NAME TYPE TTL ADDRESS
145.44.161.5.in-addr.arpa. PTR 7187s web05.luffy.cx.
155.219.28.149.in-addr.arpa. PTR 3600s web07.luffy.cx.
Note
Enable JavaScript to make the map below interactive: point at a location to get the DNS answer, or disable servers to see how the answers change.
This page was brought to you by ???. Check on the map below whether it matches your
location!
The complete code is a bit more complex. It works around
RoutingEngine.getClosestServer() supporting only IPv4, and it handles the case
where there are not enough alive servers to build an answer.
Bunny.net has a comprehensive Terraform provider. I leverage CDK Terrain to build the target state in TypeScript and OpenTofu to deploy it. Have a look at “Managing infrastructure with Terraform, CDKTF, and NixOS” for more details.6
ACME DNS with Hickory DNS#
Route 53 covered one additional use case of mine: serving acme.luffy.cx to
solve DNS-01 ACME challenges. Unlike Route 53, Bunny DNS cannot
restrict access to a single zone with a fine-grained access policy. As this
usage requires neither high availability nor low latency, I replaced Route 53
with Hickory, a memory-safe DNS server written in Rust. It supports
automatic DNSSEC signatures and dynamic updates. Its
configuration is simple:
[[zones]]
zone = "acme.luffy.cx"
zone_type = "Primary"
[zones.stores]
allow_update = true
type = "sqlite"
journal_path = "acme.luffy.cx.sqlite"
zone_path = "acme.luffy.cx.zone"
# Private key to sign the zone with DNSSEC. Generated with:
# openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256
[[zones.keys]]
algorithm = "ECDSAP256SHA256"
key_path = "/etc/hickory-dns.acme.luffy.cx.pem"
# TSIG key to sign zone updates. Generated with:
# openssl rand 32
[[zones.stores.tsig_keys]]
algorithm = "hmac-sha256"
key_file = "/etc/hickory-dns.acme.luffy.cx.update.acme.luffy.cx.tsig"
name = "update.acme.luffy.cx."
The provided zone file only seeds the database on first start. I delegate
DNS-01 challenges to this zone with CNAME records:
_acme-challenge CNAME bernat.ch.acme.luffy.cx.
_acme-challenge.vincent CNAME vincent.bernat.ch.acme.luffy.cx.
Lego, the ACME client I use to get certificates from Let’s Encrypt, answers the DNS-01 challenge by pushing entries with the DNS UPDATE protocol.
Unfortunately, Bunny DNS cannot publish a DS record in a zone.7 I need one
to secure the acme.luffy.cx zone and certificate delivery. Therefore, I host
the luffy.cx zone on deSEC,8 a free DNS hosting service run by a
non-profit organization based in Berlin. I derive the DS record to publish in
luffy.cx from the DNSKEY record of acme.luffy.cx:
$ ZONE=acme.luffy.cx
$ dig @nsupdate.luffy.cx $ZONE DNSKEY +noall +answer \
> | dnssec-dsfromkey -a SHA-256 -f - $ZONE
acme.luffy.cx. IN DS 55628 13 2 4946E7508F20E15F1B577A231104B029A1A82E04699A80CD46ADCE31227EE81F
Overall, I am quite happy with the switch to bunny.net. There are still a few
rough edges, like the inability to publish DS records, the lack of HTTP/3, and
the IPv4-only RoutingEngine.getClosestServer() helper. The bill went from US$5
per month to US$1. So far, performance is on par with AWS, but I
only finalized the current setup, including the server in Sydney, in late
October. I need a bit more time to gather more data, and I’ll post a short update. 📈