Episode #663
Accessorizing Your Devcontainer Shell

A comfy development environment includes niceties such as shell aliases and PATH customizations. But where should those be configured in a devcontainer? Let’s find out!

Episode Script

Recently we walked through the process of “furnishing” a devcontainer with needed tools, such as a console editor. But tools are just one part of making a developer environment comfortable. Another big piece is customizing the command-line environment.

FROM ruby:2.7.2
RUN apt-get update \
  && apt-get install -y yarnpkg vim lsof \
  && ln -s /usr/bin/yarnpkg /usr/local/bin/yarn \
  && rm -rf /var/lib/apt/lists/*

For instance, a very common command-line alias on developer machines is gs as a shorthand for git status.

$ gs
On branch devcontainers-screencast
nothing to commit, working tree clean

This kind of shell accessory becomes so second-nature that when we first start moving development into a container, its absence can be rather jarring.

$ docker-compose up -d
Recreating sixmilebridge_app_1 ... done
$ docker-compose exec app /bin/bash -l
root@978243ae437b:/workspace# gs
bash: gs: command not found

Another example that’s common in Rails projects like this one is to alias bundle exec to be.

root@978243ae437b:/workspace# alias be="bundle exec"
root@978243ae437b:/workspace# be rails s
=> Booting Puma
=> Rails 6.0.3.4 application starting in development 
=> Run `rails server --help` for more startup options
Libhoney::Client: no writekey configured, disabling sending events
Puma starting in single mode...
* Version 4.3.6 (ruby 2.7.2-p137), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

Both for ourselves and for teammates using this devcontainer, we’d like the command-line environment to be as comfortable, homelike, and unsurprising as possible. So we’d like to put these common aliases somewhere in the configuration where it will stick around and be available anytime we use the container.

Now, the usual place to start for Bash shell customization is in the home directory, in either the .profile file or the .bashrc file.

root@978243ae437b:/workspace# ls -a ~
.  ..  .bashrc  .profile

And we could do that, now that we have vim installed in our devcontainer.

root@978243ae437b:/workspace# vim ~/.bashrc
...

But remember, this file is inside the devcontainer’s ephemeral filesystem. Anything we change here will be wiped out the next time we rebuild the container. We need a way to make our customization more permanent.

Let’s start by creating a new shell configuration file in the .devcontainer subdirectory. We can call it profile.sh.

And we’ll populate it with the initial set of aliases we want.

alias gs="git status"
alias be="bundle exec"

Our next task is to arrange for shells started inside our devcontainer to source this file.

A straightforward but heavy-handed way to make this happen is to use the Dockerfile COPY command to copy the file from our .devcontainer directory right over the root user’s .profile file inside the container. We’re updating the root user’s account because so far, that’s the only user account that exists in this container.

COPY profile.sh /root/.profile

This would work, but there are at least three problems with it:

  1. By overwriting the original .profile, we’ve lost any standard goodies the operating system’s default .profile gave us. This could lead to surprises later.
  2. Currently we’re using the container as the root user, and we’ve hardcoded that assumption here. But, it’s sometimes better to act as an ordinary user, even inside a devcontainer. If later we update this devcontainer to have a regular user account, we’ll lose our shell environment customizations until we remember to update the Dockerfile.
  3. But the problem that’s maybe the least obvious and the most important is that this COPY command only happens when the container is rebuilt! Which means that if we make a change to our project-specific shell config file, we won’t benefit from it until we force a rebuild. That’s not ideal.

So what do we really want to do with this file? What we want set things up so that

  • when any user logs in to the container…
  • …their shell will look for the profile.sh file inside the mapped-in project directory…
  • …and load it if it exists.

How can we do this? The same way we fix any problem in object-oriented programming: more indirection!

Specifically, we’ll create a global profile file to source our dynamically mounted profile file.

As it turns out, on most Linux systems, there’s a directory called /etc/profile.d. By default, new shells load every file in this directory.

We’ll create a second file, which we’ll call sixmilebridge-load-profile.sh. (Remember, sixmilebridge is the name of our project.) This file we will copy in to the docker container.

This file goes in the /etc/profile.d directory, and we say so in a comment.

The rest of this file

  1. defines the location where the project-specific bash config file will be mounted in to the container.
  2. Tests to see if it exists and is readable
  3. If so, uses the Bash source command to evaluate it
  4. Or if not, lets us know.
# This file is intended to be copied into /etc/profile.d
project_profile="/workspace/.devcontainer/profile.sh"
if [ -r $project_profile ]; then
  source $project_profile
else
  echo "File not found or not readable: $project_profile"
fi

Now we can go back to our Dockerfile and add a COPY command to copy this file into the /etc/profile.d directory.

COPY sixmilebridge-load-profile.sh /etc/profile.d/

This is similar to our COPY command from before, but instead of enshrining our literal bash config in the container at build time, we’re copying in a loader for the project’s profile.sh file.

This solution addresses all the problems we identified before:

  1. It doesn’t copy over any system-provided files.
  2. It will work for both the root user and regular users.
  3. It will load the current project shell configuration from the project directory, every time we open a shell in the container.

Before we try this out, let’s add one more flourish to our shell customization file.

echo "*** environment loaded from ${BASH_SOURCE[0]} via ${BASH_SOURCE[1]}"

alias gs="git status"
alias be="bundle exec"

This line is easiest to explain by running it. So let’s go ahead and restart our container with a rebuild, and open a shell into it.

$ docker-compose down
Stopping sixmilebridge_app_1 ... done
Removing sixmilebridge_app_1 ... done
Removing network sixmilebridge_default
$ docker-compose up -d --build
Creating network "sixmilebridge_default" with the default driver
Building app
Step 1/3 : FROM ruby:2.7.2
 ---> 7e58098089a4
Step 2/3 : RUN apt-get update   && apt-get install -y yarnpkg vim lsof   && ln -s /usr/bin/yarnpkg /usr/local/bin/yarn   && rm -rf /var/lib/apt/lists/*
 ---> Using cache
 ---> 4a256a552b00
Step 3/3 : COPY sixmilebridge-load-profile.sh /etc/profile.d/
 ---> c99ade6ba865

Successfully built c99ade6ba865
Successfully tagged sixmilebridge_app:latest
Creating sixmilebridge_app_1 ... done
$ docker-compose exec app /bin/bash -l
*** environment loaded from /workspace/.devcontainer/profile.sh via /etc/profile.d/sixmilebridge-load-profile.sh

Here we are at the container command-line, and we can see what that last addition does. BASH_SOURCE is an array containing the bash initialization stack trace.

The way we’ve used it tells us that our config-loading setup actually worked and shows us that the script in profile.d sourced the script in .devcontainer. If we ever want to change the aliases, but we’ve forgotten where they were defined, this line gives us an informational thread to pull on.

What was all this about again? Oh yeah, aliases! Let’s try them out.

There’s gs for git status

root@e6ee674538f1:/workspace# gs
On branch devcontainers-screencast
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   .devcontainer/Dockerfile

Untracked files:
  (use "git add <file>..." to include in what will be committed)

        .devcontainer/profile.sh
        .devcontainer/sixmilebridge-profile.sh

no changes added to commit (use "git add" and/or "git commit -a")

And there’s be for bundle exec.

root@cc99a34c0fd8:/workspace# be rails s
=> Booting Puma
=> Rails 6.0.3.4 application starting in development 
=> Run `rails server --help` for more startup options
Libhoney::Client: no writekey configured, disabling sending events
Puma starting in single mode...
* Version 4.3.6 (ruby 2.7.2-p137), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

That’s a lot of setup for a couple of aliases!

But now that we have a place that we can quickly set project-related aliases and environment variables, we’ll no doubt be adding more! This is a yak shave. But it’s a yak shave that makes future yak shaves fast. Happy hacking!