Update 05/25/2019: H A S K E L L

The application talked about in this article was originally sloppily written using Node.js. I have ported it to Haskell+warp. The post has been updated as a result. I also made it even easier to build and run this application with some scripts, so all you have to do now is copy and paste 4 commands!


Before we get started, you should check out the very simple example application that we'll be using as an example in this post:

Matricx Password Generator

This link calls a relatively simple Haskell application that generates a secure password. The password is two words chosen at random from a predefined dictionary, 4 random numbers [0..9], and then a random character from the following: ['!', '@', '#', '$', '%', '&']. This password generator was ported from a Node.js implementation that I hastily threw together for work, and this Haskell version is way nicer:

{-# LANGUAGE OverloadedStrings #-}

module Main where

import System.IO
import System.Random
import Control.Monad
import Network.Wai
import Network.Wai.Handler.Warp
import Network.HTTP.Types (status200)
import Network.HTTP.Types.Header (hContentType)
import Data.ByteString.Lazy.Char8 as BS (pack)
import Data.Char (toLower,toUpper)

main :: IO ()
main = do
  let port = 80
  run port app

app :: Application
app req f = do 
  x <- genPass 
  f $ responseLBS status200 [(hContentType, "text/plain")] (BS.pack x) 

rIndex :: [a] -> IO a
rIndex arr = do
  i <- randomRIO (0, length arr - 1)
  return $ arr !! i

dromedaryCase :: [Char] -> [Char]
dromedaryCase [] = []
dromedaryCase x = toUpper (head x) : map toLower (tail x)

gen4Num :: IO [Char] -- Generates four random numbers and concats them
gen4Num = do
  a <- rIndex numList
  b <- rIndex numList
  c <- rIndex numList
  d <- rIndex numList
  return $ a ++ b ++ c ++ d

genPass :: IO [Char]
genPass = do 
  wrd0IO <- wordList
  wrd1IO <- wordList
  wrd0 <- rIndex wrd0IO
  wrd1 <- rIndex wrd1IO
  num0 <- gen4Num
  sym0 <- rIndex symbolList
  return $ (dromedaryCase wrd0) ++ (dromedaryCase wrd1) ++ num0 ++ sym0

readLines :: FilePath -> IO [[Char]]
readLines = fmap lines . readFile

wordList :: IO [[Char]]
wordList = readLines "./files/wordlist"

symbolList :: [[Char]]
symbolList = ["!", "@", "#", "$", "%", "&"]

numList :: [[Char]]
numList = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]

Warning: Nonsensical Haskell Programming Explanation Incoming. Consult your doctor before reading the next paragraph.

Here's the short walkthrough on the above code: main calls run port app, which is a function from warp that starts the webserver on a certain port (80, in this case) and runs an Application. In this case, app :: Application shoots back an HTTP 200 to the requester along with the results of the function genPass. genPass generates a secure password by reading from a word list (File IO), sending the contents of that word list to an array, and then rIndexing that array. rIndex selects a random member of an array and returns it. This result is concatenated with another randomly selected word (after being modified by dromedaryCase), which is then concatenated with the results of gen4Num, which returns 4 random numbers inside of a [Char], and finally that is concatenated with a single symbol from rIndex symbolList. genPass returns an IO [Char], which is then "unpacked" by x <- genPass in app, which is then packed into a Lazy Bytestring so it can be sent off to the recipient.

Okay, we got through the Haskell part. Now we're going to talk about how easy it is to throw this on Docker and load balance it across multiple hosts that don't even have to be on the same network*1. If you were going to do something like this manually, you'd spend the better part of a week making your application scale across servers and networks automatically according to changing needs. With Docker, this is going to take approximately fifteen minutes to set up. Every time you need to change the total number of containers, you'll spend maybe fifteen seconds updating the configuration and deploying it. It's that easy.

*1 I'm not going to show you how to do it across multiple networks.

To get started, you'll want to have the following:

  • The latest version of Docker for your Linux based operating system
  • Your text editor of choice (which better be vim)
  • About 15 minutes to dedicate to implementing this, and about 5 minutes to dedicate to the childish excitement you'll have when completed
  1. docker swarm init

If you don't already have a Docker Swarm defined, you'll want to start off by setting your main Docker machine as a manager with that command. It will also generate a worker token, so if you're planning to span this application across multiple hosts, you'll want to copy the command it provides to you and run it on your worker node. If they're not on the same network, you'll want to consult this documentation for more information on how to configure the networks for communication.

2. wget https://git.matri.cx/James/pwdGen/raw/branch/master/docker/autobuilder -O - | bash

This scary one-liner that I'm having you pipe to bash can be publicly examined at the wget link above. The script, as of this posting, looks like:

git clone https://git.matri.cx/James/pwdGen.git
cd pwdGen/docker
wget https://matri.cx/pwdGen.tar.gz
tar -xzvf pwdGen.tar.gz
mv pwdGen/pwdGenMusl ./
chmod +x buildDockerImage.sh
./buildDockerImage.sh

and then buildDockerImage.sh looks like this:

#!/bin/sh

cp -r ../files/ ./
[ -f pwdGenMusl ] && docker build --tag=pwdgen . || echo "Please place your pwdGenMusl binary in this directory."

They're split up so that you can manually do the autobuilder steps if you're not comfortable piping things directly to bash. If you are comfortable piping it directly to bash, the above one-liner after downloads everything you need and builds a Docker image named "pwdgen" for you! Super simple, super automated.

3. wget https://git.matri.cx/James/DockerIt/raw/branch/master/pwdgen/docker-compose.yml

This command will pull the docker-compose.yml for this application. Let's take a look at what that consists of:

version: "3"
services:
  web:
    image: docker.matri.cx/pwdgen
    deploy:
      replicas: 5
      resources:
        limits:
          cpus: "0.2"
          memory: 50M
      restart_policy:
        condition: on-failure
    ports:
      - "5002:80"
    networks:
      - webnet
networks:
  webnet:

This is pretty simple! It's a version 3 docker-compose file that defines a service "web" using the docker.matri.cx/pwdgen Docker image. It creates 5 replicas which will automatically load balance across manager and worker nodes. It limits each container to 0.2 of a CPU (what this means depends on your hardware) and 50M of RAM. It will restart the containers any time they fail. The port 5002 on the manager node that spins up the virtual webnet network will be mapped to port 80 in the container.

4. docker stack deploy -c docker-compose.yml pwdgen

This is where the magic happens. The configuration file we talked about above is utilized to provision a network that load-balances requests across all of the nodes hosting the application, and the containers are spun up across nodes to share the load. That's all you have to do. If you then access the application at port 5002 on the manager node, each new request will be distributed to a new container instance across your hosts.

At this point, you're done. This probably didn't even take you 5 minutes, let alone 15. It's that easy to make magic happen with Docker Swarm. Who needs Kubernetes?