šŸ¤” 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.

app.ports.speakIt.subscribe(function(text) {
    ...
    // Some fairly silly JS TTS hacks
}

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:

functions:
  slogan:
    handler: Sloganator.sloganator-serverless
    events:
      - http:
          path: /{variant}
          integration: lambda-proxy
          method: get

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.

import           Sloganator.GymSlogans
import           Sloganator.HipsterSlogans
import           Text.Read (readMaybe)
import           Data.Char (toLower)

data SloganType = Gym | Hipster
    deriving (Show, Eq)

-- TODO: work out a less clunky way of doing this
instance Read SloganType where
    readsPrec _ s = case toLower <$> s of
        "gym" -> [(Gym, "")]
        "hipster" -> [(Hipster, "")]
        _ -> []

sloganHandler :: Maybe Text -> IO (Maybe String)
sloganHandler maybeVariant =
   -- We can fail the read (Nothing) or have been _supplied_ a Nothing
   -- ...so bind that Maybe, baby.
   let variant = maybeVariant >>= (readMaybe . T.unpack)
   in case variant of
       Just Gym -> Just <$> gymSloganator
       Just Hipster -> Just <$> hipsterSloganator
       _ -> return Nothing

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:

import           AWSLambda.Events.APIGateway
import           Control.Lens
import qualified Data.HashMap.Strict as HashMap
import           Data.Text (Text)
import qualified Data.Text as T
import           Data.Semigroup ((<>))
import           Data.Aeson (encode)
import           Data.String.Conversions (cs)

-- | Convenience
newtype ContentType = ContentType Text
jsonType = ContentType "application/json"
plainText = ContentType "text/plain"

-- | Form an HTTP response appropriate for an optional content-type and slogan
responseForSlogan :: Maybe ContentType -> String -> APIGatewayProxyResponse Text
responseForSlogan maybeContentType slogan =
    case maybeContentType of
        (Just (ContentType "application/json"))
          -> okWithContentType jsonType json
             where json = cs $ encode [slogan]
        _ -> okWithContentType plainText (T.pack slogan)

-- | A 200 OK reply for the Gateway, with content type and body as passed in
okWithContentType :: ContentType -> Text -> APIGatewayProxyResponse Text
okWithContentType (ContentType contentType) body =
    responseOK & responseBody ?~ body <> "\n"
                & agprsHeaders .~ HashMap.fromList [
                      ("Access-Control-Allow-Origin", "*")
                    , ("Content-Type", contentType)
                ]

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):

sloganGatewayHandler :: APIGatewayProxyRequest Text -> IO (APIGatewayProxyResponse Text)
sloganGatewayHandler request = do
    putStrLn $ "Got request: " <> show request
    let maybeContentType = ContentType <$> HashMap.lookup "Accept" (request ^. agprqHeaders)
        variant = HashMap.lookup "variant" (request ^. agprqPathParameters)
    slogan <- sloganHandler variant
    return $ maybe responseNotFound (responseForSlogan maybeContentType) slogan

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 šŸ˜

(cd web/ && yarn build)
yarn run sls client deploy --stage=prod
yarn run sls deploy --stage=prod

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. šŸ’ø