Building on the last devcontainers video, today we’ll look at a few different techniques for capturing our Docker commands so that they are versioned and repeatable. And we’ll get our first taste of the
Video transcript & code
When we last left off, we had created a container to run a Rails app inside of, with this 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
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 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 root@f04603c7c0d2:/workspace#
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
#!/bin/sh docker create -i -t --volume `pwd`:/workspace -w /workspace --name sixmilebridge_devcontainer ruby:2.7.2
One to start the container
#!/bin/sh docker start sixmilebridge_devcontainer
And one to open a shell in the container.
#!/bin/sh 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
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
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
And the target path inside the container is
Just as we specified from the command-line, we’ll set the default working directory inside the container to
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" services: app: image: ruby:2.7.2 volumes: - 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
In here, we run project development commands, such as kicking off a
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!