Temporary Development Environments
Recently I joined the core team of an open source project called Sample Programs in Every Programming Language. The goal of the project is to provide a growing collection of simple programs in every programming language along with articles that explain each implementation. I quickly found that the languages which I use commonly were mostly complete, but was happy to see opportunities for languages which I am learning, but for which I have not had an opportunity to develop a full project.
While I long for a reason to setup a full development environment for some of these languages, single file projects did not seem to be that reason, and my minimalist Arch Linux setup agreed with me. Still, I needed at least a compiler for each of these languages, but my fear of cluttering my machine kept me from just installing a bunch of compilers/sdks.
Requirements
I wanted to be able to perform my development cycle with the following requirements:
- Build code without adding packages to my machine
- Leave no build artifacts, dependencies, other cached items
- Don’t add any files or folders besides source code of the project itself
- Persist code changes after cleaning up the isolated build/debug/run space
- Continuously run and debug code during development
The Solution: Docker
The first thing that came to mind that provided me with this kind of isolated environment was Docker. Using Docker I could quickly spin up a clean environment to build and run my code, and I could tear it down just as easily.
Docker Workflow
Since one of my requirements for this exercise was to avoid adding additional files or leaving artifacts behind, I chose to avoid Dockerfiles
and any sort of local docker build
.
I decided to base my workflow around docker run
.
For haskell I came up with the following:
docker run -it --rm -v $(pwd):/haskell -w /haskell haskell /bin/bash
Let’s break this into parts:
docker run -it --rm ... haskell ...
Interactively run the latest official image of haskell from Docker Hub. When the container stops, remove it.
... -v $(pwd):/haskell ...
Bind the current directory to the /haskell
directory inside of the container.
Anything in this directory now exists within the container.
That means we can from the container build, package, modify, etc…any source found in the current directory.
Note that this is a bind mount not a copy, so any modifications made to files and folders inside the container also occur outside the container and vice versa.
... -w /haskell ...
Set the working directory for the container to /haskell
... bash -c "cd /haskell && exec /bin/bash"
This is the command that is given to the container as soon as it starts up. Basically, this will give you a bash prompt in the /haskell
directory.
So all together, what you get is a bash shell prompt inside an isolated environment that contains nothing more than your source code and tools to build and run it.
You can use this environment to interact with your source code just as you would on a freshly set up development environment.
After exiting the environment everything besides /haskell
will be cleaned up automatically.
Generic Docker Workflow Function
After creating several almost identical aliases for the docker run
command above, I came up with a more generic function:
function containerhere {
[[ -z $1 ]] && { echo "usage: containerhere IMAGE [COMMAND]"; return 1; }
command=$2
if [[ -z $command ]]; then
command='/bin/bash'
fi
bash -c "docker run --rm -it -v $(pwd):/data -w /data $1 $command"
}
This function basically does the same as previous example but requires that you specify a Docker image to run. It also allows you to optionally override the command that is passed to the container at startup.
You could achieve the same result as the previous example by calling containerhere haskell
One difference to note is that the current directory is bind mounted to /data
instead of /haskell
.
Docker: The Advantages
Some advantages of this approach include the following:
- The environment is entirely isolated. Any caching, package installation, or build artifacts that are not written to the bound
/haskell
directory are automatically cleaned up. - The environment begins in a clean state with every
docker run
- The local bind mount between the current directory and the
/haskell
directory allow you to make changes from inside or outside of the container. That means you can still use your favorite text editor to write code on your host machine and build/run/test it inside of the container.
Docker: The Limitations
There are always trade-offs to every approach. Here are some limitations that come to mind:
- Persistence: Only changes inside of the current directory bind mount are persisted. That means you may need to re-download and/or reinstall dependencies with every new
docker run
. There are solutions to this problem, but those are outside the scope of this post. - Debugging: This approach does not provide a full IDE debugging experience. Because the SDK/compiler exists inside of the container, your IDE does not automatically have access to it. You may wish to look into command line debugging options for your language such as that provided with python.
Real World Example
Let’s say I want to write the canonical Quicksort in Haskell
I would start by creating a new directory for my project and creating a main.hs file in that directory
$ mkdir ~/code/quicksort
$ cd ~/code/quicksort
$ touch main.hs
I would add my code (as follows) to main.hs:
module Main where
import System.Random (randomRIO)
quicksort :: Ord a => [a] -> [a]
quicksort [] = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
where
lesser = filter (< p) xs
greater = filter (>= p) xs
randomList :: Int -> IO([Int])
randomList 0 = return []
randomList n = do
r <- randomRIO (1,1000)
rs <- randomList (n-1)
return (r:rs)
main :: IO ()
main = do
random25 <- randomList 25
print $ quicksort random25
I won’t take time to explain this Haskell code except to say that when executed it will generate a list of 25 random numbers between 1 and 1000 and then sort it using quick sort
At this point I will spin up my Haskell environment:
$ containerhere haskell
And I will receive a prompt similar to: root@fdd31fd2a629:/data#
If I list the contents of the current directory, I will see my main.hs.
root@fdd31fd2a629:/data# ls
main.hs
If I want to interactively test my code, I can bring up ghci, Haskell’s interactive REPL, and load main.hs
root@fdd31fd2a629:/data# ghci
GHCi, version 8.6.3: http://www.haskell.org/ghc/ :? for help
Prelude> :l main.hs
[1 of 1] Compiling Main ( main.hs, interpreted )
main.hs:3:1: error:
Could not find module ‘System.Random’
Use -v to see a list of the files searched for.
|
3 | import System.Random (randomRIO)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Failed, no modules loaded.
I receive an error telling me that System.Random is not installed in my environment.
I exit the ghci with :q
and install using cabal, a Haskell package manager:
Prelude> :q
Leaving GHCi.
root@fdd31fd2a629:/data# cabal update
root@fdd31fd2a629:/data# cabal install random
...
Note: I have omitted some output from cabal downloading and installing the random
module”
Now I can try loading main.hs
into ghci again:
root@fdd31fd2a629:/data# ghci
GHCi, version 8.6.3: http://www.haskell.org/ghc/ :? for help
Prelude> :l main.hs
[1 of 1] Compiling Main ( main.hs, interpreted )
Ok, one module loaded.
And I can run it by calling :main
*Main> :main
[65,110,129,191,258,272,287,352,394,452,458,473,473,491,501,564,570,590,726,760,795,830,869,882,908]
At this point I can make any changes I like to my source code either by installing a CLI text editor inside the container or by making modifications from outside of the container.
I generally keep two terminal windows open.
One is outside the container and is a vim window where I modify my source.
The other is where I ran containerhere
, and I use it to continually load and run my code.
If I need to build my code I can do that as well by calling the Haskell compiler, ghc:
root@fdd31fd2a629:/data# ghc main.hs
[1 of 1] Compiling Main ( main.hs, main.o )
Linking main ...
This will give me a compiled binary files.
root@fdd31fd2a629:/data# ls
main main.hi main.hs main.o
These can be run from inside the container
root@fdd31fd2a629:/data# ./main
[81,125,136,144,165,216,221,224,234,235,254,271,273,476,572,610,616,623,656,659,808,848,931,933,1000]
or from outside the container
$ ./main
[122,178,180,204,230,265,266,331,334,413,428,452,453,564,564,622,631,633,658,659,674,685,912,965,976]
Conclusion
In this post I’ve described my solution to a specific problem that I had. However, I believe that this pattern can be used in a plethora of situations. It is a great, low commitment way to start learning a new language. It works when I am using a computer other than my own, for example when pairing or mobbing with my team. I have found it to be useful anytime I quickly want a clean, isolated environment that can disappear without a second thought. Hopefully, regardless of your workflow, this approach can help you whenever you need a temporary development environment.