My Profile Photo

AndrewCz


Using liberty-minded opensource tools, and using them well


Dockerizing a Ruby on Rails application


A brilliant coder has put together a Ruby on Rails application to manage time-tracking and time-estimating. Last time we were drinking, I told him that I'd make it into a docker container. So I decided to finally do it. Here's what I found out.


Clone the code. Here's the repo.

Docker Compose

Throughout researching this, I found out that docker has a helper command that can be invoked with docker-compose. This is basically a yaml-formatted set of instructions on the end result of how a pair or several containers should be set up. Not very unlike Ansible.

Now, it’s funny, because right then, I did mention that multiple containers could be used. Docker Compose actually sets it up so that they can both be aware of each other, and gives many little shortcuts and tips to get them to work. It actually can (and did, in my case) directly substitute all of the typical docker commands. At the very end of the setup, I got the deploy down to 3 total commands and 3 (or so) files. Let me list them, then use them as a jumping-off point to describe what I had to do.

  • docker-compose build
  • docker-compose run web rake db:setup
  • docker-compose up

Using:

  • Dockerfile
  • docker-compose.yml
  • database.yml

Build

So the build portion did a couple things. First off, it took a look into the docker-compose.yml file to see what I had defined in there.

First off, I had a container named ‘db’ that was to be the postgres database. That container didn’t need anything special at all. In fact, it didn’t need to expose any ports, have any environment variables set (for development, anyways), all it needed to do was be there inside of that network that docker-compose up created. More on that later though. This meant that all I had to do was maintain an image of the latest version on my machine, and it would never have to re-build. Every time I ran docker-compose build, it just stated that since it was an already downloaded non-customized image it would skip it.

Next though, there was the matter of the web front-end. J3RN had done an incredible job with this program, so it was supremely easy for me to figure out what to do here. My first concern was to get a container with:

  1. The correct environment, and
  2. The program installed and ready to be started up

This falls under the realm of a Dockerfile.

In this Dockerfile, I was able to run shell commands, set environment variables, set $PWD’s, and copy raw files onto the container. This helped me accomplish what I needed to quite easily as far as setting up the environment. After that, I was able to copy all of the app’s necessary files into its running directory. As a side note, I typically use /srv instead of /var/www, but that’s just a personal preference. For this app, it didn’t seem to matter either way. The whole process was a lot sloppier than Ansible, but this is what it uses.

Most importantly, I set the CMD declaration. There can only be one active CMD declaration in any given Dockerfile. This is the command that is run when the container is spun up - that would be the docker-compose up command in this case. The documentation in the time-tracker repo gave me the startup command - bundle exec passenger start. I added -p 8000 to set the port number, because I typically use non-standard ports. That’s an 95% arbitrary decision though.

It wasn’t as bad as setting up everything from scratch though. I was able to use a FROM declaration that meant that I could run my commands on top of an existing image. I chose ruby:2.3.1 as I needed a ruby environment, and the program was based on version 2.3.1 of ruby. Luckily Docker set their naming scheme to make sense for ruby. I could have also used ruby:latest or ruby:slim, but for the time being I just wanted to take the easy way out.

So, now that the Dockerfile was defined with the ability to set up the environment, app files, and startup command, I was able to build it. Like I mentioned above, after the first time downloading the postgres image, all subsequent runs skipped that as the requirement was that it only be present. When it came to the web server, it took a very interesting path down the Dockerfile. It went straight through, top to bottom. As it did so, it took snapshots one line at a time. So on later runs, if I had only changed the second-to-last line, it would skip all the initial snapshots up to that point, and then build with the new directive. This made re-building very quick, but there were some commands that definitely needed to come before others, so there were times where a line had to be inserted at the beginning and I would have to wait through the entire thing again. The longest was bundle install by far. No surprise there.

After that, I had two built, but not-running images. They also weren’t linked or setup, so I had to take care of that.

Run Web Rake db:setup

In my docker-compose.yml file, there’s a directive named depends_on underneath web. That value is db. To translate, this means that my web frontend requires my db image to be started up before the web frontend itself starts up. This second command issued therefore starts up the db container first, and then the web frontend.

After the web frontend is started up though, it has a command that it has to run. Why couldn’t this have been included in docker-compose build inside of the Dockerfile you ask? Well, when the container was being built, there was no db container running, for it to act on. Also, building an image should not rely on another image or another filesystem. So we had to bring both of them up in order for them to run this command.

For the uninitiated (as I was before I took this on), db:setup does exactly what it looks like. It takes a couple SQL schemas and applies them to the database that it will be using. Therefore, if there was no postgres container up and running, it errored out because it didn’t have any database to act on.

Now that run was just a “run this command and then stop”, instead of a “spin yourself up, starting with this command”. So to get to a useful, running instance, there was one more command to run.

Up

This is the same command as vanilla docker, but like all the commands previously here, it looked at the docker-compose file to figure out what it should run. In this case, it spun up two containers. One of them was a vanilla postgres image. That was connected to, (and required by) the second, which was a web front-end built using that aforementioned Dockerfile. That web frontend had an environment variable RAILS_ENV which was set to development. It also exposed its own port 8000 to localhost:8000.

At that point, the web browser on the local machine could navigate to localhost:8000 and start messing around with the application.

database.yml

There was one hangup that really got me. So, apparently in Ruby on Rails applications, there’s a directory - config/ - and a file under there - database.yml - that determines where the database is found and what variables to use.

Like I had mentioned above, Docker Compose lets the two containers exist within a very special network setup in that they are aware of each other. Something to do with their /etc/hosts file. However, the application defaults to looking for the database on localhost. Here’s the conundrum. That behavior should be default for the application, but the docker way of running it should figure out where the database is. Namely, the directive host should be set to the database’s hostname, and the username and password should be correct.

As I’m only using the default postgres image, I contented myself with using the default user/pass and hostname combinations. This was postgres for the user, nothing for the password and db for the hostname. That meant that I needed to specify those in the application’s database.yml file. Thinking hard about this though revealed two truths. Since the directory structure mattered, I couldn’t just change the default behavior by putting the variables into the actual database.yml file. Also, this only mattered when I was using docker.

During my research to solve this, I found that another project that I use extensively - kanboard - also had a way to dockerize itself. There were four files that it needed to change based on if it was in a docker container or not. What it did was to create a docker folder in the root directory of the project and put the modified files in there, and leave the vanilla files in the default spots in the project. Then, in the Dockerfile, after the project’s structure had been added to the container (ADD . $APP_DIRECTORY) they overwrote the files that needed docker-specific configurations. They did this with a COPY directive and simply put them where they needed to go. After implementing that solution, I had my default behavior preserved while allowing for a docker deployment.

Exposing data directories

Conclusion

After all of that, I ended up with three extra files, and one copied file that was now slightly different in another location. Not too bad for dockerization. I don’t think there’s much more to it. Going forward, it would be nice to have an all-in-one image, but I would have to spend a significant amount of time crafting a Dockerfile for that. It would also be quite large, but worth it for those who would rather download an all-in-one image to just test out the software.

I will definitely be doing more of this. This is awesome. OpenVZ looks promising too…


References: