Finicky Whiskers (pt. 2): Serving the HTML, CSS, and static assets
Matt Butcher
finicky whiskers
webassembly
microservices
filserver
We recently introduced Finicky Whiskers.
Finicky Whiskers is a browser-based game in which the player attempts to feed the fickle feline Slats the Cat.
One moment Slats wants fish.
A moment later, it’s chicken.
She’s always changing her mind.
Can you satisfy Slats’ hunger before the timer expires?
In this second installment in the series, we’ll look at one of the microservices that Finicky Whiskers uses.
This microservice is not specific to Finicky Whiskers, though.
We also use it on Fermyon.com and it powers part of Spin’s documentation site as well.
This microservice is the Spin Fileserver.
File Serving is Boring… and Really, Really Useful
As we think about microservices, one of the main things we want to avoid is code bloat.
Bigger code is inimical to the idea of the microservice.
In contrast, reusability should be prized highly, especially when it is wholesale reuse of a service.
Back in the days when we built Wagi, the very first piece of “real” software that we wrote was the Wagi fileserver.
We selected a problem that we knew was generic and highly reusable.
At the time, we wrote it in Grain, a brilliant new functional programming language that is WebAssembly-first.
However, as the project grew, we realized that the absence of Grain libraries meant we were going to need to write a lot of new stuff from scratch.
For example, we wanted to add transport-layer file compression with Brotli.
But there are not yet any Brotli libraries for Grain.
We would have had to write and then maintain that code.
Around the same time, we were preparing to introduce Spin.
One of the coolest features of Spin is that, for a select number of languages,
Spin provides an even richer HTTP executor than Wagi’s CGI-based one.
(Spin can also run Wagi components).
The opportunity presented itself, and we wrote a new fileserver, this time for Spin.
While we still have plenty we want to add to this fileserver, it is production grade.
We are running it on Fermyon.com, the Spin docs site, and also – you guessed it – Finicky Whiskers.
File serving is boring. But it’s also useful. And highly re-usable.
How Does It Fit In?
A seasoned developer may ask how the Spin Fileserver counts as a microservice.
Our definition of a microservice is this: A microservice is a service that does just one task or job.
The inspiration for this is roughly the UNIX tooling philosophy.
UNIX (and Linux) tools are frequently written as small utilities, each of which does just one specific thing.
Those can then be grouped together to perform higher-level workflows.
Shell scripting is an example of how several utilities can be sequenced into a workflow.
Spin’s approach to microservices is similar.
Specifically, Spin provides a way to group several microservices under a single Spin instance.
You can think of this as essentially hosting several microservices on a single HTTP server.
They can still use facilities like service discovery to work together (we’ll cover that in a later part of this series).
But when it comes to resource usage, the microservices are efficiently run under a single long-running HTTP server,
which means we can achieve both higher performance and higher service density – both of which combine to save money.
In our view, then, the Spin Fileserver is a microservice.
With Finicky Whiskers, the file server runs on the same Spin server as the other four Finicky Whiskers microservices.
To understand that, let’s take a quick look at a simplified rendition of Finicky Whiskers’ spin.toml
:
spin_version = "1"
name = "finicky-whiskers"
version = "1.0.0"
trigger = { type = "http", base = "/" }
# Serve static files
[[component]]
id = "fileserver"
source = "components/fileserver.wasm"
files = [{ source = "site", destination = "/" }]
[component.trigger]
route = "/..."
# Redirect / to /index.html
[[component]]
id = "redirect-to-index"
source = "components/redirect.wasm"
[component.trigger]
route = "/"
# Tally an individual event
[[component]]
id = "tally"
source = "components/tally.wasm"
[component.trigger]
route = "/tally"
# Initialize session data
[[component]]
id = "session"
source = "session/ruby.wasm"
[component.trigger]
route = "/session"
# Get the scores for a particular game
[[component]]
id = "scoreboard"
source = "components/scoreboard.wasm"
[component.trigger]
route = "/score"
In the slimmed-down version of our spin.toml
above, you can see five microservices defined.
When Spin reads this file and starts, all five are started inside of the same HTTP listener,
which routes incoming traffic based on the route
defined for each component.
And the fileserver
route is special: route = "/..."
.
The ...
means “and anything below this.”
This tells Spin that if any incoming request fails to map to another route, it should match this one.
So a request to /image/slats.jpg
will match this route, while a request for /session
will match the session/ruby.wasm
entry defined in the TOML file.
This ...
syntax is what allows the file server to serve a wide variety of paths.
Thinking about how the Finicky Whiskers application is structured and run, then, we can see from the TOML file above that we can run more efficiently with Spin.
In a traditional microservice architecture, each of these five services must run its own HTTP server.
And in an orchestration system like Kubernetes, that would mean running five separate Deployments
(each with 2-3 replicas).
That’s around 15 Kubernetes pods.
(It’s no wonder cloud has gotten so expensive!)
We can achieve the same uptime and get better performance with Spin running on two or three Nomad workers.
That’s 12 fewer HTTP servers!
A Closer Look at the Spin Fileserver’s Configuration
Above we presented an abbreviated version of the spin.toml
to show how the microservices worked together.
We can take a closer look at the component definition of the fileserver to see how we are using it:
# Serve static files
[[component]]
id = "fileserver"
source = "components/fileserver.wasm"
files = [{ source = "site", destination = "/" }]
[component.trigger]
route = "/..."
In Spin, the id
is just a unique string ID within this particular TOML file.
We named it fileserver
because… well… that’s what it is.
The source
points to the location of fileserver.wasm
,
which is the WebAssembly binary of the Spin Fileserver.
Next, the files
directive is where we map the incoming URLs to a set of paths.
Essentially, the site/
directory in Finicky Whiskers has a whole tree of folders and static files.
We map that (the source
) into a filesystem inside of the WebAssembly execution environment.
This is like Docker’s volume mounts.
So when a request comes in for finickywhiskers.com/images/slats.png
,
the filserver sees this as /images/slats.png
.
It looks in its root (/
) directory for a file called images/slats.png
.
If the file is found, the fileserver returns the file.
Otherwise, the server will generate a 404 Not Found
error.
A Role to Play in Finicky Whiskers
The fileserver plays a basic role in the Finicky Whiskers game.
It serves static files.
At the time of this writing, there are around 30 files served as part of the Finicky Whiskers game.
Most of these must be served before the game can even begin.
Finicky Whiskers is a load generator disguised as a game.
If we are going to do right by the user and meet our performance goals,
then the fileserver has to be breathtakingly fast.
That’s why we are so pleased to see the Google page speed scores for Finicky Whiskers’ fileserver instance:
File serving may be boring, but we must do a good job of it.
The Spin Fileserver, generic and usable in a variety of projects, is the most complex of the components that make up Finicky Whiskers.
But this is one of the virtues of this method of constructing microservices:
We don’t need to worry about that internal complexity.
We can just use the component as-is.
A three-person team built Finicky Whiskers.
None of us had to alter a line of code in the fileserver.
Conclusion
The focus of this second part of the Finicky Whiskers series has been the fileserver.
While we’ll dive into the code of other microservices in later articles,
this one was focused on how we can make use of a ready-made Spin microservices without having to dive into the code.
In the next part we will look at the microservices that comprise gameplay.
As we look, we’ll see how Ruby scripts are run alongside Rust binaries in the same Spin instance.
Read Next
Finicky Whiskers (pt. 3): “The Microservices.”