Migrating a Dockerised AngularJS app with Haskell API on AWS ECS... to Elm with Yarn and Webpack plus Serverless Haskell on AWS Lambda, API Gateway and CloudFront SSL 🤔

Posted September 2018

Having now won “least readable title ever”, let’s, erm, begin…

Previously on Sloganator

Recently I released v2.0 of long-standing pet irony web-app, The Sloganator. You know, this one:

The Sloganator

Since its birth in 2015, I’d had various suggestions for tweaks and improvements mainly around content. A separate thread (one thread of irony is insufficient) was its ongoing use of Shiny New Things, which involved me deliberately trying (too?) many new technologies at once.

To someone who at the time knew very little Haskell, this was probably the biggest ask, and navigating Snap Framework wasn’t easy (it seems good if slightly unmaintained). The infrastructure / Dockerising side of it was more familiar, but itself presented some good challenges in the Haskell world.

Fast forward

So several years later, emboldened by the “success” of this story, and definitely a bit poorer after keeping EC2 instances alive for the ECS, I realised that AngularJS was so 2014, and Docker was everywhere anyway now, and Snap hadn’t taken off. Hmm.

Meanwhile, Elm as a front-end language and architecture was looking very interesting (EDIT: this was a blissfully before the 0.19 controversy if you care about stuff like that).

The potential to move to a more trendy architecture (Serverless / Functions As A Service etc) was noted too.

Replatforming

Original architecture

Front end & API (same container)
  • FullPage.JS & Materalize CSS
  • AngularJS & JQuery
  • HTML 5 / CSS 3
  • Snap Framework
  • Haskell / GHC 7
  • Debian Linux
  • Docker
  • ECS (managed via Elastic Beanstalk)
  • AWS EC2

New architecture

Front End
  • Elm
  • FullPage.JS / Materalize CSS
  • HTML 5 / CSS 3
  • Cloudfront (CDN)
  • S3
API
  • Haskell function(s) (GHC 8.02)
  • Serverless & Serverless Haskell shim
  • AWS Lambda (Node 6.x)
  • AWS API Gateway

Getting from A to B

This took a long time, and maybe wasn’t the optimal order. In fact it was long enough ago now that I’m not sure it was the order, but it seems like a good order:

  1. The front-end: add a front end (/web, I called it) for Elm (0.18).
  2. …for which we’ll need a package manager…
  3. Remove AngularJS code (there wasn’t much) from the JS layer.
  4. Remove Snap (high-performance web framework for Haskell)
  5. So move hosting static front-end (Elm SPA + assets) onto S3 (we no longer have a webserver…)
  6. Add Serverless framework (for which we’ll need npm on the root project, and serverless-haskell).
  7. Write Serverless handlers, and integrate with AWS API Gateway.
  8. …upgrading Stack build along the way to get newer dependencies
  9. Configure SSL support with CloudFront and Amazon Certificate Manager
  10. Rethink DNS structure to allow API to be hosted on SSL too.
  11. Switch DNS over

Easy!

Project setup

I wanted to keep the one repo if possible, though there’s logic that would argue not to do this. In the end I settled for web/ being a self-contained Elm app under the main project.

This remains confusing as Serverless is a Node / npm project, meaning there’s a package.json at the root level, as well as one in the web/ folder for the Yarn-ified Elm build. I could probably unify these one day.

Elm

Elm Logo

Elm is a functional language and SPA architecture that compile to Javascript.

I think of it as a “cut down, friendlier Haskell for the web”. This took longer to get the hang of than I thought, and even then I haven’t really got the hang of it. Enough to get it working, and I’m definitely a fan so want to carve out some time to explore larger scale apps. (Update: I’ve built another PoC. Still confusing, still love it).

Building that Elm

In keeping with my original Sloganator philosophy (try all the new things at once, and see what happens), I went with better more battle-hardened build and test tooling from the outset. That means no boilerplate generators and ditching Elm’s own preview servers.

I’m a fan of Yarn. Not everyone is, but I like it – or at least dislike npm, but again the point is it’s newer and shinier and has a beautiful colourful CLI… so I’m sold 😆

Webpack

Webpack, meanwhile, is one of those everyone’s-using-it JS things which still feels a bit magical to me. Luckily there’s an official-ish section on Webpack and Elm.

