Deploying a Haskell Web Service to Kubernetes

Haskell is a statically typed purely functional programming language from which other languages such as Scala, Elm, Purescript etc draws a lot of inpsiration. I’ve been interested in learning Haskell for a quite while and I’m still in the beginning of this phase so feel free to point out any obvious flaws in the comment section below. But what I want to talk about in this blog is not so much about Haskell itself, but rather the process of deploying a Haskell generated binary to the Cloud™ to be consumed by the masses. Since I’m into Kubernetes this will be the platform of choice for this article. My goal is that you should be able to follow along even if you have virtually no experience with Haskell before. So let’s get started.

Creating the Web Service

When working with Haskell one typically use Cabal, which is a system for building and packaging Haskell libraries and programs, and Stack which creates a sandboxed environment for the current Haskell project which includes all the projects dependencies and the GHC compiler etc. If you’re on Mac like me you can install Stack like this:

$ brew install haskell-stack

If not then see the installation instructions.

Once Stack is installed we need to determine what library we should use to create our web service. In this article I’m going to use a library called Scotty because it’s easy to get up and running. Scotty is inspired by Sinatra which is a wellknown Ruby web framework.

Here’s the hello world example taken from their website:

{-# LANGUAGE OverloadedStrings #-}
import Web.Scotty

main = scotty 3000 $ do
    get "/:word" $ do
        beam <- param "word"
        html $ mconcat ["

Scotty, ", beam, " me up!

"]

But pasting this code into your favourite editor won’t do much, we need be able to compile, package and run it as well! This is where Stack comes into play. Stack can help us bootstrap a new project and generate a good initial file structure for us and it can later help us compiling and package the application. To do this we can just do `stack new `. But can also supply a template to Stack if we want to use a particular framework for which a template exists. And it turns out that there’s one for Scotty called, `scotty-hello-world`. Let’s try it out:

$ stack new haskell-webservice scotty-hello-world

This will create a new directory (`haskell-webservice`) that includes everything needed to get us going. Stepping into this directory and you’ll see a file called `Main.hs` which (surprise!) contains the hello world Scotty example I listed earlier. Can we run this? Indeed:

stack build && stack exec haskell-webservice

The `build` argument instructs Stack to compile and package up a binary file (that’ll be located at `.stack-work/dist/x86_64-osx/Cabal-1.24.2.0/build/haskell-webservice`). You could go into this directly and execute the `haskell-webservice` binary to start the application but it’s easier to just let Stack execute it using `stack exec haskell-webservice`. You should now be able to navigate to `http://localhost:3000/hello` and see the message:

Scotty, hello me up!

Cool! But before we go webscale let’s add a “health check” route that’ll be used by Kubernetes later:

get "/health" $ do
    text "UP"

We’d also like to include some logging so that we later can check that our webservice really accepts requests. For this we’re going to import the `Network.Wai.Middleware.RequestLogger` module defined in the wai-extra package. To do this add the line `, wai-extra` under `build-depends` in the `haskell-webservice.cabal` file:

name:          haskell-webservice
version:       0.0.0
cabal-version: >= 1.8
build-type:    Simple

executable          haskell-webservice
    hs-source-dirs: .
    main-is:        Main.hs
    ghc-options:    -Wall -threaded -O2 -rtsopts -with-rtsopts=-N
    extensions:     OverloadedStrings
    build-depends:  base   >= 4      && < 5
                  , scotty
                  , wai-extra

Next we should add `middleware logStdoutDev` before the routes in our `Main.hs` file which should now look this:

{-# LANGUAGE OverloadedStrings #-}

import Web.Scotty
import Network.Wai.Middleware.RequestLogger

main :: IO ()
main = scotty 3000 $ do
  middleware logStdoutDev
  get "/health" $ do
    text "UP"

  get "/:word" $ do
    beam <- param "word"
    html $ mconcat ["

Scotty, ", beam, " me up!

"]

Try building and running the application again (`stack build && stack exec haskell-webservice`) and try to navigate to `/health` and `/` and you should see something similar to this:

Setting phasers to stun... (port 3000) (ctrl-c to quit)
GET /health
  Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
  Status: 200 OK 0.006859s
GET /favicon.ico
  Accept: image/webp,image/*,*/*;q=0.8
  Status: 200 OK 0.000027s
GET /there
  Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
  Status: 200 OK 0.000179s
GET /favicon.ico
  Accept: image/webp,image/*,*/*;q=0.8
  Status: 200 OK 0.000048s
GET /johan
  Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
  Status: 200 OK 0.000032s
GET /favicon.ico
  Accept: image/webp,image/*,*/*;q=0.8
  Status: 200 OK 0.000034s

We're ready to package this baby up in Docker!

Building Web Service Binary in Docker

Stack helped us compile and package our webservice into a binary file. The problem is that I'm on MacOSX and the binary file generated on my machine won't run in a Linux box. For this to work one needs to compile the project on Linux. This is where Docker comes into play. What we want to do is to compile the project in a Docker container which emits a binary file that we can later use to execute on Linux. Luckily there's a convenient Docker image available for this called (simply) haskell which includes stack, cabal, ghc and other things needed to compile and package a Haskell application. The `Dockerfile` we're going to use is this (inspired by the `Dockfile` generated by the servant-docker Stack template):

FROM haskell:8

VOLUME /root/.local/bin

RUN export PATH=$(stack path --local-bin):$PATH

RUN mkdir -p /app/user
WORKDIR /app/user
COPY stack.yaml .
COPY *.cabal ./
RUN stack build --dependencies-only

COPY . /app/user
ENTRYPOINT ["stack", "install"]	

When building the image it'll copy the application into Docker and download the dependencies needed for the project:

$ docker build . -t haskell-webservice-builder

This will take a loooong time (at least on my machine) but hang tight. Now we have an image (`haskell-webservice-builder`) tailored for our application that we can use to build the binary. Note how the `Dockerfile` specifies a `VOLUME` to `/root/.local/bin`, this is where the compiled binary will be placed when `stack install` is executed when we run the container. Thus we should mount this volume to our local file system in order to get a hold of the Linux binary. So let's create the binary:

$ docker run --rm -v $(PWD)/.stack-work/dist/linux/bin:/root/.local/bin --name haskell-webservice-builder haskell-webservice-builder

Here we've mounted `/root/.local/bin` in Docker to `.stack-work/dist/linux/bin`. Step into this direct and you should see the generated binary:

$ ls -lah .stack-work/dist/linux/bin/haskell-webservice
-rwxr-xr-x  1 johan  staff    16M Mar 10 12:09 .stack-work/dist/linux/bin/haskell-webservice

Packing the Web Service in Docker

Now we have the application binary but we'd like to run it in Docker as well. One could probably use the `haskell` base image to do this but it's 960 Mb large (without the actual application):

$ docker images | grep haskell
haskell        8        0487fd21eaf7        10 days ago         960 MB

So we'd like to use something smaller! I first tried basing the image on Alpine but I couldn't get it to work. But after a bit of research I found the fpco/haskell-scratch image which was exactly what I was after. It includes the minimal shared libraries for GHC-compiled executables and it has a size of just 4.3 Mb:

$ docker images | grep haskell-scratch
fpco/haskell-scratch         integer-gmp         55b3beaa0179        18 months ago       4.28 MB

Much better 🙂 So let's package up the application binary in a Docker image using this `Dockerfile` (name it `DockerfileRuntime` to not clash with the build-time `Dockfile`):

FROM fpco/haskell-scratch:integer-gmp

EXPOSE 3000
WORKDIR /app
COPY .stack-work/dist/linux/bin/haskell-webservice /app
ENTRYPOINT ["/app/haskell-webservice"]	

and execute this command:

$ docker build . -t haskell-webservice

Now we have an image, let's try it out:

$ docker run --rm -p 3000:3000 --name haskell-webservice haskell-webservice

Go back into the browser and navigate to http://localhost:3000/something to see that everything is working.

Deploy to Kubernetes

Kubernetes is a popular open-source system for automating deployment, scaling, and management of containerized applications such as the our newly created haskell-webservice. I won't cover the basics of Kubernetes in the blog, you can easily read up on it in their docs. The smallest deployable units of computing that can be created and managed in Kubernetes is a pod but in order to deploy such a pod we wrap it in a deployment. A deployment controls the rollout of one or more pods and is also responsible maintaining the specified amount of replicas for this pod. In order to expose our webservice to the outside world we'll also define a service. But first we need a Kubernetes cluster. We could create one locally using minikube but in this example we'll use Google Container Engine or GKE for short. Creating such a cluster only takes a few minutes (see quickstart). When you setup a GKE cluster Google is also kind enough to supply us with a private Docker Registry called Container Registry (GCR) to which we can publish our Docker image so that it can be downloaded to the instances in our Kubernetes cluster. Before we can push our Docker image to (any) private registry, we must add the registry name and image name as a tag to the image. In GCR our tag must take the form gcr.io/<google-project-id>/<image-name>. So if you've named your Google project "haskell-webservice-test" we'd like our tag to be `gcr.io/haskell-webservice-test/haskell-webservice:1.0.0`:

$ docker tag haskell-webservice gcr.io/haskell-webservice-test/haskell-webservice:1.0.0

Now let's push the image to GCR:

$ gcloud docker -- push gcr.io/haskell-webservice-test/haskell-webservice:1.0.0

Next let's start defining our Kubernetes deployment file. Here's an example:

---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: haskell-webservice
  labels:
    name: haskell-webservice
spec:
  replicas: 3
  revisionHistoryLimit: 5
  strategy:
    type: "RollingUpdate"  
  selector:
    matchLabels:
      name: "haskell-webservice"
  template:
    metadata:
      labels:
        name: "haskell-webservice"
        version: "1.0.0"
    spec:
      containers:
        - name: "haskell-webservice"
          image: "gcr.io/haskell-webservice-test/haskell-webservice:1.0.0"
          resources:
            requests:
              memory: 10Mi
              cpu: 50m
            limits:
              memory: 100Mi
              cpu: 200m
          livenessProbe:
            httpGet:
              path: /health
              port: external
            initialDelaySeconds: 5
            timeoutSeconds: 1
          readinessProbe:
            httpGet:
              path: /health
              port: external
            initialDelaySeconds: 5
            timeoutSeconds: 1
          ports:
            - containerPort: 3000
              name: "external"
              protocol: "TCP"

Again read up on the details here but there are a couple of things worth mentioning. First of all note that we specify that the "haskell-webservice" container should use the image that we just published to GCR and that we'd like 3 replicas. Next we specify some resource requests and limits for the container followed by a liveness- and readiness probe (see docs here). The readiness probe instructs Kubernetes not to route requests to our container before the `/health` path returns with 200 (this allows some slack before the container has started up correctly) and liveness probe continuously ping `/health` during the lifetime of the application to make sure that it's up and running (if not, it'll be restarted by the kubelet). Nice! Now let's ship this specification to Kubernetes:

$ kubectl create -f deployment.yaml
deployment "haskell-webservice" created

We've now created the deployment controller. Let's wait until all pods are up and running:

$ kubectl rollout status deployment/haskell-webservice

This will block until the deployment of our service is completed. Let's see what it looks like:

$ kubectl get pods -o wide -L version
NAME                                  READY     STATUS    RESTARTS   AGE       IP          NODE                                    VERSION
haskell-webservice-1448737210-5d22k   1/1       Running   0          1m        10.84.2.5   gke-haskell-default-pool-92a254a0-jncr   1.0.0
haskell-webservice-1448737210-84ql9   1/1       Running   0          1m        10.84.1.6   gke-haskell-default-pool-92a254a0-tggs   1.0.0
haskell-webservice-1448737210-d66jw   1/1       Running   0          1m        10.84.1.5   gke-haskell-default-pool-92a254a0-tggs   1.0.0

This is nice, but how can we actually access it? For this we'll define a service which is also a yaml file named `service.yaml` defined like this:

---
kind: "Service"
apiVersion: "v1"
metadata:
  name: haskell-webservice
  labels:
    name: haskell-webservice
    version: 1.0.0
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: external
  selector:
    name: haskell-webservice

Here we've named the service `haskell-webservice` (which will be the internal DNS name you can use if you want to reach the app from within the Kubernetes cluster) and we've set the type to `LoadBalancer`. This type is GKE specific and instructs GKE to create an external IP address and load balancer for us which we can use to access our service externally. We also instruct the service to expose port 80 and route to port "external" (which we defined in our `deployment.yaml` file earlier). The `selector` is used to by the service to identify which pods it should route to. So let's deploy it:

$ kubectl create -f service.yaml
service "haskell-webservice" created

Now we must wait until GKE has created the load balancer and the IP address for us:

$ kubectl get svc --watch
NAME                 CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
haskell-webservice   10.87.242.120        80:31429/TCP   48s
kubernetes           10.87.240.1             443/TCP        39m

You'll see that the `EXTERNAL-IP` says `<pending>` but after a while it'll print the external IP address.

In the meantime you could download a utility called kubetail that helps us tail the logs from multiple pods simultaneously. Start a new terminal and type:

$ kubetail haskell-webservice

You should see calls to the health check (which is made by Kubernetes):

haskell-webservice-1448737210-d66jw]   Accept:
[haskell-webservice-1448737210-5d22k]   Status: 200 OK 0.000040385s
[haskell-webservice-1448737210-84ql9]   Status: 200 OK 0.000083277s
[haskell-webservice-1448737210-d66jw]   Status: 200 OK 0.000035643s
[haskell-webservice-1448737210-5d22k] GET /health
[haskell-webservice-1448737210-84ql9] GET /health
[haskell-webservice-1448737210-5d22k]   Accept:
[haskell-webservice-1448737210-d66jw] GET /health
[haskell-webservice-1448737210-84ql9]   Accept:
[haskell-webservice-1448737210-d66jw]   Accept:
[haskell-webservice-1448737210-5d22k]   Status: 200 OK 0.000061687s
[haskell-webservice-1448737210-84ql9]   Status: 200 OK 0.000016148s
[haskell-webservice-1448737210-d66jw]   Status: 200 OK 0.000055973s

Once the external service ip has been assigned to the load balancer you should be able to navigate to it in the browser like this `http://<external-ip>/kubernetes` and our haskell-webservice should give us:

Scotty, kubernetes me up!

The (kubetail) logs should also indicate that a call was received:

[haskell-webservice-1448737210-5d22k] GET /kubernetes
[haskell-webservice-1448737210-5d22k]   Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
[haskell-webservice-1448737210-5d22k]   Status: 200 OK 0.000058756s	

Now we're ready for primetime and can scale the replicas as we find fit:

kubectl scale --replicas=10 deployments haskell-webservice

And now we have 10 replicas ready to serve traffic!

Conclusion

Hopefully you've been able to follow along on how you could both create, package, dockerify and deploy a basic Haskell webservice to Kubernetes. Feel free to post improvements or errors in the comments. Thanks for reading.

4 thoughts on “Deploying a Haskell Web Service to Kubernetes

  1. I think that this is a great post. I especially enjoy how you show how to minimize the docker image down to 20 MB.

  2. Thank you so much for this blog!

    Small note, for people trying this one in the future: I had to change the first line of the Dockerfile slightly to:

    FROM haskell:8.0.2

  3. hi do you need to specify DockerfileRuntime with `docker build..`?

    should this section:

    “..package up the application binary in a Docker image using this Dockerfile (name it DockerfileRuntime.. docker build . -t haskell-webservice..”

    be:

    “..package up the application binary in a Docker image using this Dockerfile (name it DockerfileRuntime.. docker build -f DockerfileRuntime . -t haskell-webservice..”

    ?

    thanks for the hs k8s tips..

Leave a Reply

Your email address will not be published. Required fields are marked *