I often have issues when using latest software versions like for fast moving projects like Node.js. Since I am using Debian and that hosted packages are often stable versions, I need to install those packages manually. Some projects provide prebuilt packages to download from their website but others require manual installation with all dependencies, hoping that those will not conflict with the system and requiring to remember all manually installed dependencies when cleaning up the system. Latest versions of fast moving projects often change by nature, meaning I often need to manually update them.

Docker’s execution model looked interesting for isolating those in a container rather than installing them on the host system, then being able to start this container in milliseconds when needed. I saw people have been using that strategy for some time and gave it a try. The good news is that Docker being so popular those days, there qre endless up to date images for most projects.

The simplest thing people are doing is just to create an alias that boots a container with a volume to the host working directory and executes the command inside, all arguments simply being passed down:

alias node="docker run -it --rm -v \`pwd\`:/pwd -w /pwd node:0.12.2-slim node"
alias npm="docker run -it --rm -v \`pwd\`:/pwd -w /pwd node:0.12.2-slim npm"

Adding this alias in my .bashrc solves my two issues:

  • upgrading to a newer version just requires to change the image version in my alias
  • cleaning up is just asking Docker to prune unused images

The first issue I encountered was that all files being created by the command were created as root. Solving that requires to have a user defined in the container with the same identifier than the host user, and running the container with this user. I thus created a script which creates a local image with the user properly set inside, and changed aliases to use this image instead:

docker run --name node-with-user node:0.12.2-slim /bin/bash -c \
  "groupadd -f -g $(id -g) dummy && useradd -u $(id -u) -g dummy dummy"
docker commit node-with-user local/node-with-user
docker rm node-with-user

alias node="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user node"
alias npm="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user npm"

Again upgrading to another version is just changing the image in the file and running it again.

The second issue I had was that this does not respect npm caching. npm is keeping all downloaded binaries locally and only checks metadata against the server when installing the same package over and over. For this I decided to use docker volumes, I created a data container that would keep the cache and reuse its volumes in running containers. In the generated image I had to create the folder containing npm cache with the correct user. For convenience I also created an alias not mounting cache volumes so I can force fresh installs if needed:

docker run --name node-with-user node:0.12.2-slim /bin/bash -c \
  "groupadd -f -g $(id -g) dummy && useradd -u $(id -u) -g dummy dummy && mkdir -p /home/dummy && chown -R dummy:dummy /home/dummy"
docker commit node-with-user local/node-with-user
docker rm node-with-user

docker inspect npm-cache > /dev/null
if [ $? -eq 0 ]; then
  docker rm npm-cache
fi
docker create -v /home/dummy --name npm-cache local/node-with-user

alias node="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user node"
alias npm="docker run -it --rm --volumes-from npm-cache -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user npm"
alias npm-fresh="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user npm"

The volume is set to the whole home directory to also include .npmrc that allows logging to npm.

That organisation is quite limiting as a new data container is created each time one creates a bash session, I thus had to split this in two scripts:

  • one creating the image and data container each time it is invoked
  • in .bashrc I kept a test for the data container existence (calling the other script if not) and aliases
#!/bin/bash

# Create a container with a user having the same id than the local one and rights to edit npm cache directory, create an image out of the container and delete it (this is were node version is chosen)
docker run --name node-with-user node:0.12.2-slim /bin/bash -c \
  "groupadd -f -g $(id -g) dummy && useradd -u $(id -u) -g dummy dummy && mkdir -p /home/dummy && chown -R dummy:dummy /home/dummy"
docker commit node-with-user local/node-with-user
docker rm node-with-user

# Create a data container keeping npm content
docker inspect npm-cache > /dev/null
if [ $? -eq 0 ]; then
  docker rm npm-cache
fi
docker create -v /home/dummy --name npm-cache local/node-with-user
docker inspect npm-cache > /dev/null
if [ $? -ne 0 ]; then
  source ~/.config/node-setup.sh
fi

alias node="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user node"
alias npm="docker run -it --rm --volumes-from npm-cache -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user npm"
alias npm-fresh="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/node-with-user npm"

Upgrading to a new version now requires that I change image version in the standalone script and runs it once.

I had the same working for Maven:

#!/bin/bash

# Create a container with a user having the same id than the local one, create an image out of the container and delete it (this is were maven version is chosen)
docker run --name mvn-with-user maven:3.3.3-jdk-8 /bin/bash -c \
  "groupadd -f -g $(id -g) dummy && useradd -u $(id -u) -g dummy dummy && mkdir -p /home/dummy && chown -R dummy:dummy /home/dummy"
docker commit mvn-with-user local/mvn-with-user
docker rm mvn-with-user

# Create a data container keeping maven content
docker inspect mvn-cache > /dev/null
if [ $? -eq 0 ]; then
  docker rm mvn-cache
fi
docker create -v /home/dummy --name mvn-cache local/mvn-with-user
docker inspect mvn-cache > /dev/null
if [ $? -ne 0 ]; then
  source ~/.config/java-setup.sh
fi

alias mvn="docker run -it --rm --volumes-from mvn-cache -v \`pwd\`:/pwd -w /pwd -u dummy local/mvn-with-user mvn"
alias mvn-fresh="docker run -it --rm -v \`pwd\`:/pwd -w /pwd -u dummy local/mvn-with-user mvn"

Even if pretty satisfied by the setup I encountered a few limitations. The obvious one is that it consumes more space on disk than just installing packages: my hard drive is way large enough to accomodate it. It also consumes a lot of bandwidth on setup to download all filesystem layers. Execution time does not seem to be affected by running inside containers.

The real limation I found was that those executables are actually isolated, they cannot rely on the presence of other executables on the system. For most cases this is exactly what I wanted: the software I write does not implicitly depends on things installed on my system. It is howerver a problem for npm commands, I used to have a few helpers relying on git to deploy to Github pages:

{
  "scripts": {
    "deploy-pages": "cd _gh-pages && git add --all && git commit -m \"Update site\" && git push",
    "setup-pages": "git clone --depth 1 -b gh-pages $(git config --get remote.origin.url) _gh-pages"
  }
}