Skip to content

Commit dda994f

Browse files
committed
How I overengineered my preview site
Signed-off-by: Xe Iaso <[email protected]>
1 parent ad6b5de commit dda994f

File tree

3 files changed

+134
-1
lines changed

3 files changed

+134
-1
lines changed

internal/lume/lume.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,5 +686,7 @@ func (f *FS) futureSight(ctx context.Context) error {
686686
return web.NewError(http.StatusOK, resp)
687687
}
688688

689+
slog.Info("deployed to preview site")
690+
689691
return nil
690692
}

lume/src/_includes/blog.njk

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ layout: base.njk
1717
<p class="text-yellow-700 text-sm font-semibold font-['Inter']">This content is exclusive to my patrons. If you are not a patron, please don't be the reason I need to make a process more complicated than the honor system. This will be made public in the future, once the series is finished.</p>
1818
</div>
1919
{% else %}
20-
{{ comp.ads() | safe }}
20+
{% if commit.hash != "development" %}
21+
{{ comp.ads() | safe }}
22+
{% endif %}
2123
{% endif %}
2224

2325
{% if hero %}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
---
2+
title: "Overengineering this blog's preview site with Kubernetes"
3+
date: 2024-06-09
4+
desc: "A small overview on how future-sight, my blog's preview site server, is overengineered with the power of Kubernetes."
5+
hero:
6+
ai: "Photo by Xe Iaso, iPhone 15 Pro Max"
7+
file: sf-ocean-vibes
8+
prompt: "A picture of the rocky shore of the San Francisco Bay on an idyllic sunny day."
9+
---
10+
11+
I write a lot. I also need to have people preview what I write before I publish it. This review step is _essential_ for the much longer articles and my blog engine uses enough fancy features that it makes it basically impossible for most people to be able to look at the source code and visualize what is happening to the rendered HTML.
12+
13+
So, for a very long time my "preview site" has been me running the blog engine in `--devel` mode. `--devel` mode enables a few extra features that aren't relevant for production deployments:
14+
15+
- Automatic rebuild when files change (production rebuilds when ingesting webhooks from GitHub and Patreon)
16+
- Removing the authentication middleware from the GitHub and Patreon webhook endpoints
17+
18+
I exposed this to the world using Tailscale Funnel. This strategy does work and it's gotten me this far, but it comes with a few downsides that in no way relate to Tailscale. Namely, the machine that I'm drafting on needs to be online 24/7 for the preview site to be online 24/7. This wasn't as much of a problem when I was doing most of my drafting on my shellbox, but I've been doing more and more drafting on my MacBook.
19+
20+
<Conv name="Cadey" mood="enby">
21+
Heck, I wrote most of this article on a plane from YYZ to YOW.
22+
</Conv>
23+
24+
This has resulted in situations where I've linked a preview article to someone and then had to go somewhere and then I get a DM complaining that the preview site is down. This is frankly embarrassing and I just want to fix the problem for long enough that I don't have to think about it anymore.
25+
26+
## The zip file of doom
27+
28+
When I made the engine behind Xesite v4, I decided to make all of my rendered assets get served from a zipfile. This was originally intended for making a "preview site" mechanism (and possibly serving Xesite via XeDN), but I just didn't take the time to do it. When you click on any links on my site, chances are that you are triggering Go's [archive/zip](https://pkg.go.dev/archive/zip) package to decompress some HTML or whatever.
29+
30+
I also made a bit of a galaxy brain move to take advantage of Go's custom compressor support to make the zip file be a bag of gzip streams. This was originally intended to be implemented as a speedhack to allow clients that support receiving gzip streams directly to just decompress them inline (most clients do, so this could result in a huge net reduction in CPU and bandwidth). I tried to implement that and found out I'd need to redo like half of Go's HTTP static file serving code from scratch, so I haven't done that yet.
31+
32+
But, as a side effect of having the zipfile be full of gzip streams, this means that the website slug is rather small. About 15 MB on average:
33+
34+
```
35+
$ du -hs var/site.zip
36+
15M var/site.zip
37+
```
38+
39+
This is in the sweet spot where I can reasonably throw it around from my laptop to a server and then serve it directly to the world from there. This would let me retain my writing workflow on my MacBook, but then hand out preview links that don't just evaporate when I have to travel.
40+
41+
# future-sight
42+
43+
I had a bunch of time on a flight from SFO to YYZ, so I decided to slightly overengineer myself a solution. Like any good overengineered solution, it involves protocol buffers, NATS, and Valkey. Here's what it does:
44+
45+
![](https://cdn.xeiaso.net/file/christine-static/blog/2024/future-sight/future-sight.excalidraw.svg)
46+
47+
In a nutshell, whenever I hit save in my editor, I trigger xesite to rebuild my local preview site. This builds `site.zip`, which will get POSTed to one of the future-sight replicae. Once a replica copies `site.zip` locally from the POST request, it takes the SHA256 hash of that file and uploads that to Tigris. It then sends a NATS broadcast to all replicae (including the one that was just being uploaded to), which triggers them to pull the `site.zip` from Tigris and configure that as "active". Finally, the service sets the `site.zip` version as "current" in Valkey so that when replicae restart they can pull the most recent version for free.
48+
49+
It is probably vastly overkill for my needs, but I love how brutally effective this is. I have things wired up so that I can poke any Kubernetes service from my MacBook over WireGuard, so I don't even need to worry about authentication for this.
50+
51+
Hacking this up was kinda fun. I got NATS, Minio, and Valkey running in Kubernetes services before I got on the plane, and then I did the rest of the implementation on the plane. I ended up writing a monster of a script called `port-forward.sh` that started all of the "development" services and port forwarded them so I could use them from my MacBook:
52+
53+
```sh
54+
#!/usr/bin/env bash
55+
56+
kubectl apply -f manifest.dev.yaml
57+
58+
kubectl port-forward -n future-sight svc/nats 4222:4222 &
59+
kubectl port-forward -n future-sight deploy/minio 9000:9000 9001:9001 &
60+
kubectl port-forward -n future-sight svc/valkey 6379:6379 &
61+
62+
wait
63+
```
64+
65+
This was a great decision and I wholeheartedly suggest you try this should you want to set up databases or whatever in a local Kubernetes cluster for development.
66+
67+
<Conv name="Aoi" mood="wut">
68+
But...Docker compose is right there. It's way less YAML even. Why do this to
69+
yourself?
70+
</Conv>
71+
<Conv name="Numa" mood="delet">
72+
Sure, Docker compose is here _right now_, but who knows how long it's going to
73+
last with Kubernetes sucking all of the oxygen out of the room. If you can't
74+
beat 'em, join 'em. Plus at the very least this means that you can use the
75+
same resources in prod, down to the last line of YAML.
76+
</Conv>
77+
78+
Getting this all working was uneventful, modulo getting the AWS S3 library to play nice with Minio. A while ago, AWS transitioned to "hostname-derived bucket URLs", and Minio hard-depends on the legacy path-based behavior. I ended up fixing this by making my S3 client manually and using a `--use-path-style` flag to "unbreak" Minio.
79+
80+
```go
81+
creds := credentials.NewStaticCredentialsProvider(*awsAccessKeyID, *awsSecretKey, "")
82+
83+
s3c := s3.New(s3.Options{
84+
AppID: useragent.GenUserAgent("future-sight-push", "https://xeiaso.net"),
85+
BaseEndpoint: awsEndpointS3,
86+
ClientLogMode: aws.LogRetries | aws.LogRequest | aws.LogResponse,
87+
Credentials: creds,
88+
EndpointResolver: s3.EndpointResolverFromURL(*awsEndpointS3),
89+
//Logger: logging.NewStandardLogger(os.Stderr),
90+
UsePathStyle: *usePathStyle,
91+
Region: *awsRegion,
92+
})
93+
```
94+
95+
<Conv name="Mara" mood="hacker">
96+
The verbose logging support in the S3 client is great, it's what made
97+
debugging all this on a plane possible. It dumps the raw request and response
98+
headers to whatever writer you want.
99+
</Conv>
100+
101+
I run the "production" deployment on my homelab Kubernetes cluster thanks to the power of [`manifest.yaml`](https://github.com/Xe/x/blob/master/cmd/future-sight/manifest.yaml). When you look at the preview site, you're looking at something running across three pods in my homelab. It's a bit overkill, but there's no kill like overkill.
102+
103+
<Conv name="Aoi" mood="coffee">
104+
Did you seriously set the Valkey password to `hunter2`? Anyways, shouldn't this be a Secret instead of a ConfigMap?
105+
106+
```yaml
107+
apiVersion: v1
108+
kind: ConfigMap
109+
metadata:
110+
name: valkey-secret
111+
namespace: future-sight
112+
labels:
113+
app: valkey
114+
data:
115+
VALKEY_PASSWORD: hunter2
116+
```
117+
118+
</Conv>
119+
<Conv name="Numa" mood="happy">
120+
`hunter2` is the best password because all the hackers will see is `******`. Realistically yes this should probably be a secret, but I'm not too worried about it because the valkey instance is only accessible from inside the cluster. If you're in the cluster, you can probably just exec into the pod and get the password anyways. It's not super relevant to secure it.
121+
</Conv>
122+
123+
## Conclusion
124+
125+
Everything worked out in the end. My preview site is now up and running on future-sight and I don't have to think about it. I was easily able to shim it into Xesite so that I could write on my MacBook fearlessly.
126+
127+
In the future I hope to auth-gate the preview site somehow. It'll probably either be by Patreon oauth2 or some kind of "preview token". I shouldn't need to implement this until the preview site leaks something good, so let's not worry about this for now.
128+
129+
I'd also like to implement auto-refresh when an update is pushed. This will require some clever thinking, and may end up with me using WebSockets or something. I'm not sure yet. Ideas are welcome.

0 commit comments

Comments
 (0)