[ToDoList] Dockerising the App

So we've covered all the basic Ruby and Rails magic that has allowed us to build a basic web application, as well as made it look like a passably professional piece of work. This certainly deserves another whoop of celebration if you're not already whooped out.
However at this point, you might already have run into problems with the joys Ruby / Rails / Bundler / Node versions, particularly if you have been working on different systems. It is also entirely possible that you're a bit sick of the VM or online IDE you are using to build the app, and you are hoping for a better solution.

The solution to both of these is the mighty Docker (other container frameworks are available).

You may have already heard of Docker, being a tool that essentially revolutionised app development and deployment by using a funky little addition to the Linux kernel: namespaces. Think of these almost as Virtual Machines (though much, much cooler and resource-friendly) that allow isolated instances of other environments to run simultaneously with your primary one.

Docker contains a repository of images (think snapshots of operating systems) from which to base custom images from in order to provide containers that will run exactly the same no matter which system you are running it on for a fraction of the computing resource and space of a virtual machine, addressing the two issues presented above!
As an extra flex, Docker is built with Google's Go language, and is a huge part of the reason that Windows has an Ubuntu shell available on it.

Obviously it goes without saying that if you were brave enough to build the app in Docker from the start using docker-compose, you can probably largely skip this step. Still, you might learn something.

  1. Installation and Setup
  2. Docker Configuration Files
  3. Building the Image and Container

 


 

So before we can use this incredible piece of software, we need to get it onto our host machine. This won't be the VM or online IDE you have probably been using up until now, but your primary operating system. This guide will, of course, focus on installing Docker to an Ubuntu/Linux system (which can be used with Mac as well).
Windows installation will obviously be a bit different and is covered quite extensively by Docker in their docs.

So, let's the required packages:

$ sudo apt install docker.io docker-buildx
That's it. That's all we need.
Well, not quite. Because Docker interacts with some very high-level stuff, it will automatically require root access to run every time (i.e. sudo). Luckily, we can get around this and wield the power of Docker from our normal user by creating a docker group and adding ourselves to it:
$ sudo groupadd docker
$ sudo usermod -aG docker $USER
$ newgrp docker  ## run this in each new terminal until you reboot
That last command is required to refresh the group config in your current user session - once you log out or reboot, you won't need to run this again as your user will load with the group config.

 


 

As mentioned previously, Docker works with images to clone instances of operating systems onto containers where they are run in their own isolated namespaces. This means that, in order to work with Docker, we will need an image of our app running in an operating system and clone this to a container where it can run in peace.
If this sounds scary, that's good. It's incredible stuff we're working with here. But don't let that fear put you off, because Docker has made this literally too easy.

