zerowidth positive lookahead

Scripts to rule them all, with containers

A description and example of an extension to the scripts-to-rule-them-all pattern to transparently run commands inside a container.

In 2015, GitHub introduced the idea of scripts to rule them all, a convention for a set of common scripts for use in every project’s repository. Developers can expect these common scripts to be there and work consistently, regardless of how the code is written. A few examples:

  • script/bootstrap to install dependencies
  • script/server to run a server
  • script/test to run the test suite

In 2016, Misty DeMéo added a new script, script/app-env, to support work on a system that used containers. It relies on two simple pieces. First, a Dockerfile that includes touching a sentinel file:

RUN touch /etc/inside-container

And the script/app-env script that, in its simplest form, checks for the sentinel and decides how to run the command:

# if the sentinel is present we're in a container: run the command directly
[ -f /etc/inside-container ] && exec "$@"

# otherwise, re-run the same command inside a container
docker run -it --rm my-app "$@"

Using script/app-env as a wrapper for the remaining scripts runs the code inside a container transparently. With a few more additions, we can also automatically build the app’s container if it’s missing or if the build dependencies have changed.

If you want to just see the code for the script/app-env pattern, skip to the end.

Example: static site generator

This is a step-by-step example for moving from a local ruby site generator to an implementation of the script/app-env pattern. We’ll use a minimal jekyll site setup, starting with a local development environment and moving to containers.

Initial ruby setup

Gemfile for ruby dependencies:

source 'https://rubygems.org'
gem "jekyll"

A jekyll _config.yml to ignore the script directory:

exclude: ["script"]

index.html so it’s a real website:

<!DOCTYPE html>
<html><body><h1>Hello World!</h1></body></html>

And then the scripts:

script/bootstrap to install dependencies:

#!/bin/sh
bundle install

script/server to run the server to preview changes:

#!/bin/sh
bundle exec jekyll serve --incremental --drafts

script/build to build the site:

#!/bin/sh
bundle exec jekyll build

Now we can install the dependencies locally, preview, and build the site.

Running in a container

We’ll package everything in a container by adding a basic Dockerfile:

FROM ruby:3.3.4

COPY Gemfile /site/
COPY Gemfile.lock /site/
WORKDIR /site

RUN bundle install

CMD ["bash"] # so the default is a shell

Update the jekyll config to ignore Dockerfile:

exclude: ["script", "Dockerfile"]

Updating script/bootstrap to build the docker image instead:

docker build -t site-demo .

script/server needs to mount the local directory so it can read file changes:

root=$(realpath "$(dirname "$0")/..")
docker run -p 4000:4000 -v "$root:/site" site-demo \
  bundle exec jekyll serve --incremental --drafts --host 0.0.0.0

And script/build too so it can write to _site:

root=$(realpath "$(dirname "$0")/..")
docker run -v "$root:/site" site-demo bundle exec jekyll build

Now we can script/bootstrap to build the docker image and script/server to run the server. This is good enough, but there are some improvements we can make:

  • the docker command is duplicated
  • we don’t have easy access to the container environment
  • if we’re inside the container (docker run -it site-demo bash) we can’t run script/server from there
  • we have to manually update the docker image if it changes.

Adding script/app-env

To address these problems, we’ll introduce script/app-env. As mentioned above, it uses the presence of a sentinel file /etc/inside-container to decide whether to run a command via docker (when not in the container) or directly (when inside the container).

Update the Dockerfile to create the sentinel:

FROM ruby:3.3.2
RUN touch /etc/inside-container
# ...

And create script/app-env:

#!/bin/sh
set -e # always good practice!

# run what we were asked to if we're already inside the container
[ -f /etc/inside-container ] && exec "$@"

# otherwise, re-exec the command inside the container
root=$(realpath "$(dirname "$0")/..")
exec docker run -it --rm \
  -p 4000:4000 \
  -v "$root:/site" \
  site-demo "$@"

Simplify script/server and script/build again:

script/app-env bundle exec jekyll serve --incremental --drafts --host 0.0.0.0
script/app-env bundle exec jekyll build

