Go Serverless
From POC to Prod
27 August 2018
Steven Bogacz
Software Engineer at SendGrid
Steven Bogacz
Software Engineer at SendGrid
Let's work toward them with a toy app
2We want a lightweight, ephemeral storage API
net/http library. json.Encode-able object, which precludes using stdlib (unexported fields)aws-lambda-go/events has objects for proxy API Gateway Requests/ResponsesIn our case, that looks like
Handler(*events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error)
AWS Lambda Handler Documentation
5
Using the events.APIGatewayProxyRequest type directly:
switch req.HTTPMethod { case "POST": key, err = postFile(ctx, req.Body) case "GET": key, err = extractKey(req) if err != nil { return nil, err } data, err = getFile(ctx, key) case "DELETE": key, err = extractKey(req) if err != nil { return nil, err } err = deleteFile(ctx, key) }
Some decoupling:
func postFile(ctx context.Context, data string) (string, error) { u, err := uuid.NewV4() if err != nil { return "", newInternalServerErr(err, "failed to generate key") }
Sub-functions don't know about APIGW-specific types
7Using a normal testing approach, we can unit test some helpers
func TestHelpers(t *testing.T) { t.Run("extract key should parse the path correctly", func(t *testing.T) { req := &events.APIGatewayProxyRequest{ Path: "blobs/1234", } key, err := extractKey(req) require.NoError(t, err) require.Equal(t, "1234", key) }) // ... }
This makes us use dummy input, and doesn't give us great coverage
8We use Terraform (a cloud-agnostic Infrastructure-as-Code tool)
Initial deploy
terraform apply phase1.plan 33.54s
Subsequent plan and apply
terraform plan --out=phase1.plan 12.54 terraform apply phase1.plan 19.12
Can take a look at:
Supports several locally running versions of AWS Services
- API Gateway
- DynamoDB
- Kinesis
- S3
- etc.
Configuring our deployment isn't exactly trivial.
Could use terraform, but there's a current issue open to get the AWS fakes to work:
Can have set up scripts to run before we run our tests, e.g. make test spins up, go test s, spins down.
Downsides:
Ask how would I do this as idiomatically as possible?
net/http is Go's bread and buttertree . ├── config.go ├── errors.go ├── errors_test.go ├── main.go ├── main_test.go ├── toy └── toy.zip 0 directories, 7 files
tree . ├── cmd │ ├── http │ │ ├── main.go │ │ └── toy │ └── lambda │ └── main.go ├── internal │ ├── httperrs │ │ ├── errors.go │ │ └── errors_test.go │ ├── s3store │ │ └── s3_store.go │ └── toy │ ├── config.go │ ├── local_store.go │ ├── server.go │ ├── server_test.go │ └── store.go ├── toy └── toy.zip 7 directories, 13 files
Now we have a server struct
// Server represents all of the config and clients // needed to run our app type Server struct { store Store cfg *Config router *chi.Mux }
Where Store is
// Store provides an interface to the blobs we // store in the toy app type Store interface { Get(context.Context, string) (string, error) Set(context.Context, string, string) error Del(context.Context, string) error }
Starting the server looks familiar
// Start starts the server func (s *Server) Start() { s.router.Route("/blobs", func(r chi.Router) { r.Post("/", s.storeBlob) r.Route("/{key}", func(r chi.Router) { r.Get("/", s.getBlob) r.Delete("/", s.deleteBlob) }) }) if err := h.ListenAndServe(); err != nil { if err != http.ErrServerClosed { log.Fatal(err) } } }
Local
// LocalStore is an in-memory implementation of our // store interface type LocalStore struct { store map[string]string lock *sync.RWMutex }
S3
// S3Store is an S3 backed implementation of our Store // interaface type S3Store struct { client *s3.S3 bucket string }
With a little setup of our server
func TestMain(m *testing.M) { if err := setupServer(); err != nil { log.WithError(err).Fatal("failed to set up server for tests") } go s.Start() time.Sleep(500 * time.Millisecond) status := m.Run() s.Stop() os.Exit(status) }
Now we can write more thorough tests in a familiar way
testBlob := "this is a test blob" var key string t.Run("create a blob", func(t *testing.T) { resp, err := http.Post(addr, "application/text", strings.NewReader(testBlob)) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.NotNil(t, resp) require.NotNil(t, resp.Body) defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) key = string(b) })
It's great that our code and tests look a little more stdlib-ish, but how does that translate to our actual deployment, i.e. Lambda???
We can translate the events.APIGatewayProxyRequest, either by hand, or with:
You may have noticed two packages under cmd, lambda and http
As long as we expose our Router
// Router exposes our chi Route externally func (s *Server) Router() *chi.Mux { return s.router }
we can change our lambda/main.go to set up the proxy adapter
chiLambda = chiadapter.New(s.Router())
and call it in the Handler
// Handler satisfies the AWS Lambda Go interface func Handler(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { // If no name is provided in the HTTP request body, throw an error return chiLambda.Proxy(req) }
./toy --local-store
Can curl locally, and get faster feedback
curl -XPOST -d @key.json http://127.0.0.1:8080/blobs
43823ea6-6f48-49c2-a798-47eb728e50a7
0.02s
curl http://127.0.0.1:8080/blobs/43823ea6-6f48-49c2-a798-47eb728e50a7
{ "Name": { "S": "banana" }}
0.01s
curl -XDELETE -v http://127.0.0.1:8080/blobs/43823ea6-6f48-49c2-a798-47eb728e50a7
* Trying 127.0.0.1...
...
< HTTP/1.1 204 No Content
0.02sWhat have we gained?
We're flexible about what we're deployed on now, but what about our backend?
23
The go-cloud project aims to provide common interfaces to common services across cloud providers.
BEAR TRAP ALERT This means that they can only support common operations, wouldn't be helpful for more deployment-specific control BEAR TRAP ALERT
Bucket methods cover the functionality we had before
func NewBucket(b driver.Bucket) *Bucket func (b *Bucket) Delete(ctx context.Context, key string) error func (b *Bucket) NewReader(ctx context.Context, key string) (*Reader, error) func (b *Bucket) NewWriter(ctx context.Context, key string, opt *WriterOptions) (*Writer, error)
We can even delete code!
24diff --brief -r third fourth Only in third/internal: s3store Only in third/internal/toy: local_store.go Files third/internal/toy/server.go and fourth/internal/toy/server.go differ Only in third/internal/toy: store.go tree -I vendor ├── cmd │ ├── http │ │ ├── main.go │ │ └── toy │ └── lambda │ └── main.go ├── internal │ ├── httperrs │ │ ├── errors.go │ │ └── errors_test.go │ └── toy │ ├── config.go │ ├── server.go │ └── server_test.go
We can use the fileblob package for local tests and running locally
func setupServer(dir string) error { port, err := freeport.GetFreePort() if err != nil { return errors.Wrap(err, "failed to get free port") } c := &Config{ BucketName: "test-bucket", Port: port, } addr = fmt.Sprintf("http://127.0.0.1:%d/blobs", port) log.Infof("starting server at %s", addr) store, err := fileblob.NewBucket(dir) if err != nil { return errors.Wrap(err, "failed to set up local store") } s = New(c, store) return nil }
Our locally running main can now support both backends... and it wouldn't be much work to support a third (e.g. Google Cloud Storage)
if localStore { store, cleanup, err = getLocalStore() } else { store, cleanup, err = getS3Store() } if err != nil { return errors.Wrap(err, "failed to initialize store") } defer cleanup() s = toy.New(config, store) go s.Start()
lambda/main.go) using aws-lambda-go-api-proxy, use an interface for our backend to let us run locally in-memoryblob.Bucket, allowing for the same advantages above, and to optionally support more providers in the future