Episode #655
Repeatable Devcontainer

In this video we’ll look at a couple of approaches to making dev container commands repeatable and versioned, and we’ll get our first taste of docker-compose.

Episode Script

When we last left off, we had created a container to run a Rails app inside of, with thpis command:

docker run -i -t --volume `pwd`:/workspace -w /workspace ruby:2.7.2 /bin/bash -l

Later we re-started the container we created, by looking up its randomly-assigned codename and using a docker start command

docker start upbeat_pascal

And then we started up a new terminal in that container using a docker exec command that was similar, but not identical, to our original docker run command line:

docker exec -i -t -w /workspace upbeat_pascal /bin/bash -l

There are some definite shortcomings to this devcontainer workflow. For one thing, these commands are long and hard to get right. For another thing, if we want to re-use a container we’ve created and set up, we have to discover and remember either its ID or its random codename.

Let’s address the second issue first.

We’re going to re-create our devcontainer, but this time instead of run, we’re going to seperate creation from execution.

Instead we’ll use the create command.

To make this container more find-able, we’ll add a --name flag and name it. This project is called sixmilebridge. We append _devcontainer to that name.

And since we’re only creating, not running, we’ll remove the bash invocation from the end.

docker create -i -t --volume `pwd`:/workspace -w /workspace --name sixmilebridge_devcontainer ruby:2.7.2

Docker pulls down the layers it needs, builds a container, and outputs the full unique ID of the created container to indicate success.

If we now run docker ps -a, we can see this new container, with our assigned name.

avdi@viv:~/sixmilebridge$ docker ps -a | head
CONTAINER ID   IMAGE                                                              COMMAND                  CREATED         STATUS                      PORTS     NAMES
f04603c7c0d2   ruby:2.7.2                                                         "irb"                    9 seconds ago   Created                               sixmilebridge_devcontainer

We can start up this container by name.

avdi@viv:~/sixmilebridge$ docker start sixmilebridge_devcontainer

And we can open a shell inside it by name.

avdi@viv:~/sixmilebridge$ docker exec -i -t -w /workspace sixmilebridge_devcontainer /bin/bash -l

Now we don’t need to worry so much about losing track of our container. But these commands are still long and tricky.

One way we could make this process more approachable and repeatable for other developers is to capture them as scripts in the project.

One to create the container

docker create -i -t --volume `pwd`:/workspace -w /workspace --name sixmilebridge_devcontainer ruby:2.7.2

One to start the container

docker start sixmilebridge_devcontainer

And one to open a shell in the container.

docker exec -i -t -w /workspace sixmilebridge_devcontainer /bin/bash -l

This is a workable approach, and we could easily stop here. However, I want to give you a teaser for another approach. As we go forward we’re going to be adding more and more elaborate configuration, including things like adding secondary containers for a database or for browser testing. A robust way to handle these kinds of needs is to use the docker-compose tool.

Let’s create a directory called .devcontainer. We’ll keep all of our dev container configuration files in here, to keep it distinct from any deployment container definition in our project.

In this directory, we’ll edit a file called docker-compose.yml.

It’s a good idea to start off with a version declaration, for forwards compatibility.

The bulk of our config will live under the services heading. Today we’re only going to define a single service, but one of the strengths of docker-compose is orchestrating multiple containers that should all start up or shut down together.

We’ll call our service app, because it’s for our app development.

The rest of the directives all mirror command-line arguments we’ve been giving to docker commands.

We specify that it should use the off-the-shelf Ruby 2.7.2 machine image from Docker Hub.

We set up a volume mapping for our project directory.

The type is bind, which is the most common type of volume mapping.

The source is our project root, which is the directory above the .devcontainer directory.

And the target path inside the container is /workspace

Just as we specified from the command-line, we’ll set the default working directory inside the container to /workspace.

The one thing we’ll change from the command line is that we’ll make this container’s default command one that just sleeps forever. This will keep the container running unless we explicitly terminate it.

version: "3.2"
    image: ruby:2.7.2
      - type: bind
        source: ..
        target: /workspace
    working_dir: /workspace
    command: sleep infinity

Now in our terminal we can run docker-compose up inside our .devcontainer directory to build and start up the devcontainer. By default this attaches to the STDOUT of the default command inside the container, so it won’t return us to the command-line until we shut it down.

Once that’s up, we’ll go to a second terminal in the same directory.

In here, we open a shell inside the container by invoking docker-compose exec app /bin/bash -l. This says to execute a command inside the app container, which corresponds to the app service we defined in the docker-compose.yml file.

In here, we run project development commands, such as kicking off a bundle install.

If we want to shut this container down, we can go back to the terminal where we ran docker-compose up, hit Ctrl-C, and wait a bit for it to shut the container down.

Today we’ve seen a couple of techniques for capturing a devcontainer configuration in a repeatable, versionable form. And we’ve had our first taste of docker-compose, which we’ll be doing a lot with in the future. Happy hacking!