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.
1package serve
2
3import (
4 "database/sql"
5 "encoding/json"
6 "fmt"
7 "log"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/aws/aws-sdk-go/aws/session"
14 s3svc "github.com/aws/aws-sdk-go/service/s3"
15 "github.com/go-chi/chi"
16 "github.com/go-chi/chi/middleware"
17 _ "github.com/go-sql-driver/mysql"
18 "github.com/jimmysawczuk/louvre-v1-functions/functions/serve/image"
19 "github.com/jmoiron/sqlx"
20 "github.com/joho/godotenv"
21 "github.com/kelseyhightower/envconfig"
22 "github.com/pkg/errors"
23)
24
25type mysqlConfig struct {
26 ConnectionName string `envconfig:"INSTANCE_CONNECTION_NAME"`
27 Host string `envconfig:"HOST" default:"localhost"`
28 Port int `envconfig:"PORT" default:"3306"`
29 User string `envconfig:"USER" required:"true" default:"root"`
30 Password string `envconfig:"PW"`
31 DB string `envconfig:"DB" required:"true" default:"louvre"`
32}
33
34var cfg struct {
35 // These are set in the cloud functions config.
36 MySQL mysqlConfig `envconfig:"MYSQL" required:"true"`
37 AWSBucket string `envconfig:"AWS_BUCKET" required:"true"`
38 AWSOutputBucket string `envconfig:"AWS_OUTPUT_BUCKET" required:"true"`
39
40 // This is set by the Google Cloud Function environment.
41 GoogleFunctionVersion int `envconfig:"X_GOOGLE_FUNCTION_VERSION"`
42}
43
44var db *sqlx.DB
45var mux *chi.Mux
46var s3 *s3svc.S3
47
48func init() {
49 // We need to expose a http.HandlerFunc
50 // (func (http.ResponseWriter, *http.Request)), but
51 // the incoming URLs might have a few different shapes. I don't
52 // want to write logic to handle all that, so we'll use chi, a
53 // well-known router for Go. Because chi returns an http.Handler,
54 // we can use it to route the request from the Cloud Function.
55 //
56 // Note that because we're just exposing this router via a
57 // package-level function, we have to set up the router in init();
58 // Google Cloud Functions guarantees that init() is run before the
59 // handler is invoked.
60 mux = chi.NewRouter()
61 mux.Use(middleware.Logger)
62 mux.Use(middleware.Recoverer)
63 mux.Use(middleware.RedirectSlashes)
64
65 mux.Get("/", serveVersion)
66 mux.Group(func(r chi.Router) {
67 r.Get(`/{stub:[A-Za-z0-9]{8}}`, serveImage)
68 r.Get(`/{stub:[A-Za-z0-9]{8}}.{ext:\w+}`, serveImage)
69
70 r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x`, serveImage)
71 r.Get(`/{stub:[A-Za-z0-9]{8}}/x{mh:\d+}`, serveImage)
72 r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x{mh:\d+}`, serveImage)
73
74 r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x.{ext:\w+}`, serveImage)
75 r.Get(`/{stub:[A-Za-z0-9]{8}}/x{mh:\d+}.{ext:\w+}`, serveImage)
76 r.Get(`/{stub:[A-Za-z0-9]{8}}/{mw:\d+}x{mh:\d+}.{ext:\w+}`, serveImage)
77 })
78
79 // Set up the environment, connect to MySQL.
80 if err := godotenv.Load(); err != nil {
81 log.Println(err)
82 }
83
84 if err := envconfig.Process("", &cfg); err != nil {
85 log.Panic(err)
86 }
87
88 if conn, err := getMySQL(); err != nil {
89 log.Println(err)
90 } else {
91 db = conn
92 }
93
94 // Create an S3 session for caching image builds to S3.
95 sess := session.Must(session.NewSession())
96 s3 = s3svc.New(sess)
97}
98
99// Mux is the function we'll tell the Google Cloud Function to run. We're just
100// proxying the parameters into the chi router.
101func Mux(w http.ResponseWriter, r *http.Request) {
102 mux.ServeHTTP(w, r)
103}
104
105// serveImage figures out what image we're being asked for, transforms it as
106// necessary, then serves it.
107func serveImage(w http.ResponseWriter, r *http.Request) {
108 stub := chi.URLParam(r, "stub")
109 mw, _ := strconv.ParseInt(chi.URLParam(r, "mw"), 10, 64)
110 mh, _ := strconv.ParseInt(chi.URLParam(r, "mh"), 10, 64)
111 ext := chi.URLParam(r, "ext")
112
113 log.Println("loading image", stub)
114
115 // Get the image metadata from MySQL.
116 img, err := image.FindByStub(db, stub)
117 if err != nil {
118 if errors.Cause(err) == sql.ErrNoRows {
119 http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
120 return
121 }
122
123 http.Error(w, err.Error(), http.StatusInternalServerError)
124 return
125 }
126
127 // Load the image data into an image.Image for transforming.
128 if err := img.LoadOriginal(s3, cfg.AWSBucket); err != nil {
129 http.Error(w, err.Error(), http.StatusInternalServerError)
130 return
131 }
132
133 // Apply the transforms on the loaded image.
134 if err := img.ApplyTransforms(image.Transform{
135 MaxWidth: mw,
136 MaxHeight: mh,
137 }); err != nil {
138 http.Error(w, err.Error(), http.StatusInternalServerError)
139 return
140 }
141
142 // This exposes a way for clients to get metadata about a built image.
143 //
144 // See: https://cdn.section411.com/I0W0QFLE/600x.json
145 // (image: https://cdn.section411.com/I0W0QFLE/600x)
146 if ext == "json" {
147 by, err := img.GetJSON()
148 if err != nil {
149 http.Error(w, err.Error(), http.StatusInternalServerError)
150 return
151 }
152
153 h := img.Headers(by, image.JSON)
154
155 if status, err := img.ServeJSON(w, by, h); err != nil {
156 http.Error(w, err.Error(), status)
157 return
158 }
159
160 if err := img.SaveToS3(s3, cfg.AWSOutputBucket, r.URL.Path, by, h); err != nil {
161 log.Println("error writing json to s3", err)
162 }
163
164 return
165 }
166
167 // Set the output format based on the extension, if provided.
168 if err := img.SetFormat(ext); err != nil {
169 http.Error(w, err.Error(), http.StatusBadRequest)
170 return
171 }
172
173 // Encode the image to the set format.
174 by, err := img.Encode()
175 if err != nil {
176 http.Error(w, err.Error(), http.StatusInternalServerError)
177 return
178 }
179
180 // Write the proper headers, serve the image.
181 h := img.Headers(by, img.Format)
182
183 if status, err := img.Serve(w, by, h); err != nil {
184 log.Println("error serving image", err)
185 http.Error(w, err.Error(), status)
186 return
187 }
188
189 // Save the image to S3. The next time the CDN asks for the image,
190 // it'll ask S3 first, so it'll save us a computation.
191 if err := img.SaveToS3(s3, cfg.AWSOutputBucket, r.URL.Path, by, h); err != nil {
192 log.Println("error writing image to s3", err)
193 }
194}
195
196// serveVersion writes out some version information. Helps for
197// debugging to make sure the right version is deployed/live.
198//
199// See: https://cdn.section411.com
200func serveVersion(w http.ResponseWriter, r *http.Request) {
201 w.Header().Set("Content-Type", "text/plain")
202 w.Header().Set("Cache-Control", "no-cache")
203 w.WriteHeader(http.StatusOK)
204
205 ver, _ := getVersion()
206
207 var target struct {
208 Hex struct {
209 Short string `json:"short"`
210 } `json:"hex"`
211 Date time.Time `json:"date"`
212 }
213 json.NewDecoder(ver).Decode(&target)
214
215 w.Write([]byte(
216 fmt.Sprintf(
217 "louvre; google cloud function v%d; rev. %s (%s)",
218 cfg.GoogleFunctionVersion,
219 target.Hex.Short,
220 target.Date.Format(time.RFC3339),
221 ),
222 ))
223}
224
225func getMySQL() (*sqlx.DB, error) {
226 // omitted for brevity
227 return conn, nil
228}
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├── cmd
2│ └── server
3│ └── main.go # used for testing locally
4└── functions
5 └── serve
6 ├── deploy.bash
7 ├── function.go # entry point
8 ├── go.mod
9 ├── go.sum
10 ├── image # dependency
11 │ ├── format.go
12 │ └── image.go
13 └── 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.