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 dependenciesscript/server
to run a serverscript/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 runscript/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.