Red Green Repeat Adventures of a Spec Driven Junkie

How to Conditionally Create a File in a Dockerfile Build

Docker Icon

Building from my previous post of how to conditionally copy a file in a Docker image build from a Dockerfile, I am going over how to conditionally create within a Docker image build in a Dockerfile. For example:

  • If a specified file is not in the working directory, create a file.
  • Otherwise, use the specified file.

Motivation

The main reason I want to solve this problem is I want to have a simple Dockerfile for existing Ruby on Rails projects, but also to create new Ruby on Rails projects.

With the way Docker uses images, I will always create a new Docker image based on the project, but I would like to have one goto Dockerfile for any Ruby on Rails project. For example:

  • I want to take an existing app and put it in a Docker container.
  • I want to start a new app and have it inside a Docker container.

Requirements

If you want to follow along, the approach I took with this solution will require:

I did development on a UNIX based system (such as macOS or Ubuntu), mainly for bash utility: echo.

Create File

In my example, I will create a new Ruby Gemfile. Otherwise, use the existing Gemfile (copied over in the build process).

If there needs to be a new Gemfile, create the following Gemfile with contents:

ruby '2.5.1'
source 'https://rubygems.org'
gem 'rails', '~> 5.1.0'

It’s the same as the default Gemfile I used in my Docker Rails article.

Solution

The approach I took to solve the problem of creating a Gemfile if it is not in the folder, or using the existing one is to:

  • Test for existence of the Gemfile inside the working directory.
  • If the Gemfile is not there, write a prebuilt Gemfile.

I used Bash for my solution as the system Docker is building will have Bash available.

Testing for File in Bash

The Bash snippet to test for the Gemfile existence is:

if [ ! -e "Gemfile" ]; then
# do something
fi

The do something section is where I will have the script write the file contents, but how?

Creating Files with: echo

Using another Unix command, echo, we can write the contents of a file in the form:

echo "file contents" > file.txt

So, combining the above, the function becomes:

if [ ! -e "Gemfile" ]; then
echo "ruby '2.5.1'" > Gemfile
echo "source 'https://rubygems.org'" >> Gemfile
echo "gem 'rails', '~> 5.1.0'" >> Gemfile
fi

There is a slight difference between > and >>:

  • The former, >, will overwrite the file with a new one.
  • While >>, will append to the existing file.

Small difference in syntax will have large consequences.

Making Script Executable: chmod

If we write the script file to disk out and add an execution bit to it:

$ ls
script.sh
$ cat script.sh
if [ ! -e "Gemfile" ]; then
echo "ruby '2.5.1'" > Gemfile
echo "source 'https://rubygems.org'" >> Gemfile
echo "gem 'rails', '~> 5.1.0'" >> Gemfile
fi
$ chmod +x script.sh
$ ./script.sh
$ ls
Gemfile script.sh
$ cat Gemfile
ruby '2.5.1'
source 'https://rubygems.org'
gem 'rails', '~> 5.1.0'
$

So, the script does what we want, how do we get this script inside the Dockerfile?

File COPY

One way is to write the file and have the Dockerfile copy the script over, the commands inside the Dockerfile:

...
CMD mkdir /app
WORKDIR /app
COPY script.sh .
...

This approach works, especially if the script file is large or complex.

One downside to this approach is there is another file maintain with the Dockerfile. Every project will have to include the Dockerfile and the additional file.

File RUN

Another approach is to embed the script within the Dockerfile and use Docker’s RUN command to create a script to create the file. (Think how meta that is!)

I prefer this approach

I will take advantage of the echo command from earlier, a shortened version in a Dockerfile:

...
RUN echo "file contents" > script.sh
CMD chmod +x script.sh
CMD ./script.sh
...

In this case, I chose to have the RUN command write the script out as:

RUN echo "!#/bin/bash\nif [ ! -e \"Gemfile\" ]; then\necho \"ruby '2.5.1'\" > Gemfile\necho \"source 'https://rubygems.org'\" >> Gemfile\necho \"gem 'rails', '~> 5.1.0'\" >> Gemfile\nfi" > script.sh
CMD chmod +x script.sh
CMD ./script.sh

Having the whole script in a single line is long and hard to read or change, but once it’s perfected, there only needs to be a single file to keep around, the Dockerfile.

Can I Do Better?

Actually, like all good computer scientist, I have to ask: “Can we do better?” (than having the whole file on one line)?

When I look at the Dockerfile documentation, I notice that the \ operator separates multiple line arguments on different lines.

Let’s see if that works for strings as well:

...
RUN echo "#!/bin/bash\n" \
         "if [ ! -e \"Gemfile\" ]; then\n" \
         "  echo \"Creating default Gemfile\"\n" \
         "  echo \"ruby '2.5.1'\n" \
         "         source 'https://rubygems.org'\n" \
         "         gem 'rails', '~> 5.1.0'\" > Gemfile\n" \
         "fi\n" > script.sh
...

Let’s see by building it out:

$ docker build . -t test
$ docker run -it test bash
root:test $ ls
script.sh
root:test $ cat script.sh
#!/bin/bash
 if [ ! -e "Gemfile" ]; then
   echo "Creating default Gemfile"
   echo "ruby '2.5.1'
          source 'https://rubygems.org'
          gem 'rails', '~> 5.1.0'" > Gemfile
 fi
root:test $

So, wow, we can embed a whole shell script inside the Dockerfile and have it on multiple lines, making it more readable. Now this is bootstrapping a Docker image through the Dockerfile!

Dockerfile

The section below is the Dockerfile I used for reference:

FROM ruby:2.5.0-slim

RUN apt-get update && apt-get install -qq -y --no-install-recommends \
    build-essential \
    libsqlite3-dev \
    nodejs \
    sqlite3 \
 && rm -rf /var/lib/apt/lists/*

RUN mkdir /app
WORKDIR /app

COPY Dockerfile Gemfile* .

RUN echo "#!/bin/bash\n" \
         "IFS=';'\n" \
         "additional_gems=\"$add_gems\"\n" \
         "if [ ! -e \"Gemfile\" ]; then\n" \
         "  echo \"Creating default Gemfile\"\n" \
         "  echo \"ruby '2.5.1'\n" \
         "         source 'https://rubygems.org'\n" \
         "         gem 'rails', '~> 5.1.0'\" > Gemfile\n" \
         "  for i in \$additional_gems; do\n" \
         "    echo \"gem \$i\" >> Gemfile;\n" \
         "  done\n" \
         "fi\n" > script.sh

RUN chmod +x script.sh
RUN ./script.sh

RUN bundle install

RUN rails new temp_app
RUN rm -rf temp_app

The file is also downloadable here

To build and test, just run:

$ docker build . -t image_name
$ docker run -it image_name bash
/app $ ls
Dockerfile script.sh

Conclusion

I wanted to have a Dockerfile that I can use to:

  1. Create a new Rails project
  2. Dockerize and existing Rails project

By having a shell script to create a script to create a new Gemfile, I have solved the former while keeping compatibility with the latter using a file copy trick from my previous article.

So, now I will be using this Dockerfile for my projects, either new or existing!