Why we’re doing this
Docker is difficult
In my experience, Dockerizing a Rails application for the first time is pretty hard. Actually, doing anything with Docker seems pretty hard. The documentation isn’t that good. Clear examples are hard to find.
Dockerizing Rails is too ambitious as a first goal
Whenever I do anything for the first time, I want to do the simplest, easiest possible version of that thing before I try anything more complicated. I also never want to try to learn more than one thing at once.
If I try to Dockerize a Rails application without any prior Docker experience, then I’m trying to learn the particulars of Dockerizing a Rails application while also learning the general principles of Docker at the same time. This isn’t a great way to go.
Dockerizing a Sinatra application gives us practice
Dockerizing a Sinatra application lets us learn some of the principles of Docker, and lets us get a small Docker win under our belt, without having to confront all the complications of Dockerizing a Rails application. (Sinatra is a very simple Ruby web application framework.)
After we Dockerize our Sinatra application we’ll have a little more confidence and a little more understanding than we did before. This confidence and understanding will be useful when we go to try to Dockerize a Rails application (which will be a future post).
By the way, if you’ve never worked with Sinatra before, don’t worry. No prior Sinatra experience is necessary.
What we’re going to do
Here’s what we’re going to do:
- Create a Sinatra application
- Run the Sinatra application to make sure it works
- Dockerize the Sinatra application
- Run the Sinatra application using Docker
- Shotgun a beer in celebration (optional)
I’m assuming you’re on a Mac and that you already have Docker installed. If you don’t want to copy/paste everything, I have a repo of all the files here.
(Side note: I must give credit to Marko Anastasov’s Dockerize a Sinatra Microservice post, from which this post draws heavily.)
Let’s get started.
Creating the Sinatra application
Our Sinatra “application” will have just one file. The application will have just one endpoint. Create a file called
hello.rb with the following content.
# hello.rb require 'sinatra' get '/' do 'It works!' end
We’ll also need to create a
Gemfile that says Sinatra is a dependency.
# Gemfile source 'https://rubygems.org' gem 'sinatra'
Lastly for the Sinatra application, we’ll need to add the rackup file,
# config.ru require './hello' run Sinatra::Application
After we run
bundle install to install the Sinatra gem, we can run the Sinatra application by running
$ bundle install $ ruby hello.rb
Sinatra apps run on port
4567 by default, so let’s open up
http://localhost:4567 in a browser.
$ open http://localhost:4567
If everything works properly, you should see the following.
Dockerizing the Sinatra application
Dockerizing the Sinatra application will involve two steps. First, we’ll create a
Dockerfile will tells Docker how to package up the application. Next we’ll use our
Dockerfile to build a Docker image of our Sinatra application.
Creating the Dockerfile
Here’s what our
Dockerfile looks like. You can put this file right at the root of the project alongside the Sinatra application files.
# Dockerfile FROM ruby:2.7.4 WORKDIR /code COPY . /code RUN bundle install EXPOSE 4567 CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
Since it might not be clear what each part of this file does, here’s an annotated version.
# Dockerfile # Include the Ruby base image (https://hub.docker.com/_/ruby) # in the image for this application, version 2.7.4. FROM ruby:2.7.4 # Put all this application's files in a directory called /code. # This directory name is arbitrary and could be anything. WORKDIR /code COPY . /code # Run this command. RUN can be used to run anything. In our # case we're using it to install our dependencies. RUN bundle install # Tell Docker to listen on port 4567. EXPOSE 4567 # Tell Docker that when we run "docker run", we want it to # run the following command: # $ bundle exec rackup --host 0.0.0.0 -p 4567. CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
Building the Docker image
All we need to do to build the Docker image is to run the following command.
I’m choosing to tag this image as
hello, although that’s an arbitrary choice that doesn’t connect with anything inside our Sinatra application. We could have tagged it with anything.
. part of the command tells
docker build that we’re targeting the current directory. In order to work, this command needs to be run at the project root.
$ docker build --tag hello .
docker build command successfully completes, you should be able to run
docker images and see the
hello image listed.
Running the Docker image
To run the Docker image, we’ll run
docker run. The
-p 4567:4567 portion says “take whatever’s on port 4567 on the container and expose it on port 4567 on the host machine”.
$ docker run -p 4567:4567 hello
If we visit
http://localhost:4567, we should see the Sinatra application being served.
$ open http://localhost:4567
Congratulations. You now have a Dockerized Ruby application!
With this experience behind you, you’ll be better equipped to Dockerize a Rails application the next time you try to take on that task.
We do a couple of things differently. However, I think the most interesting is that we use the ENTRYPOINT directive in the Dockerfile to do bundle exec and then run the rails app on the CMD directive.
This allows us to run the rails console inside the running container like “docker exec -it rails-app rails console”. So this would include any rails or rake task.
It’d be interesting to hear your thoughts as to the “why” it’s considered good or necessary to Dockerizing these web applications. What do we gain from the process? Operation/Administration, Monitoring, Deployment, etc..
Truly curious! I’ve been through the process a number of times just to get familiarity with containerization but I still don’t see the pros.
That’s a really good question. It has taken me a while to come to an understanding myself.
For me it’s two things. First, we recently hired a developer where I work and we’re planning to hire more. The process of getting our developer set up with our application was a fairly painful and manual process. It would be nice if future developers could just download and run a Docker image instead.
Second, I expect that it might make deployment and operations easier. Rather than having an Ansible playbook that configures each server the way it needs to be, I can just have a Docker image that gets run in the cloud instead. (Tools like Ansible may well still have their place in our infrastructure because Docker of course isn’t meant to do everything.)
So with those two things in mind, we’d go from two different ways of getting the app set up (one for dev environments and one for prod) we’d have just one, which could be simpler.
“Docker can apparently only expose port 80.”
Don’t think that is true, but perhaps on your machine traffic to other ports is blocked?
$ docker run -p 8080:4567 hello
your container should respond to requests at
I second Jochen’s contribution :-).
If you want to access your app on port 80 you just have to start
docker run hello -p 4567:80
thank you for the article!
sorry, just re-read everything. Please disregard my former message.
I’d strongly recommend breaking out the copy command so that you first copy the Gemfile & Gemfile.lock, then do bundle install, and then copy the rest of your application code separately, like this: https://github.com/ministryofjustice/cloud-platform-helloworld-ruby-app/blob/main/Dockerfile
If you do it all in one, i.e. “COPY . /code”, then whenever you run `docker build` you’ll be reinstalling all your gems, even if nothing changed, because *any* change in *any* of your code files will cause that step to be repeated.
If you do it separately, like the example I linked to, then you only reinstall your gems if your Gemfile changes, not on every code change. That will result in *much* faster docker builds as you iterate your code. The general rule is, put the stuff that changes often near the bottom of your Dockerfile.
Very nice! Thanks for the article, it helped me.
To me it looks like the bundler approach duplicates the isolation efforts of the libs? Didn’t we already gain isolation by using a docker container? Why not just installing the required libs directly inside the Dockerfile?
Sorry, I’m not sure what you mean. Can you clarify?