I ended up with plugins (other than the normal loaders include the magical elm-webpack-loader):

  • clean-webpack-plugin – why this isn’t default I don’t know.
  • html-webpack-plugin – this way allowed templating the SPA entry point page (with EJS), controlled by Webpack and thus the build process.
  • mini-css-extract-plugin – seemed quite useful for CSS minification and separation to a file for Production (and… sanity).

JQuery

Getting the interop right involved getting rid of as much jQuery as possible in the end, and just using Elm’s messages. Whilst this feels right (especially now Github did it!) Sometimes this just isn’t an option though – e.g. when plugins are in heavy use.

The site uses FullPage.js for the, err, full page interaction, and this was built as a JQuery plugin.

🆕 jQuery – gone 😄. It wasn’t too hard, latest FullPage (paid version now 😧) and MaterializeCSS now both support JQuery-free life.

A few things had to use document.getElementById or document.querySelectorAll and element.addEventListener but it’s really not so bad.

Ports

Ports are all part of the magic of Elm. One port was only needed here (there’s no JS -> Elm at all either) – the TTS, which uses the little-explored Web Audio API that modern browsers support to varying degrees.

Serverless

The trigger to the whole migration was the combination of using Serverless more at work, and finding the serverless-haskell project on Github.

Serverless Logo

Plugins

Other than the Haskell plugin, there were a couple of others useful for the SSL and front-end parts.

serverless-domain-manager came in handy to help configure CloudFront and AWS Certificate Manager a bit, but in reality there was a fair amount of hand-holding.

serverless-finch proved perfect for the deployment of static assets to S3 as part of the – just needed directing to the web/dist/ directory (and a bucket).

Haskell support

Realising this could and does work well enough (it uses a Node base, wraps Stack at build time, and calls the compiled binary process at runtime, with stdout as the interface – nice and UNIXey I guess). Under the hood, the build is done with Docker (fpco/stack-build in fact. Warning: it’s ~3GB of image). I guess this keeps things sane given how hard GHC builds can be cross-platform.

Configuring the function

Using AWS API Gateway was sensible, and lambda-proxy was the Serverless “integration” method I went for in the end, after reading (then ignoring) the confusing API GW Serverless documentation.

Pretty simple serverless.yml config in the end, though getting there never is:

Type Safety++

To help serve the API Gateway endpoint, it seemed a good time to use ADTs to define the possible variants of the API.

Content Type

Dealing with content type negotiation became a bit of a pain. The original contract was simple enough: a plain text request should elicit a plain-text API response, but with Accept: application/json, we want a minimally JSON-wrapped response, with an appropriate JSON Content-Type:

The API Gateway Handler

If you go down the API Gateway route (I soon did), the docs give some hints / examples.

Here’s what I ended up with (recently refactored for neatness):

Integration Testing

I eventually managed enough lens-fu and using the project’s strong types to get some reasonable HSpec BDD tests going on.

Deployment

This is now a dream 😁

It takes seconds (previously the Dockerised solution(s) weren’t fast), and works every time – Serverless (and the Haskell plugin) is more reliable than I was expecting.

Gotchas

Some lessons learned, or at least pain points (that I remember – it was a while ago).

Haskell upgrades

As part of the regular releases in the last few years I’d tried to keep up with Stackage LTS releases on update, and GHC itself from 7.8 up to 8.2.2 now. With this of course the controlled explosions of major version bumps, but I managed to sort most of these out easily even given limited experience doing so before.

Binaries are large(ish)

  • I found ~30MB for a simple(ish) Haskell function. Understandable, considering the amount of libraries GHC is compiling, but not for you if you’re on a minimalist tip.

SSL with DNS

  • To get DNS to play ball at all, I had to do some breaking changes at the URL level
  • https://api.sloganator.io is now the root for all API requests
  • https://sloganator.io now serves content over SSL
  • A fair bit of this isn’t automated – the SSL certificate was fairly manually created, for example, but the second time around I’d hazard more could be automated from the outset.

The big payback

The great news is API Gateway, Lambda, S3, and CloudFront all take a lot of load before costing much and there’s not much more to it at runtime.

Here are the costs on that AWS account (the “other” EC2 cost there is one instance for tfl.declension.net – which needs rewriting too…) cost report:

So quite apart from the learning aspect, the cost for The Sloganator has gone from around $12 / month (EC2 + EB / ECS) to a under a dollar per month for the Serverless version. 💸