What in the world is Containerisation?
This is truly a great question, and one which I avoided learning the answer to for longer than I care to admit. It’s one of those that, as new developers, we are aware of its existence but don’t find ourselves actively thinking ‘oh boy, I really need a container right now’.
Do I need containerisation? I will not be contained!
That’s fair but don’t worry, we’re not containing you, we are containing your code.
Does my code need containerisation? What’s wrong with my code?
There’s a good chance nothing’s wrong with your code — when you run it. As you’ve been creating your glorious Whale Translator app in your local environment, you’ve faced some issues, some questions, but you’ve resolved them and this app is looking great, nice one! And therein lies the potential issue. You push it all up to GitHub, send a message to your mentor, friend, frenemy and ask them to clone it down and take a look. And guess what, it ‘mysteriously’ doesn’t work on their machine.
Wouldn’t it be awesome if you could have sent over the whole environment that your app felt at home in— OS, config, dependencies and all the other good stuff — in some sort of *ahem* container?
That sounds interesting, how can I try this?
There are several offerings for containerisation. I’ll use Docker in the example today but other options include Mesos and Rkt. Docker has the advantage of having a super QT logo.
Get started with Docker
If you’ve not already done so, sign up for Docker, and follow the instructions to download and install the Docker Engine. Mac and Windows users will be downloading Docker Desktop and Linux users can find their appropriate package here. Once you’re done with installation, open up a terminal and run docker run hello-world
to see it in action! You can find easy to follow install guides for Windows and Linux on the Docker site.
For a friendly intro to the basic flow, the onboarding steps are a great way to get acquainted and see some sweet ASCII whales.
I’ve decided that today I’m going to containerise a small Ruby app. I have a soft spot for Ruby CLI applications as it was the format of My First App and for the majority of my students, it is their first too. If you would like to follow along and don’t have anything already written, feel free to use this repo as a starting point.
Is there really a problem here?
I’ve planted a couple of potential issues in the demo.
1. I’ve done my dues and made a Gemfile but ‘forgot’ to add a couple of gems that I had pre-installed. And even worse, not mentioned them in the documentation.
You can test this as an issue by just cloning it down and running it with bin/hello-docker
— unless you have the dependencies already installed you’re going to get a require error of some kind. ‘Right, better run a bundle install
I guess’, say you (and your user might not know that so there’s another problem). We try again and we get struck by another require error — hmm, a dependency that was not in the Gemfile. Manually you go through adding gems until you gain access. You get there but it was a pain.
2. I’ve used the Array#append method which was introduced in Ruby 2.5. I wrote this code in a Ruby 2.6.1 environment but nowhere have I mentioned that.
You can test this as an issue by trying to run this in an early Ruby version eg 2.4.1 (use rvm to bounce between versions, it’s awesome!). Once you’ve sorted out the gem dependencies (to get there quick, use gem install tty-prompt word_wrap
), running the app should now get you in but when you try and see all the famous quotes, there comes another error — undefined method 'append' for []:Array (NoMethodError)
These are by no means insurmountable issues but they would be much better avoided. Let’s wrap everything up together so we can deliver a complete, ready-to-go, package.
It’s that time — get a tea top up and get ready to do this thing.
~Interlude~ Image vs Container
Both of these words are going to come up a lot so let’s clarify what each is. Whilst containers get most of the media time, images are the real smarties here. In the steps below we are going to make a Dockerfile which holds instructions on how to build an image. Once built, we cannot change that image, only run it or delete it. When you run it, a container is created from which you can interact with the image. Go ham, try anything you want, but remember, nothing that you do will be reflected in the original image. Each container is an isolated instance of the image. In fact, you can think of image vs container rather like a class vs instance in OOP.
If you do want to make a ‘change’ to an image, you’ll need to build a new one. You can have multiple images on your Docker engine so you can either keep or delete the other one.
So what is actually shared in our scenario of solving ‘it doesn’t work for me!’ is the Docker image — when someone decides to run that image, that is when the container is created.
Alright, let’s do this
First we’ll need a Dockerfile
The Dockerfile is where our little package will learn its lot in life. Create one at the top level of your app withtouch Dockerfile
. In it, three basic things need to be addressed:
- Base Image (the core ‘identity’)
- we will want an image running Ruby 2.6.1
- vist the Docker Hub to search for something appropriate
-FROM <base-image>
- Setup Instructions (what ‘training’ your are providing)
- we declare what is needed
- copy over the files needed
-COPY <origin location> <destination>
- run any commands to setup environment
-RUN <any necessary commands>
- Execution Instructions (what ‘job’ it will do)
- we give the command(s) we want to be run when we start a container
-ENTRYPOINT <your-entry-point-if-executable>
-CMD <any-execution-arguments>
FROM ruby:2.6-alpine3.11
— I chose the base image of ruby:2.6-alpine3.11
for this as Alpine has a reputation for being extremely lightweight, but I did want to get something which had Ruby 2.6 pre-installed. You could start with just Alpine (or any distro) and install Ruby on it yourself , there’s a good chance you’ll save some space that way too, but today let’s keep it ASAP — as simple as possible!
COPY . ./
— This line will take everything that’s in the current directory (when I run the build command) and copy it into the current directory of its new home. A nice option here is to add WORKDIR /<name-me>
so your code has its own folder. Not essential but certainly a good way to keep things neat. When you use WORKDIR
, if the folder you name does not exist it will run mkdir
and cd
for you so all your subsequent commands will be made relative to that location.
RUN gem update --system && gem install bundler && bundle install
— These are the setup commands I’ll need to make sure the environment is ready to run my code. The first two here are in so I can make sure that Bundler 2.0 is installed. Have a look at the Bundler 2 announcement to find out exactly why. The final one, bundle install
is going to look in my Gemfile, go out and get what I’ve asked for and create a Gemfile.lock. This step should feel very familiar if you’ve made any Ruby app ever!
ENTRYPOINT bin/hello-docker
— Finally we announce any commands that we wish to run when we use the docker run
command to start up a new container. In our case both ENTRYPOINT
and CMD
would do the job but ENTRYPOINT
is the most appropriate as we intend this image to be run as an executable — ie. we intend that our hello-docker app will run immediately on starting a container and the user won’t be able to change that fact. The big difference between the two is that a CMD
can be overwritten at runtime. We will have a quick look into that further below.
As soon we exit the app, the container will also stop running as its designated process will be complete. Alternatively we could have it just start a shell and have our user load the file themselves but that seems uneccesary here.
Putting it all together, my resulting Dockerfile looks like this:
FROM ruby:2.6-alpine3.11
WORKDIR /hello-docker-app
COPY . ./
RUN gem update — system && gem install bundler && bundle install
ENTRYPOINT bin/hello-docker
Let’s build the image
docker build <options> <Dockerfile-location>
In my case I'm going to add an option with the-t
flag which lets us tag (name) our image with a custom label. My build command is going to look like: docker build -t hello-docker-image .
where hello-docker-image is what I wish to call the image and the Dockerfile is in my current working directory.
On running your build command you will see ouput reflecting the build process as well as any output from your setup commands that you declared in your dockerfile.
To see all your local images, run docker images
— there should be your new image. If you did want to delete an image, you can use docker rmi <image-id>
.
Time to run an actual container!
docker run <options> <image>
So our run command is going to be: docker run -it hello-docker-image
. We are especially interested in adding the -i
and-t
flags in our case as without both of them, our user interaction via the standard i/o would be jeopardized. As this app needs our user to be able to interact via the command line then you’ll definately want to include them. Try it without, go on, I’ll wait here.
Once you’re running a container, before exiting the app (which will also stop the container in our case) open up a new terminal and run docker ps
which will give you back a list of all currently running containers. There it is! If we stop a container and run docker ps
again, it will have gone. To see all containers, running or not, add the -a
flag to your docker ps
. The NAMES
column in the output shows the name of the container. If you would like to choose the name for your container you can set this when starting it up: docker run -it --name doris hello-docker-image
will get you a container called Doris. If you don’t specify one, a name will be automatically generated.. If you want to stop a container manually, you can run docker stop <container-name>
.
To get rid of a container completely run docker rm <container-name>
or for a complete cleanse of all unused items, docker system prune
does an excellent job!
~Interlude~ CMD vs ENTRYPOINT
As mentioned above, a quick demo of when we would use CMD instead of ENTRYPOINT would be if we had something else we wanted to run. touch something-else.rb
in your project folder, add some code to it (puts “Now for something completely different”
?) and change the Dockerfile to use CMD bin/hello-docker
instead of ENTRYPOINT
. Build again with docker build -t hello-cmd .
and then run a container as before: docker run -it hello-cmd
— the app runs as expected, it used our CMD
by default. But now run it with docker run -it hello-cmd ruby something-else.rb
And we get the output from our new file. In fact, since we don’t need any interaction when running that file, we can trim it to docker run hello-cmd ruby something-else.rb
. That ruby something-else.rb
command overwrote the CMD
default. We can’t do that with ENTRYPOINT
(technically you can but only if you really mean to — it’s unlikely to happen by accident) so do think about which is most appropriate for your use case. You can combine then also — if you are making an executable which takes arguments you can use ENTRYPOINT
to specify, well, the entrypoint, and CMD
to give a default argument which can be overwritten by the user at runtime.
Share it!
Let’s share our great work!
- Namespace your image
-tag <image-name> <your-docker-username>/<image-name>
- if you rundocker images
now, you’ll see the image id listed twice — once with it’s original name and again with the new tag - Log in (or skip if you’re already in)
-docker login
- Push
-docker push <your-docker-username>/<image-name>
- check it out on Docker Hub
That’s it! Now you can share this with anyone — it’s up to them to have Docker installed on their host machine but other than that, all they have to do to run our sweet whale translation app is:
docker run -it gingertonic/hello-docker-image
That will check to see if an image of that name is available locally, pull it down from Docker Hub if not, and start a container. If they don’t want to run it straight away they can use docker pull gingertonic/hello-docker-image
so they have the image locally for later. Thanks to the fact that the image has ALL the info required to successfully run the app, we have no concerns that our user will run into any dependency issues or other mysterious concerns!
docker run -it gingertonic/hello-docker-image
It just works!
EDIT: I just tried doing this without Docker on my partner’s MPB which is not setup for any Ruby dev at all and here are all the steps I had to take:git clone git@github.com:Gingertonic/hello-docker.git
cd hello-docker
curl -sSL https://get.rvm.io | bash
rvm install 2.6.1
gem update --system
gem install bundler
bundle install
gem install tty_prompt word_wrap
and finally… bin/whale-talk
So do you prefer that or this?
docker run -it gingertonic/hello-docker-image
PILLS — Potentially Interesting Linky-Links
Docker
Docker Docs
Repo for this walkthrough
A great video giving a fun rundown of the Whys and Hows of Docker basics