The commands are now executed in a container. app-env also acts as a shortcut to get access to the container environment itself:

$ script/app-env
root@666fe218a034:/site#

Automatic rebuilds

The container image only needs to change when Dockerfile, Gemfile, or Gemfile.lock change. To make this automatic, we’ll use git to generate a hash based on these files that we can use to tag or rebuild the image. This goes in another script, script/image-tag:

#!/bin/sh

hash=$(git hash-object Gemfile.lock Dockerfile | \
  git hash-object --stdin | \
  cut -c1-9)
echo "site-demo:$hash"

Replace instances of site-demo with "$(script/image-tag)" in app-env and bootstrap so it’s always using the right version of the image name and tag. Now, app-env can check for this image first and bootstrap if it’s not present:

[ -f /etc/inside-container ] && exec "$@"

image=$(script/image-tag)
if [ -z "$(docker images -q $image)" ]; then
  echo "docker image $image not found, bootstrapping..."
  script/bootstrap
fi

docker exec -it --rm $image \
# ...

Now there’s no need to bootstrap ahead of time: we can run script/server and script/build any time and the container will be created for us as needed.

Updating the dependencies

To update the dependency lock files after changing Gemfile, use script/app-env bundle install to update the lockfile in your local working copy. The image will already have been rebuilt from the Gemfile change, but the lockfile would have only been written to the container image, not to the local working directory.

Example code

This is the final generic version of the patterns we built above. First, a Dockerfile with instructions to write a sentinel file:

# ...

# create a sentinel file
RUN touch /etc/inside-container

# we'll mount the current working directory here
WORKDIR /work

# ...

# default to a shell
CMD ["bash"]

script/image-tag to generate a deterministic tag based on the dependency-related files:

#!/bin/sh
set -e

# add Gemfile, packages.json, etc. here for automatic rebuild
dependencies="Dockerfile"

# infer the image name from the name of the root directory
# or use a fixed image name instead if you like
image_name=$(basename $(realpath "$(dirname "$0")/.."))

# generate a hash from the Dockerfile and any other dependency files
hash=$(git hash-object $dependencies | git hash-object --stdin | cut -c1-9)

echo "$image_name:$hash"

And script/app-env to run a given command inside the container, either inside or outside the container, with automatic bootstrapping if the image isn’t there:

#!/bin/sh
set -e

# already inside the container, so exec the command directly
[ -f /etc/inside-container ] && exec "$@"

# check to see that the image is available and build it if not
image=$(script/image-tag)
[ -z "$(docker images -q $image)" ] && script/bootstrap

# so we can mount the working directory
root=$(realpath "$(dirname "$0")/..")

# mount the working directory and re-run the command inside the container
exec docker run -it --rm \
  -v "$root:/work" \
  "$image" "$@"

Even if you don’t use the rest of “scripts to rule them all”, it’s easy to prefix commands in a Makefile or a Rakefile with script/app-env to wrap their execution. You can add port forwarding, additional volume mounts, or whatever else your app needs to the docker command. The combination of the Dockerfile and two support scripts gives us:

  • easy execution of any command in the repo using a container instead of the local system
  • automatic container rebuild if the container is missing or dependencies have been updated
  • direct access to the container environment with a bare script/app-env invocation

script/app-env <command> always does the right thing, whether called from inside the container or from the host system.

I use this pattern for many of my own projects, particularly things that run old software or aren’t under active development. For example, this site (not open source) currently runs the jekyll static generator. I don’t want to have to reconfigure my local ruby development environment each time I come back; I want script/publish to just work. Similarly, old code written for my asteroids game experiment and the jump point search post isn’t changing but I still want to be able to generate the static output javascript using the now-outdated software.

In more recent use, one of the codebases my team maintains at GitHub is a shared schema registry. The repository contains event schemas in protobuf format, and teams across the engineering organization need to generate the serialization code from those schemas with the protoc toolchain. The usual script/server, script/test, and script/lint scripts along with the script/generate code generation utility use the app-env pattern to wrap all the complex parts of installing and executing the right protobuf tools and language plugins inside docker, without modifying or conflicting with anything on the host system beyond the final output files.