Louvre, version 1.1 — How Section 411 went serverless (mostly)

Louvre, version 1.1

How Section 411 went serverless (mostly)

By jimmy@section411.com
By Jimmy Sawczuk
Published · 14 min. read

Quinten de Graaf, Unsplash

A little over two years ago, I launched Section 411. In a post that made the launch official, published about a month after the site went live, I formally introduced the site, writing about the new name and some of the technology that powered it. I wrote that unlike its predecessor, Section 411 was built using a static site generator (Hugo) instead of relying on something to render the pages in real time. I also wrote about Louvre, an image manager and processor I built that could dynamically serve images to a CDN, solving what’s typically a pain point for static site generators.

I’m pleased to say that over the last two years, while I’ve certainly made my fair share of design tweaks and updates, I’m still really happy with the overall architecture of Section 411. I’ve even been able to transition Section 411 from an Apache server to a Netlify instance, removing my need to run a traditional HTTP server to serve the site.

But ever since I first launched Section 411, I wasn’t very happy with how Louvre turned out. I knew I’d need a decent image manager to make Section 411 possible, but I also knew I didn’t want to spend a ton of time on it. So after a few starts and stops over that summer before launching, I finally decided to write the first version of Louvre as a Laravel (PHP) application. Engineering is all about trade-offs, I told myself, and writing Louvre in Laravel would help me get it done quicker and thus allow me to focus on the rest of Section 411.

As an image manager, Louvre was and still is just fine. The interface isn’t amazing (and it’s still only half done), but it lets me upload, transform and crop images. My main displeasure was with Louvre’s image processor. As a PHP application, it requires a full HTTP server to be running all the time, waiting for traffic, but for the most part, it sits idle. When traffic does finally come in, it tends to come in surges. Section 411’s homepage is covered with images, and all it takes is one user to hit the site with a cold CDN cache for Louvre to be inundated with simultaneous and time-sensitive requests. A powerful server, especially sitting behind a CDN, could handle this with no problem. But I didn’t want to pay for a powerful server to sit idle most of the time. So instead, Louvre lived on a tiny server that was never properly utilized: it either sat idle, or was overwhelmed.


One of the hottest trends in backend engineering today are serverless functions. Amazon Web Services, one of the first companies to enter the serverless market, brands their product as Lambda. Here’s how Amazon describes it:

With Lambda, you can run code for virtually any type of application or backend service - all with zero administration. Just upload your code and Lambda takes care of everything required to run and scale your code with high availability. You can set up your code to automatically trigger from other AWS services or call it directly from any web or mobile app.

The first two sentences make it easy to see why this concept is intriguing, even if the term “serverless” is probably going a little too far. (If Jerry Seinfeld were to ever go on stage with a bit on “serverless,” the punchline would undoubtedly be “there’s gotta’ be a server somewhere!”) Writing applications is hard enough; deploying them can be even harder because it often requires a completely different skillset. The fewer obstacles there are between the code being on my laptop and the code being deployed, the better.

But it’s the last sentence that got me thinking about using serverless functions for Louvre. A traditional application can literally do anything, but because of that flexibility, its behavior can be complicated. Modeling what an application might be doing at any given moment would probably involve a fairly complicated flowchart. Lambdas, on the other hand, are essentially just functions: inputs and outputs. Their behavior is well-defined and linear. This simplicity gives Lambdas incredible versatility in that they can be plugged in to a variety of inputs and chained together to offer the same functionality as a traditional application but with all of the state management complexity abstracted away. It’s a nod to old-school Unix-style programs that only have one or two primary features but can be so powerful when chained together on the command line.

My idea was to leave the image manager part of Louvre alone and let that live on as a Laravel app for as long as necessary. What I’d focus on was the part that wasn’t working well: the image processor. As a serverless function, I could focus on the inputs (an HTTP request) and the outputs (images). All I’d have to do was write a function that parses a request URL to look up an image, run any transforms needed, and then serve the image. I’d also leave it behind a CDN to keep things speedy.


After a little research, I ended up choosing Google Cloud Functions over AWS Lambda for two reasons. First, Cloud Functions can respond to HTTP requests out of the box, where Lambdas require you to set up an API Gateway or Cloudfront to actually capture the request and forward it to the Lambda. This wasn’t a dealbreaker, but it meant there’d be extra work to get the Lambda deployed properly. Secondly, while both Lambda and Cloud Functions support Go, Cloud Functions does it more natively. With Cloud Functions, you only need to open a file, write an http.HandlerFunc, copy it into your Cloud Function config, and finally specify it as your “Function to execute”.

Here’s how I wrote the function to serve Louvre images, with some annotations.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
package serve

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go/aws/session"
	s3svc "github.com/aws/aws-sdk-go/service/s3"
	"github.com/go-chi/chi"
	"github.com/go-chi/chi/middleware"
	_ "github.com/go-sql-driver/mysql"
	"github.com/jimmysawczuk/louvre-v1-functions/functions/serve/image"
	"github.com/jmoiron/sqlx"
	"github.com/joho/godotenv"
	"github.com/kelseyhightower/envconfig"
	"github.com/pkg/errors"
)

