EDIT 2021-04-01: When using ruby with docker compose, as noted by a commenter, bundle install should be run as the root user. I’d mistakenly set the user before running the bundle install layer. I’ve updated the code below to fix this. If you had problems before, try the updated examples.
I’ve recently begun experimenting with Docker and docker-compose in my Ruby development and am fairly pleased with the results. Building ruby with docker-compose keeps my environment clean while giving me a working rails tool set. I derived most of my workflow from this guide. While that served quite well as a starting place, one major annoyance cropped up again and again: my container ran as root.
The Problem at Hand
First, running as root in production creates an obvious security risk. Containers are supposed to isolate processes. However, history tells us that hackers find creative ways to “crash-out” of containers into the host operating system. If your process runs as root, hackers escalate to administrative privileges if they succeed.
Second, running code as root complicates your local development process. Rails creates temporary files. These files complicate cleanup on your local copy of the code. Further, you must specify special user parameters in order to run Rails commands, such as generators or migrations. These reasons alone motivated a change in process for me, regardless of security.
Goals
Let’s establish some goals going forward.
First, we will build a Dockerfile
which can be shared between production and our development environment. This file will enforce consistency in how we build our production or development images, whether they run locally or in a production cluster.
Second, we will build an environment where containers envelope all of our Ruby and Rails tools. We will not install any Ruby or Rails tooling into our host operating system.
Third, we will run as a non-root user in both production and development. This measure limits our attack surface and helps us achieve our final goal.
Finally, we will set the user running on our local development instance as our own user that owns the checked out code. This process ensures that generated files remain consistent with the rest of our code files. Additionally, this user can run Rails generators, migrations, and other commands naively without specifying special UIDs. We’ll run our ruby with docker-compose to set up this development environment.
The Starting Point – An Imperfect Setup
Following the Docker guide on Rails applications lead to the creation of the following Dockerfile
and docker-compose.yml
files.
#Dockerfile
FROM ruby:2.6-alpine
LABEL maintainer="Aaron M. Bond"
ARG APP_PATH=/opt/myapp
RUN apk add --update --no-cache \
bash \
build-base \
nodejs \
sqlite-dev \
tzdata \
mysql-dev && \
gem install bundler && \
mkdir $APP_PATH
COPY docker-entrypoint.sh /usr/bin
RUN chmod +x /usr/bin/docker-entrypoint.sh
WORKDIR $APP_PATH
COPY Gemfile* $APP_PATH/
RUN bundle install
COPY . $APP_PATH/
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
As a quick review, this file first pulls an image build for Ruby applications running the 2.6 family of Ruby versions. It then installs necessary operating system packages and creates an application folder.
Next it copies in an entrypoint script, which will be the default entry for any images created with docker run
. (In my case, this command cleans up the Rails server pidfile and runs whatever command is passed.)
The build file then copies Gemfile*
into the application folder and runs bundle install
to install and compile necessary gems.
Finally, the file indicates its entrypoint, notes that port 3000 will be exposed, and sets up a default command argument to simply run rails server -b 0.0.0.0
.
With no modifications, all of these steps will execute as root
within the built image.
# docker-compose.yml
version: '3'
services:
db:
image: mysql:5.7
environment:
- MYSQL_ROOT_PASSWORD=somesecret
- MYSQL_DATABASE=myapp
- MYSQL_USER=myapp_user
- MYSQL_PASSWORD=devtest
volumes:
- datavolume:/var/lib/mysql
web:
build:
context: .
dockerfile: Dockerfile
command: bash -c "rm -f /tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/opt/myapp
ports:
- "3000:3000"
depends_on:
- db
tty: true
stdin_open: true
volumes:
datavolume:
This docker-compose.yml
file creates services for use in our development environment. (Another technology will handle production, such as a Kubernetes deployment.)
First, we define a db
service (container), which utilizes the MySQL image from Docker Hub and passes some information specific to the image for database creation.
Second, we define a web
service (container), which builds an image from our Dockerfile
and mounts our local code directory over the top of the previous application directory. This should enable us to see simple changes instantaneously, without rebuilding the image.
The Cracks in the Facade
If you start here with your application and run docker-compose up
, you’ll be able to see some of the issues that arise from root execution. The root process within the container will pollute your app’s tmp
directory with root-owned files that make cleanup annoying.
Generators demonstrate a bigger problem. Assume we wanted to generate a new controller called Greetings
with an action of hello
(yes, I blatantly stole this example directly from the Ruby on Rails guide). The following command should create an ephemeral container with our image, run the rails generator, and remove the image (--rm
) when complete.
docker-compose run --rm web bundle exec rails generate controller Greetings hello
This appears logical, but the command will result in a mess. The root user would now own all of the files generated by this command within our source code. We can solve this problem by adding a bit of a hack:
docker-compose run --rm --user $(id -u):$(id -g) web bundle exec rails generate controller Greetings hello
This offensive little command runs the id
utility of Linux (twice) to get our UID and GID and passes that to the run command. Now, our generators will run using our own user identity. However, the ugliness of this command offends my delicate sensibilities.
Even after we complete our clunky development process, our local system administrator will definitely complain that our Rails server is running as root in our cluster.
Mitigation Step 1 – Adding an App-Specific User
To begin untangling ourselves from root, we must start by creating a non-root user within our image. This user should run our Rails server process and take over when the application-specific portions of the image are built. Take a look at the below, modified version of our Dockerfile
to see how we add an app user.
#Dockerfile
FROM ruby:2.6-alpine
LABEL maintainer="Aaron M. Bond"
ARG APP_PATH=/opt/myapp
ARG APP_USER=appuser
ARG APP_GROUP=appgroup
RUN apk add --update --no-cache \
bash \
build-base \
nodejs \
sqlite-dev \
tzdata \
mysql-dev && \
gem install bundler && \
addgroup -S $APP_GROUP && \
adduser -S -s /sbin/nologin -G $APP_GROUP $APP_USER && \
mkdir $APP_PATH && \
chown $APP_USER:$APP_GROUP $APP_PATH
COPY docker-entrypoint.sh /usr/bin
RUN chmod +x /usr/bin/docker-entrypoint.sh
WORKDIR $APP_PATH
COPY --chown=$APP_USER:$APP_GROUP Gemfile* $APP_PATH/
RUN bundle install
USER $APP_USER
COPY --chown=$APP_USER:$APP_GROUP . $APP_PATH/
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
Here, we’ve added some variables for an app user name and app group name under which we intend to run.
Our initial setup step, which still runs as root, uses addgroup
and adduser
to create the specified group and user. Additionally, after we’ve created our application path, we change the owner to said user and group.
Once we’ve completed other root tasks (such as pushing our entrypoint), the USER
directive instructs Docker that all other RUN
directives and the container execution itself should be run as our app user. We also add our app user and group as the --chown
argument to the COPY
directives which push our app into the container. If we built an image and ran this container right now, the app would execute as a new, non-root user.
While this is a fantastic first step and secures our application in production, we’ve missed the mark on making our development environment easier to use.
While appuser
isn’t root, it’s still some random user within the container which doesn’t match our local machine’s user. Files are still going to be created as a non-matching user in the tmp
directories and by any generator commands we run in containers.
Mitigation 2 – Making our App-Specific User Match the Development User
To relieve our development pain, we have to force our containers to act as our own host user when working with our source code. Fortunately for us, Linux sees users and groups only by their IDs.
In our images, we’ll have to explicitly set IDs for the UID and GID that the application (by default) will utilize. Then, in development, we’ll want to override that default with our own UID and GID.
Let’s start by adding more build arguments in the Dockerfile
for our two ids and using those arguments in our addgroup
and adduser
commands.
#Dockerfile
FROM ruby:2.6-alpine
LABEL maintainer="Aaron M. Bond"
ARG APP_PATH=/opt/myapp
ARG APP_USER=appuser
ARG APP_GROUP=appgroup
ARG APP_USER_UID=7084
ARG APP_GROUP_GID=2001
RUN apk add --update --no-cache \
bash \
build-base \
nodejs \
sqlite-dev \
tzdata \
mysql-dev && \
gem install bundler && \
addgroup -g $APP_GROUP_GID -S $APP_GROUP && \
adduser -S -s /sbin/nologin -u $APP_USER_UID -G $APP_GROUP $APP_USER && \
mkdir $APP_PATH && \
chown $APP_USER:$APP_GROUP $APP_PATH
COPY docker-entrypoint.sh /usr/bin
RUN chmod +x /usr/bin/docker-entrypoint.sh
WORKDIR $APP_PATH
COPY --chown=$APP_USER:$APP_GROUP Gemfile* $APP_PATH/
RUN bundle install
USER $APP_USER
COPY --chown=$APP_USER:$APP_GROUP . $APP_PATH/
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
Setting these IDs up as ARG
directives with a default value opens the door to docker-compose.yml
to override them. The numbers are not terribly important. You should pick IDs that are in the standard user and group id ranges. Also, by best practice, ensure your different apps have unique IDs from each other.
Next, we’ll add these arguments to the docker-compose.yml
file.
# docker-compose.yml
version: '3'
services:
db:
image: mysql:5.7
environment:
- MYSQL_ROOT_PASSWORD=somesecret
- MYSQL_DATABASE=myapp
- MYSQL_USER=myapp_user
- MYSQL_PASSWORD=devtest
volumes:
- datavolume:/var/lib/mysql
web:
build:
context: .
dockerfile: Dockerfile
args:
- APP_USER_UID=${APP_USER_UID}
- APP_GROUP_GID=${APP_GROUP_GID}
command: bash -c "rm -f /tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/opt/myapp
ports:
- "3000:3000"
depends_on:
- db
tty: true
stdin_open: true
volumes:
datavolume:
Note that under the web
service definition’s build
key, we’ve added an args
section referencing our two args. Here, we’re setting them as equal to environment variable values of the same name. Unfortunately, we can’t specify default environment variable values in the docker-compose.yml
file; but, we can add a special file called .env
that specifies these values.
#.env
APP_USER_UID=7084
APP_GROUP_GID=2001
As we’ve currently built everything, docker-compose up
will still have the undesired behavior of running as a differing UID and GID; but, passing overriding values to those environment variables allows us to run as ourselves.
APP_USER_UID=$(id -u) APP_GROUP_GID=$(id -g) docker-compose up --build
After we’ve run the build a single time, our local development version of the image will execute as a user matching our UID and GID by default. Any docker-compose run
commands we run after this step will execute properly.
However, I don’t want to have to remember this every time I rebuild this container image (or build any other container image). So, I will specify in my .bashrc
file on my local machine that these two environment variables should always be set to myself.
#Added to the bottom of ~/.bashrc
export APP_USER_UID=$(id -u)
export APP_GROUP_GID=$(id -g)
So long as I am consistent in naming these variables in my Dockerfile
and docker-compose.yml
files of other projects, I will get a consistent environment for every project.
A Quick Aside
I want to highlight one problem that I ran into that, while specific to my environment, might bite someone else. Few people will see this issue, but for completeness, I’m noting it here.
When using the above setup, I ran into build failures when building my Docker image. Turns out, since my user on my development machine is an Active Directory user, the UID it utilizes is NOT within the sane range of Linux UIDs. The same was true of my group id.
abond@abondlintab01:~$ id -u
500000001
abond@abondlintab01:~$ id -g
500000003
Since Active Directory used such large IDs, I couldn’t utilize this user’s UID and GID for the container IDs. The build would fail on attempting to run addgroup
.
$ APP_USER_UID=$(id -u) APP_GROUP_GID=$(id -g) docker-compose build
db uses an image, skipping
Building web
Step 1/18 : FROM ruby:2.6-alpine
...
Executing busybox-1.30.1-r2.trigger
OK: 268 MiB in 75 packages
Successfully installed bundler-2.1.0
1 gem installed
addgroup: number 500000003 is not in 0..256000 range
ERROR: Service 'web' failed to build: The command '/bin/sh -c apk add --update --no-cache bash build-base nodejs sqlite-dev tzdata mysql-dev postgresql-dev && gem install bundler && addgroup -g $APP_GROUP_GID -S $APP_GROUP && adduser -S -u $APP_USER_UID -G $APP_GROUP $APP_USER && mkdir $APP_PATH && chown $APP_USER:$APP_GROUP $APP_PATH' returned a non-zero code: 1
I resolved this by creating another Linux user (not on the domain) with a sane UID which I use to develop Ruby apps. This shouldn’t be necessary for most users.
A Quick Review
Using Ruby with docker-compose can simplify your development processes and keep your environment slim.
However, running containers as root is a bad security practice. The default instructions given by Docker for Rails app development provide a functional setup, but ignore the security of root privileges. Further, running as root in dev complicates your workflow and your environment.
By creating a default app user and group with a specific UID and GID, you eliminate root processes in your container and make your production sysadmins happy.
To take it a step further, you can override that UID and GID on your machine to mach YOUR user and simplify your development workflow.
Docker and containers are great tools for development; but, finding environment settings and patterns can be difficult. Hopefully, this pattern helps someone out there as new running ruby with docker compose as I was when I started.