š¤ 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
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:
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:
- The front-end: add a front end (
/web
, I called it) for Elm (0.18). - ā¦for which weāll need a package managerā¦
- Remove AngularJS code (there wasnāt much) from the JS layer.
- Remove Snap (high-performance web framework for Haskell)
- So move hosting static front-end (Elm SPA + assets) onto S3 (we no longer have a webserverā¦)
- Add Serverless framework (for which weāll need npm on the root project, and
serverless-haskell
). - Write Serverless handlers, and integrate with AWS API Gateway.
- ā¦upgrading Stack build along the way to get newer dependencies
- Configure SSL support with CloudFront and Amazon Certificate Manager
- Rethink DNS structure to allow API to be hosted on SSL too.
- 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 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.
.ports.speakIt.subscribe(function(text) {
app...
// 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.
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
= ContentType "application/json"
jsonType = ContentType "text/plain"
plainText
-- | 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
ContentType contentType) body =
okWithContentType (& responseBody ?~ body <> "\n"
responseOK & 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)
= do
sloganGatewayHandler request putStrLn $ "Got request: " <> show request
let maybeContentType = ContentType <$> HashMap.lookup "Accept" (request ^. agprqHeaders)
= HashMap.lookup "variant" (request ^. agprqPathParameters)
variant <- sloganHandler variant
slogan 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 requestshttps://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ā¦) :
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. šø