type mysqlConfig struct {
	ConnectionName string `envconfig:"INSTANCE_CONNECTION_NAME"`
	Host           string `envconfig:"HOST" default:"localhost"`
	Port           int    `envconfig:"PORT" default:"3306"`
	User           string `envconfig:"USER" required:"true" default:"root"`
	Password       string `envconfig:"PW"`
	DB             string `envconfig:"DB" required:"true" default:"louvre"`
}

var cfg struct {
	// These are set in the cloud functions config.
	MySQL           mysqlConfig `envconfig:"MYSQL" required:"true"`
	AWSBucket       string      `envconfig:"AWS_BUCKET" required:"true"`
	AWSOutputBucket string      `envconfig:"AWS_OUTPUT_BUCKET" required:"true"`

	// This is set by the Google Cloud Function environment.
	GoogleFunctionVersion int `envconfig:"X_GOOGLE_FUNCTION_VERSION"`
}

var db *sqlx.DB
var mux *chi.Mux
var s3 *s3svc.S3

func init() {
	// We need to expose a http.HandlerFunc
	// (func (http.ResponseWriter, *http.Request)), but
	// the incoming URLs might have a few different shapes. I don't
	// want to write logic to handle all that, so we'll use chi, a
	// well-known router for Go. Because chi returns an http.Handler,
	// we can use it to route the request from the Cloud Function.
	//
	// Note that because we're just exposing this router via a
	// package-level function, we have to set up the router in init();
	// Google Cloud Functions guarantees that init() is run before the
	// handler is invoked.
	mux = chi.NewRouter()
	mux.Use(middleware.Logger)
	mux.Use(middleware.Recoverer)
	mux.Use(middleware.RedirectSlashes)

	mux.Get("/", serveVersion)
	mux.Group(func(r chi.Router) {
		r.Get(`/{stub:[A-Za-z0-9]{8}}`, serveImage)
		r.Get(`/{stub:[A-Za-z0-9]{8}}.{ext:\w+}`, serveImage)

		r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x`, serveImage)
		r.Get(`/{stub:[A-Za-z0-9]{8}}/x{mh:\d+}`, serveImage)
		r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x{mh:\d+}`, serveImage)

		r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x.{ext:\w+}`, serveImage)
		r.Get(`/{stub:[A-Za-z0-9]{8}}/x{mh:\d+}.{ext:\w+}`, serveImage)
		r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x{mh:\d+}.{ext:\w+}`, serveImage)
	})

	// Set up the environment, connect to MySQL.
	if err := godotenv.Load(); err != nil {
		log.Println(err)
	}

	if err := envconfig.Process("", &cfg); err != nil {
		log.Panic(err)
	}

	if conn, err := getMySQL(); err != nil {
		log.Println(err)
	} else {
		db = conn
	}

	// Create an S3 session for caching image builds to S3.
	sess := session.Must(session.NewSession())
	s3 = s3svc.New(sess)
}

// Mux is the function we'll tell the Google Cloud Function to run. We're just
// proxying the parameters into the chi router.
func Mux(w http.ResponseWriter, r *http.Request) {
	mux.ServeHTTP(w, r)
}

