We can't deny how popular Docker containers became on the daily basis of many software developers around the world. Moreover, it is a mainstream technology behind cloud-native applications such as Kubernetes and it's used as the deployment engine at various production systems.
However, its learning curve is not so friendly for many, considering that the fundamentals of Linux containers is not widely taught. On this post I will try to briefly explain the building block for containers and how to use the Docker container runtime to boost productivity.
To understand a container, we should understand what is virtualization at first.
Virtualization is a solution to the problem of isolating and having a completely different environment sharing the same "machine". Each virtualized environment would have its own memory, disk and CPU quotas. For instance:
- Machine RAM: 16GB
- Windows 10 quota: 4GB
- Ubuntu 18.04 quota: 4GB
However, virtualization has a cost: it does not share the same OS kernel. In case we want to have a small virtualized web-application in our machine, such app will bootstrap a different kernel and load a lot of runtime libraries.
It's useful for those who want a fully virtualized operating system but not ideal for general-purpose applications. That's where containers come in.
Containers are the solution of sharing the same physical resources, the same kernel space, but still isolated.
In short: a container is an isolated space in our computer where we can run a different OS, and from that OS we are able to install whatever we want and run any command without affecting our local machine, the host.
For me, that's exactly the fascinating part of using containers. Following are some benefits I can highlight.
Since all the needed dependencies belong to the containers, we will no longer face installation problems in our computer, library conflicts, version upgrades and so on.
All the messy stuff that sometimes, prevent us from doing more experimentation. Gone.
Sometimes, many of us work on a variety of projects. Switching contexts may be cumbersome. One project uses the PostgreSQL 9.5 while other uses PostgreSQL 11. Managing two Postgres instances in your local environment? Good luck with that.
By using containers, context switch is easily as doing:
$ docker rm -f postgres_9 $ docker run --name postgres_11 postgres:11
Containers allow to run a different operating system. What about if we choose to run the same production environment in our computer?
It takes us to a different position where we have full control on the application. That's WAY easier to debug and replicate bugs since we are using the same OS and libraries.
No more "it works on my machine" or "it DOES NOT work on my machine".
The steps to deploy are exactly the same be it on our computer or in the production environment.
$ docker run --name my_application my_application
Furthermore, the container does not care about the environment it's running in. We can EASILY push our container to another server and run it from there. Almost zero configuration needed.
Want to try a cheaper cloud-provider? Cool. Your container WILL run there effortlessly.
In order to run containers, we need to install and configure a container runtime. For the purpose of this article, and due to its popularity, we will use Docker.
So let's suppose we have a new computer with just the Operating System installed. And a browser, of course :)
Assuming that you have Docker properly configured, we want to run
bundle install in some application we have but remember, we don't have Ruby installed in our computer.
- we don't want to install Ruby neither its dependencies
- we just wanna run
bundle installinside the container and that's all
First things first:
$ docker run ruby:2.7
run command takes a pre-defined container image somewhere and starts a container following the rules described from that very image. Where did the
ruby:2.7 image come from? Docker Hub. Think of it like a
Github for Docker images.
The above command did nothing but showing a message
Switch to inspect mode.. That's because the default command pre-defined in the image
ruby:2.7 is the
irb, which means the container will try to allocate a pseudo-TTY so we can interact on that tty, or "terminal".
For doing so, we should use a flag option in the
$ docker run -it ruby:2.7 ### Options -t Allocate a pseudo-TTY -i Keep STDIN open even if not attached
Now, we have a full Ruby 2.7 installation ready to receive instructions, right on our computer with no host installation required!
That's a big YAY, in my point of view :D
We can run arbitrary commands other than the default:
$ docker run ruby:2.7 ls
- creates the container using the image
- runs the
lscommand inside the container
- exits the container
Knowing that we can run any command, we can run the
bash command, which will take us right into the container:
$ docker run -it ruby:2.7 bash # Remember that the bash, as the same as irb, will # allocate a pseudo-TTY
What if we try to run
bundle inside the container?
$ Could not locale Gemfile
Here's why: containers are isolated. Think of separate "computers" (containers) inside our computer (host).
How about "copying" our files from host to container? We can use Volumes.
$ docker run \ -it \ -v $(pwd):/app \ ruby:2.7 \ bash ## Options -v source:target, where the source is our current dir on host (pwd), and target a folder we will cal "app" in the container
Now, inside the container, we can run
bundle install. If you are using a common Ruby application with Gemfile, it will start installing the gems using bundler!
The bundler config and installed gems are located in the standard directory,
Try to exit the container and entering it again. Run
bundle install. What will happen?
It will install the gems all over again. Boring. That's because containers are ephemeral and do not persist state. If we want to keep the state of the container, we should send back anything we want to the host. Using what?
$ docker run \ -it \ -v $(pwd):/app \ -v bundler_gems:/usr/local/bundler \ ruby:2.7 \ bash
This time, the volume is a bit different, because it's not using the
relative path, but using a
named volume instead. Docker has this feature where we can create named volumes in the host, as we don't care where this volume is persisted. Somewhere in our computer, for sure. But we don't care.
We are just saying:
I want to associate anything "inside /usr/local/bundler" FROM the container TO a volume named "bundler_gems" ON the host
bundle install will take time, because the volume is still empty. But from the second time and on, everytime we run a new container, Docker will associate the named volume to the
/usr/loca/bundler inside the container and vice-versa.
That's the nature of volumes: two-way binding. You change from host, it will affect the container. If you change from the container, it will affect the host.
This article was an introduction on containers and, being more strict, on how to think like a container.
I think the ride to containers is worth. A bit "fuzzy" at start but it will pay back as soon as we feel we can experiment more on computers.
And in case you want to keep learning it, check out my series of posts about containers and Docker.