A description of two problems I ran into whilst putting a small haskell/elm project running in docker on heroku.
NOTE: i’m not an expert (far from it) in any of these three things. My experience with heroku and docker is <24h, but my project is working now and I’d like to save other’s from digging through many stackoverflow posts.
I’ve recently been working on a haskell interpreter for a language that is reversible and has session types. It’s a pretty simple haskell application, with a webserver and a basic elm frontend. The frontend can view the current program state (spawned threads, declared variables), and can step forward and backward in the execution.
Rather than having to teach my project advisor to replicate my development setup (stack & elm), I want the app to run on heroku. This saves a lot of time and makes the project more easily sharable.
Using heroku’s docker support seems the best solution, especially because i want to use stack (not halcyon) and there is a base image for stack (but no buildpack).
1. Installing docker (on linux mint)
The docker site has instructions, but they didn’t work on my machine (the executable just hanged). I eventually found these instructions at this gist:
Also, make sure your docker version is higher than 17.05
$ docker -v
Docker version 17.05.0-ce, build 89658be
This allows multi-stage builds which we’ll want.
2. Circumventing timeout with staged builds
The evolution of my docker file
picking a base
I picked fpcomplete’s fpco/stack-build: it comes with all the haskell tools installed, and allows for specifying a particular lts version
creating a .dockerignore
I advise to create a .dockerignore file with at least “.stack-work” in it. It makes building the container much faster because all of the dependencies don’t get loaded and copied.
using multi-stage builds
A major problem I ran into was that somehow heroku - after pushing my container - would try to run the whole docker file, starting with installing the compiler. That’s undesirable (takes a long time), and will fail quickly with:
Error R10 (Boot timeout) ->
Web process failed to bind to $PORT within 60 seconds of launch
So if your app is not running withing 60 seconds, it will be killed. The haskell compiler doesn’t even install in 60s, so clearly something is wrong.
Multi-stage builds seem to fix this, where everything but the final stage (I think) is ran on the local machine and pushed. Only the final stage - often it copies executables from earlier stages and exposes ports - is ran on the heroku server and this step is quite fast.
the pseudo-dockerfile so far
# build stage # pull baseFROM fpco/stack-build:lts-9.9
# create dir to store files inRUN mkdir -p /myApp
# copy the project filesCOPY . /myApp
RUN stack setup &&stack build
## runtime ## pull same base as beforeFROM fpco/stack-build:lts-9.9
# expose a port for local development# heroku seems to figure out what port to use to listen toEXPOSE 8000
RUN mkdir -p /app
# Executable(s) from build stageCOPY --from=0 /myApp/.stack-work/install/x86_64-linux/lts-9.9/8.0.2/bin/myExecutable /app/myExecutable
RUN useradd app
# boot the appCMD ["/app/myExecutable" ]
This is very heavily based on this gist by @pbrisbin.
A major problem with the above file is that whenever a haskell file changes, the full RUN stack setup && stack build command will run again, recompiling all dependencies. That is awfully slow for a project of nontrivial size: unacceptable.
The solution is to install the dependencies as a separate step. To further optimize caching, I also install some large dependencies as a separate step, such that even when I add a new dependency, some of the work remains cached.
COPY . /myApp
RUN stack setup && stack build
# only copy stack.yaml and the cabal file
COPY stack.yaml myApp.cabal /src/
# only download and compile the dependencies
RUN stack setup && stack install --dependencies-only
# copy all the source and build
COPY . /src
RUN stack setup && stack install
I use a similar pattern for my elm code: copy elm-package.json, install the packages, copy the full source, compile the app.
One minor problem remains: a change in a haskell file requires all project haskell files to be rebuilt. I don’t have a fix for this yet and for my current project the build time is manageable. If anyone has tips on how to make rebuilds faster, let me know.
The pushing of a docker container to heroku is pretty straightforward. Install their CLI and create a new app. Pick “container registry” as the deployment method and follow the steps. For their last step, I need to explicitly include the app name for it to work.
# Buils stage
# pull base
# install nodejs
RUN apt-get update -y && apt-get install -y nodejs
# configure stack
RUN stack upgrade
RUN stack setup
# large haskell packages, separate step for better caching
RUN stack install --resolver lts-9.9 aeson servant-server wai text warp mtl transformers parsec elm-export
# make a dir for the project
RUN mkdir -p reversible-debugger
# copy only the stack.yaml and the cabal file; build only dependencies
COPY stack.yaml reversible-debugger.cabal /reversible-debugger/
RUN stack setup && stack install --resolver lts-9.9 --dependencies-only
# install elm
RUN npm install -g n && n 4.0.0
RUN npm install -g email@example.com
# frontend dir for elm code
RUN mkdir -p /reversible-debugger/frontend
# copy only the elm-package.json and install the elm dependencies
COPY frontend/elm-package.json /reversible-debugger/frontend/
RUN elm-package install -y
# copy the full source
COPY . /reversible-debugger
# compile the elm app
RUN elm-make src/Main.elm src/ThreeBuyer.elm --output=index.html
# compile the haskell app
RUN stack build reversible-debugger:server
# pull same base
# expose a port for local dev. heroku seems to find the port
# make the file structure the server expects
RUN mkdir -p /app
RUN mkdir -p /app/frontend
# Executable(s) from build stage (stage 0, therefore `--from=0`).
COPY --from=0 /reversible-debugger/.stack-work/install/x86_64-linux/lts-9.9/8.0.2/bin/server /app/server
COPY --from=0 /reversible-debugger/frontend/index.html /app/frontend/index.html
# make user for the app (unsure if needed)
RUN useradd app
# start the server
CMD ["/app/server" ]