The configuration for our image and container is specified in a DockerFile that is specific to the application we are wanting to Dockerise. This does a large number of jobs using a number of key-value combinations explained below:

  • Source Image
    • The FROM key denotes which source image Docker will use to build our new image from - in this instance, we will use a ruby source image.
    • It is hugely advised that a version is specified with the selected image - in most cases, this should be the same version of Ruby that is specified at the top of our Gemfile:
      $ cat Gemfile | head -n 5
      source 'https://rubygems.org'
      git_source(:github) { |repo| "https://github.com/#{repo}.git" }
      
      ruby '3.3.0'
      Not doing this so will default to the latest image every time our app's image is rebuilt, which will cause issues when this version passes the version in your Gemfile.
    • If you really want to use the latest available version of Ruby from Docker, but cannot install this on your local machine, see the guide for setting up New Rails Apps with Docker Compose which outlines how to create the app purely within Docker containers (meaning your are not limited to the highest version of Ruby available to your local machine's OS). It is hugely recommended that you read this section and its subsection before this, though.
  • Setup Instructions
    Each RUN value will be run in the shell of the container that is creating the image, which allows us to run various commands including:
    • Updating the apt package cache and installing the required dependencies from it (node.js package manager and postgres client).
    • Installing yarn and a recent, stable version of node.js with npm that we installed with apt.
      (Note that again, we are specifying a specific numbered version of node and not just the a labelled version).
    • Running bundler to pull our gem dependencies for our app.
    • Manipulating the filesystem and permissions so as to avoid issues later.
    • Creating our database and running the migrations we have generated.
  • Directory Population
    • Each ADD value will copy the specified file or directory to the passed location on the image.
      In this instance, we will be copying the entire application directory (.) over to the to_do_list directory on the image.
    • WORKDIR denotes the location that Docker will run the subsequent actions from, until a new WORKDIR is declared.
  • Port Access
    Port numbers passed to EXPOSE will be made available from the container by Docker - in this case we want to expose port 3000 as this is where the Rails development server runs.
  • Container Function
    An array making up parts of the command we want the container to run when it spins up are specified with the CMD key - here we are just running the rails server with a binding to the required internal network address:
    bundle exec rails s -b 0.0.0.0.

Using what we've learned above, we can build our Dockerfile for the image and container we want to create, and put in the app's root directory:

FROM ruby:2.7.0                                         ## matching ruby version in our Gemfile
RUN apt update -qq \                                    ## update package cache
    && apt install -y npm postgresql-client             ## install packages not included in ruby image
RUN npm install --global yarn n                         ## install yarn and node
RUN n 16.4.0                                            ## install stable node.js
ADD . /to_do_list                                       ## copy app to image
WORKDIR /to_do_list                                     ## run all subsequent actions from this dir
RUN bundle install                                      ## install gems
RUN mkdir tmp/db && chmod -R 777 tmp/db                 ## avoid issues later with database permissions
RUN bundle exec rails db:create                         ## create the database
RUN bundle exec rails db:migrate                        ## run migrations
EXPOSE 3000                                             ## make port 3000 accessible to us
CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0"]   ## run the rails server when the container is run
Note here the one downside of stock Docker images - if, for example, nodejs sees a much more rapid release schedule than ruby, the version we are able to use is limited by the environment that our container exists in (e.g. operating system). There are usually ways around this, such as using the image of the utility that receives more updates and installing the other tools onto it. However, in this example, nodejs's own package manager (npm) makes upgrading extremely easy!

The final thing to do before we build our image is to set up a .dockerignore containing, you guessed it, the files we want Docker to ignore. Luckily, the files we want Docker to ignore is almost identical to what we want git to ignore, so we can start by just copying the .gitignore file:

$ cp .gitignore .dockerignore
and add any further files we want to ignore at the bottom - in this case, we just want to include the Git & Docker configuration files, as well as the temporary database objects:
/.git*
/Dockerfile*
/docker-compose*
/.dockerignore
/tmp/db
/config/database.yml.*

 


 

With our basic config set up, we can now set about creating an image of our application, based on the ruby:2.7.0 image we specified in the Dockerfile. All we need to do is run the command and specify the current directory (.), and maybe give it a nice repository name, too:

$ docker build -t to_do_list .
This will run through all of the steps specified in our Dockerfile (in order), including pulling the source image down from DockerHub if we don't already have it, and save it to an image. If all goes well, we can see our lovely new image afterwards with:
$ docker image ls
REPOSITORY       TAG       IMAGE ID       CREATED         SIZE
to_do_list       latest    f0c2ad6a602a   2 minutes ago   1.5GB
ruby             3.3       dddf6b6befbf   2 weeks ago     989MB
Docker has also kindly given us a tag signifying that the image is the latest build, and a unique image ID.

With all this done, we can now create a container from our image using an almost equally easy command that names the container and points it at an image.
The only difference here is that we need to bind the port exposed by Docker (3000) to an available one on our host machine. This can easily be the same port but, just for fun, we'll set it slightly different:

$ docker run --name to_do_list_web -p 8008:3000 to_do_list
Our container is now created and running with the output of the command showing in our terminal. If we open another terminal (remembering newgrp docker if you haven't rebooted yet), we can check it using Docker's own ps command:
$ docker ps
CONTAINER ID   IMAGE        COMMAND                  CREATED        STATUS        PORTS                                       NAMES
8d12ba454808   to_do_list   "bundle exec rails s…"   1 minute ago   Up 1 second   0.0.0.0:8008->3000/tcp, :::8008->3000/tcp   to_do_list_web
which again shows some useful information with the container's ID & name and the image used, the command it is running and the port configuration as well as uptime indicators.

If we wanted to check that the container has all of the goodness we need it to, or indeed to check it doesn't have anything too incriminating in there, we can even access the bash shell that is running on the container using Docker's exec functionality, passing it the container name / ID and the command we want to run:

$ docker exec -it to_do_list_web /bin/bash
root@8d12ba454808:/todoapp-test#
and just like that, we're in the container in the WORKDIR we specified in the Dockerfile.

If we now navigate to http://localhost:8008 in our browser, we can see our beautiful app presents itself!

ToDoList running in Docker

When we're ready, hitting Ctrl + c in the terminal running our container will kill it.
If we then want to start the container again, this is done differently to how we built the container - if you don't remember the name or ID of the container we can find it with:

$ docker ps -a  ## a for all, meaning offline
CONTAINER ID   IMAGE        COMMAND                  CREATED        STATUS                      PORTS                                       NAMES
8d12ba454808   to_do_list   "bundle exec rails s…"   1 hour ago     Exited (1) 30 minutes ago   0.0.0.0:8008->3000/tcp, :::8008->3000/tcp   to_do_list_web
and then simply pass the name or ID of the container to Docker's start command to start it up again in the background:
$ docker start to_do_list_web
Docker will remember the port configuration and we gave it when we built it, so it's literally just a case of starting it with the name.
Because this started in the background, you might wonder how we can stop this container - Docker has your back:
$ docker stop to_do_list_web
Who'd have thought something so cool could be so simple?

Now, the caveat to all of this. Obviously, because Docker is building each container from an image, every time we make a code change we need to rebuild the image and the container. This can lead to some pretty messy lists of Docker images, especially if you're tracking a bug and rebuilding the image several times an hour...... (not me, I'm angry on a friend's behalf..).

The simplest way around this, and preferable to those with limited storage space, is to delete your container and image before you build the next version:

$ docker rm to_do_list_web
to_do_list_web
$ docker image rm to_do_list
Deleted: sha256:d01c433a3996e28fa9c6c8596071c910e66094743db5dc01a679883d455159d3
Deleted: sha256:c0ad1e756f03cff7db1efc80db32345b54d60cd0ca47762b3d11256927c1ed88
Deleted: sha256:334dcc769f9e28821aa7faffa5025bcda23f96cda3dfad4b7a4c40f0616d115f
However this takes away a lot of redundancy, for example if you find a bug and want to investigate with which code change it was introduced.

What we can do instead is use Docker's tag system to keep a sensible number of previous image versions on disk, and differentiate between them using whatever indicator you see fit, e.g.

  • A simple numbered versioning system, tried and tested: [0.0.1, 0.0.2, 0.0.3].
    This has an additional advantage of using different positions to indicate major, minor and patch versions, e.g.
    • [0.1.1, 0.1.2] patch versions could indicate small changes to stamp out bugs.
    • [0.2.0, 0.3.0] minor versions can be used when the bug or issue is found and resolved.
    • [1.0.0, 2.0.0] major versions show when you have a fully tested version with significant differences to the previous major version.
  • Taking the Debian or Ubuntu approach and naming after certain things, with or without an alphabetical theme:
    • Debian uses Toy Story characters in a seemingly random order: [jessie, buster, bullseye].
    • Ubuntu uses an adjective-animal tag in alphabetical order: [Bionic Beaver, Cosmic Cuttlefish, Disco Dingo].
    • In a project with my stepson, he elected to use Pokemon in Pokedex order: [Gengar, Onix, Drowzee].
  • Or simply use a hash either generated randomly or ripped from your GitHub commit.
Using the first (admittedly most boring) option, when we make a change and need to generate a new image, we can add a tag to the repository name in Docker's build command:
$ docker build -t to_do_list:0.0.2 .
Sending build context to Docker daemon  140.4MB
Step 1/13 : FROM ruby:2.7.0
 ---> ea1d77821a3c
Step 2/13 : RUN apt-get update -qq     && apt-get install -y npm postgresql-client
 ---> Using cache
 ---> 1726273dbbe4
Step 3/13 : RUN npm install --global yarn n
 ---> Using cache
 ---> 02063daba50f
Step 4/13 : RUN n 16.4.0
 ---> Using cache
...
Here we can see the other fantastic thing that Docker does - it caches each step so that, if we are rebuilding the same parts each time, it won't waste time, resource or bandwidth reinstalling packages!
When this has finished, we can check our list of images and see our new version!
$ docker image ls
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
to_do_list       0.0.2     2aca8d3bab54   7 seconds ago    1.53GB
to_do_list       latest    f0c2ad6a602a   38 minutes ago   1.53GB
ruby             3.3       dddf6b6befbf   2 weeks ago     989MB
You might notice that Docker hasn't moved the latest tag to our new version - this is deliberately so, as Docker doesn't know if you want this to be your latest stable version yet!

If we're happy with our new version, we can apply this tag manually with:

$ docker image tag to_do_list:0.0.2 to_do_list:latest
$ docker image ls
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
to_do_list       0.0.2     2aca8d3bab54   1 minute ago     1.53GB
to_do_list       latest    2aca8d3bab54   1 minute ago     1.53GB
<none>           <none>    f0c2ad6a602a   39 minutes ago   1.53GB
ruby             3.3       dddf6b6befbf   2 weeks ago     989MB
Of course, this has had the negative effect of removing all of the information from our original image, because we didn't tag it with a custom tag and have just taken its latest tag away.
We can fix this in a similar fashion by pointing the image's ID to the tag we want to give it:
$ docker image tag f0c2ad6a602a to_do_list:0.0.1
$ docker image list
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
to_do_list       0.0.2     2aca8d3bab54   2 minutes ago    1.53GB
to_do_list       latest    2aca8d3bab54   2 minutes ago    1.53GB
to_do_list       0.0.1     f0c2ad6a602a   40 minutes ago   1.53GB
ruby             3.3       dddf6b6befbf   2 weeks ago     989MB
This still leaves one complication, however. The container.

Docker doesn't have quite the helpful tooling for containers as it does for images, and additionally doesn't allow multiple containers to share the same name. There are multiple approaches around this, such as container labels or simply adding the version to the name container name (i.e. to_do_list_web_0.0.2).
However, personally, I find removing names altogether (or rather, using the default ones) a perfect acceptable way of using Docker containers!

$ docker run -p 8008:3000 to_do_list:0.0.2
If no name is passed then Docker will just generate one for you, but ultimately it does not matter for me as, so long as your image tags are kept tidy, one can find the container one needs from the corresponding image used by the container:
$ docker ps -a
CONTAINER ID   IMAGE              COMMAND                  CREATED        STATUS                      PORTS                                       NAMES
990cf24f6ca9   to_do_list:0.0.2   "bundle exec rails s…"   1 minute ago   Exited (1) 20 seconds ago   0.0.0.0:8008->3000/tcp, :::8008->3000/tcp   tasty_bill
8d12ba454808   to_do_list:0.0.1   "bundle exec rails s…"   1 hour ago     Exited (1) 1 hour ago       0.0.0.0:8008->3000/tcp, :::8008->3000/tcp   to_do_list_web
All this means is that, when starting a stopped container, we just need to run the command above to check the name (though I tend to use the ID) of which container to start! Ultimately though, however you choose to manage your containers locally matters a lot less than how you choose to manage your images and, if you manage your images well, your containers will benefit.

 


 

In all, Docker is an extremely cool bit of kit. Celebration of this should require zero effort though, if you need some pointers, why not push what we've done up to GitHub?

$ git checkout -b dockerise
$ git add -A
$ git commit -m "Docker config for app"
$ git push origin dockerise
Before we create and merge a Pull Request, however, it is worth acknowledging that there are some drawbacks to Docker with regards to having to rebuild the image every time we change the code, which in turns wipes our development database.

Well, wouldn't you know it, Docker thought of that, too. The subsection linked below will show you how to use Docker Compose to alleviate both of these issues and provide an almost seamless development experience, combining all the benefits of Docker with all the convenience of working in your local filesystem!

 
 

Comments

Popular posts from this blog

New Rails Apps with Docker Compose

[ToDoList] Basic Pages

[ToDoList] Docker Compose