// serveImage figures out what image we're being asked for, transforms it as
// necessary, then serves it.
func serveImage(w http.ResponseWriter, r *http.Request) {
	stub := chi.URLParam(r, "stub")
	mw, _ := strconv.ParseInt(chi.URLParam(r, "mw"), 10, 64)
	mh, _ := strconv.ParseInt(chi.URLParam(r, "mh"), 10, 64)
	ext := chi.URLParam(r, "ext")

	log.Println("loading image", stub)

	// Get the image metadata from MySQL.
	img, err := image.FindByStub(db, stub)
	if err != nil {
		if errors.Cause(err) == sql.ErrNoRows {
			http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
			return
		}

		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Load the image data into an image.Image for transforming.
	if err := img.LoadOriginal(s3, cfg.AWSBucket); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Apply the transforms on the loaded image.
	if err := img.ApplyTransforms(image.Transform{
		MaxWidth:  mw,
		MaxHeight: mh,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// This exposes a way for clients to get metadata about a built image.
	//
	// See: https://cdn.section411.com/I0W0QFLE/600x.json
	// (image: https://cdn.section411.com/I0W0QFLE/600x)
	if ext == "json" {
		by, err := img.GetJSON()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		h := img.Headers(by, image.JSON)

		if status, err := img.ServeJSON(w, by, h); err != nil {
			http.Error(w, err.Error(), status)
			return
		}

		if err := img.SaveToS3(s3, cfg.AWSOutputBucket, r.URL.Path, by, h); err != nil {
			log.Println("error writing json to s3", err)
		}

		return
	}

	// Set the output format based on the extension, if provided.
	if err := img.SetFormat(ext); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Encode the image to the set format.
	by, err := img.Encode()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Write the proper headers, serve the image.
	h := img.Headers(by, img.Format)

	if status, err := img.Serve(w, by, h); err != nil {
		log.Println("error serving image", err)
		http.Error(w, err.Error(), status)
		return
	}

	// Save the image to S3. The next time the CDN asks for the image,
	// it'll ask S3 first, so it'll save us a computation.
	if err := img.SaveToS3(s3, cfg.AWSOutputBucket, r.URL.Path, by, h); err != nil {
		log.Println("error writing image to s3", err)
	}
}

// serveVersion writes out some version information. Helps for
// debugging to make sure the right version is deployed/live.
//
// See: https://cdn.section411.com
func serveVersion(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.Header().Set("Cache-Control", "no-cache")
	w.WriteHeader(http.StatusOK)

	ver, _ := getVersion()

	var target struct {
		Hex struct {
			Short string `json:"short"`
		} `json:"hex"`
		Date time.Time `json:"date"`
	}
	json.NewDecoder(ver).Decode(&target)

	w.Write([]byte(
		fmt.Sprintf(
			"louvre; google cloud function v%d; rev. %s (%s)",
			cfg.GoogleFunctionVersion,
			target.Hex.Short,
			target.Date.Format(time.RFC3339),
		),
	))
}

func getMySQL() (*sqlx.DB, error) {
	// omitted for brevity
	return conn, nil
}

Here are a few takeaways, in case that code looks like gibberish or you just skimmed it. First, yes, I know I’m writing to an Amazon S3 bucket on a Google Cloud Function. I’m a monster. Second, the http.Handler interface continues to be one of the greatest things in the Go standard library. Here, it lets us effortlessly shim a production-grade router into the entrypoint for the Cloud Function. And finally, the handler that actually serves the image is kind of long, but I still think it’s pretty simple because it’s completely linear: the input is always an *http.Request that’s been pre-routed, the output is some sort of HTTP response.

My biggest issue with Google Cloud Functions was related to its handling of Go, and how it lets you write your handler as a normal http.HandlerFunc. Go is a statically-compiled language, which means that any code that’s part of your program has to be there as part of the compile. There’s no way to take Go code and bolt it into a precompiled or running program, unless you do something like compile the new code into a separate program and execute that program from your first program. (This is what Lambda does.)

So when you upload your code with your HandlerFunc, Cloud Functions combines it with some common code that can invoke it, and compiles the whole thing to use as your function. This is similar to how go test works, and for the most part, it’s not a problem. It only becomes a problem if you ever need to compare line numbers in stack traces (like if your handler panics) to your code. The additional code that Google has added means the line numbers won’t match, so you’ll have to use your logging to figure out what’s breaking.

This fact that Google combines your code with code of its own means you might not be able to lay out your repository the way you normally would. I eventually landed on this structure, which probably looks strange to you if you’ve written Go for a while:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
├── cmd
│   └── server
│       └── main.go # used for testing locally
└── functions
    └── serve
        ├── deploy.bash
        ├── function.go # entry point
        ├── go.mod
        ├── go.sum
        ├── image # dependency
        │   ├── format.go
        │   └── image.go
        └── version.go

Because each function is self-contained, it needs to resolve its own dependencies, so we have to put the go.mod and go.sum files in the directory with the function. Putting these files in a non-root directory is definitely not normal, but fortunately Go modules seem to be more flexible than $GOPATH-based dependency managers. What’s stranger is putting a library or shared package (image) underneath the main serve package, but I couldn’t find a way to make it work with image being in a more common directory, apart from splitting off the image package into its own repository and importing it via Go modules.


Overall, I was really happy with how this project turned out. The entire process of moving Louvre to a serverless platform took maybe six or seven hours total, starting with absolutely nothing and ending with the function deployed to production. I didn’t have to do any additional work to get the function to run concurrently; that was a benefit I just got for free. The biggest win for me was that I got to spend most of my time focusing on my image processing code, rather than working on the infrastructure to get it deployed. I spent my time solving the problem I was trying to solve, not fighting with infrastructure.

By now, you might be wondering: what does all this cost? The database that powers Louvre is unchanged, and it’s still the vast majority of the total monthly bill. The CDN (Cloudfront) is also unchanged, and at Section 411’s current traffic levels, the CDN bill is pretty cheap. This solution makes a little more use of S3, but it’s not much more, and the total amount of data Louvre has in S3 is less than 10 GB, meaning my S3 storage bill is no more than $0.30 a month.

The only real change to the infrastructure is the usage of a Google Cloud Function. I took an hour or so one night to try to understand the pricing page, and after a lot of math I estimated my costs for Cloud Functions would be somewhere between $5 and $10 a month.

So here’s the short answer on how much all this costs: exactly $0 more than before. I forgot to account for the fact that you’re not charged for Google Cloud Functions until you exceed the free tier. Turns out the Internet is pretty cheap when it’s serverless.


Thanks to Sara Sawczuk for reading a draft of this post and catching a critical bug on line 134 of my code.