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
$ 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”
I think that this is a great post. I especially enjoy how you show how to minimize the docker image down to 20 MB.
Thanks for your comment 🙂
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
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..