An Introduction to Docker: Answering the What, Why, and How

Dozens of multi-colored shipping containers are stacked in clusters 3-5 high, atop a flat off-white surface. The sky is partly cloudy. Photo by frank mckenna on Unsplash.

Sunny Patel
Sunny Patel

February 21, 2023

If you're a recent graduate or career changer who just landed their first gig as a software engineer, you've probably heard your colleagues talk about Docker.


And why wouldn't they be talking about it? It's been one of the most popular DevOps technologies in the industry, so it's good to have a basic understanding of what it is, why you would use it, and how to use it.

In this article, I will answer the what, why, and how of Docker. But first, I’ll provide a brief overview of DevOps to provide context for the problems Docker solves. With this context in mind, I will finish with instructions on how you can get started working with Docker today.

What is DevOps?

DevOps describes the cultural practices formed when teams began addressing the challenges of development and operations teams being siloed. Both of these teams play an integral part in the life cycle of an application. However, when they're kept apart and communication breaks down, the entire system can experience inefficiencies and delays throughout the software delivery cycle.

Before the age of cloud computing, it made sense to keep these two teams separated, since the former focused on software and the latter focused on hardware setup for deployment. A lot of the work the operations team did was manual — from setting up the servers, to running the commands to installing the application and its dependencies.

As an application scales in size, the operations team's work becomes more difficult. Because deployments were done manually, they become slower, more unpredictable, and error-prone as the number of servers increases. The result? Less frequent deployments. So while operations teams dealt with issues around the management of resources, development teams struggled with poor workflow processes. For example, on the day of the big release, software teams tried to merge their changes only to face merge conflicts, or they tried deploying a large changeset and failed. As you can surmise, this led to unexpected delays in delivery.

DevOps aims to address these tensions through improved workflow processes, automation, and techniques. The overall result is a shortened software development life cycle and continuous software delivery.

The union of Dev and Ops began alongside the rise of cloud computing. Leveraging the services offered by Amazon Web Services (AWS), Google Cloud, and Microsoft Azure, companies no longer have to manage their own data centers! Since operations teams no longer needed to manage physical hardware, they began using software tools like Ansible, Terraform, Docker, or Kubernetes to manage their services' infrastructure. Teams began to treat their infrastructure as code, and the same principles that have led to quality software applications are now helping to build quality infrastructure as well.

If my explanation of DevOps has interested you in becoming a DevOps engineer, read the article by Seth Thomas, What to Know When Becoming a DevOps Engineer.

What Is Docker and What Problems Does It Solve?

With the rise of cloud computing, members of an operations team found themselves using software to manage the infrastructure of their services. One of the most popular infrastructure tools, Docker is a platform that enables developers to test, build, ship, and run their applications quickly and reliably.

To understand how Docker makes that possible, let’s start with how it used to be done. Developers, having done their job, are ready to ship to production. They would build the application, and pass the executable code over to the operations team to deploy. The operations team managed the hosting environment, including the runtimes, libraries, and OS needed by the application.

Managing dependencies can become time-consuming, especially when the operations team is handling the hosting environment across multiple machines. Now imagine different applications that share dependencies being hosted on a single machine. Can you think of problems that can pop up when updating a dependency for a particular application? How would changing the shared dependency for one application affect the other?

Docker makes managing dependencies and deployments much easier. Docker allows developers to package up their application and its dependencies into containers that can be run quickly and reliably from one computing environment to another. In other words, Docker makes your application portable, so you can quickly set it up in any environment and run it in no time.

With Docker, developers don't have to worry about managing dependencies, because the application's dependencies are packaged into a container that runs in an isolated environment on the host machine. In an isolated environment, the application has its dependencies, which do not conflict with others on the host machine. If it helps, you can think of containers as isolated virtual machines that run your application.

As for making deployment easier, deploying is as simple as running a new container, routing users to the new one, and deleting the old one. What's even better is that this process can be automated using orchestration tools.

Why Use Docker?

Understanding the history might have already shown some advantages of using Docker. In this section, I’ll highlight the benefits of using Docker and provide a deeper understanding of the problems it solves.

Manage Dependency Conflicts

One of the benefits of using Docker is that it solves dependency conflicts, which would become likely when updating a shared dependency on a host that serves multiple applications. To elaborate, say you have two applications built using Craft CMS version 3, PHP 7.2, MySQL 5.5, and Apache running on a host. The first application is app1 and the second app2. Both applications share the dependencies mentioned above.

Suppose you upgrade the PHP version from 7.2 to 8.0 for app1. However, doing so results in breaking changes in the applications. Before proceeding with the upgrade, you need to address the errors for both applications, which might lead you to update app2 as well. That wasn't the original intention. What if you had three or more applications on this host? Sadly, the upgrade would be delayed — or worse, never done. Then introduce a third application. However, it uses a package that, when installed, updates a dependency used by Craft CMS 3. And there you have it, a dependency conflict!

Docker solves these problems by encapsulating each application and its dependencies within containers. Each container runs in an isolated environment; therefore, any container using that dependency-conflicting package would not affect any containers running the Craft CMS applications. Also, updating the PHP version from 7.2 to 8.0 for app1 becomes easier. Each container will have its own version of PHP.

Scale

Another benefit of Docker is how easy it is to scale up an application to meet the growing demand for your service.

Without Docker, applications can scale by placing a proxy before duplicated services. However, considering the scenarios already described, upgrading dependencies will require updates for each server.

With Docker, containers make life easier again. Containers are created from images. You can think of images as blueprints or templates for containers. Images describe everything needed to create containers. Single images can also be used to create multiple containers with the same dependencies. Now apply that concept to the scaling strategy with a proxy: All that’s needed is to build the image, and create and run a container on each server.

How to Use Docker

Now that I've gone over what Docker is and why you would use it, I'll dive into how to use Docker. I’ll start by creating a Rails application. Before installing Rails on your local machine, have Ruby and SQLite3 installed. For this example, I'm using rails 3.0.2 and sqlite3 3.37.0.


1. Install Rails:


gem install rails
# Check the version to confirm the installation
rails --version


    

2. Create a new Rails application and verify:


rails new rails_docker_app
cd /path/to/rails_docker_app

# this will start up the Puma app server and will verify our app was created successfully

rails server


    

3. Create a Dockerfile at the root of the directory:


touch Dockerfile


    

4. Open up the Dockerfile and add the following:


FROM ruby:3.0.2
# Rails 7.0 no longer requires NodeJs. Omit the line below if using Rails 7.0
RUN apt-get update && apt-get install -y nodejs
WORKDIR /app
COPY Gemfile* .
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]


    

I’ll review the instructions specified in the Dockerfile in more detail.

  • FROM: instruction specifies the parent or base image that will be used as a starting point. This is a very powerful feature Docker provides. Building one image on top of another greatly reduces complexity. Since I'm using ruby 3.0.2, I’ll use a Ruby image with the same version. The Ruby image comes from a Docker registry. Just like how you can publish your code to Github, you can publish your image to a Docker registry. If Docker cannot find the Ruby image on your disk, it will pull it from Docker Hub.
  • RUN: instruction executes any commands passed to it in a new layer on top of the current image and commits it. The RUN instruction executes during the image build process, and the committed image will be used for the next steps in the Dockerfile.
  • WORKDIR: instruction creates a working directory, where subsequent instructions — such as RUN, CMD, ENTRYPOINT, COPY, and ADD — will be followed in. Declaring a WORKDIR; instruction is optional but considered good practice. If one is not defined, Docker sets the working directory as /, or the root.
  • COPY: instruction is used to copy files from the host to the Docker image. The syntax is COPY<src><dest>, where src is the source of the file(s) and dest is the destination path in the image. The period (.) refers to the current directory.
  • EXPOSE: instruction informs Docker which port the application will be listening on when a container is running. This serves as documentation between the creator of the image and the user. It informs the user that when you're creating and running a container to open the specified port.
  • CMD: instruction executes the listed commands when the container runs. This instruction serves as an entry point into the Docker container. As a note, only one CMD; instruction can be in a Dockerfile. If you list more than one, only the last CMD instruction will be followed. The command executed in the code above is rails server -b 0.0.0.0. You might be wondering why I'm binding to 0.0.0.0. By default, Rails binds to localhost or 127.0.0.1. This IP address is accessible to processes running on the same machine. However, Docker containers are virtually different machines; only processes running on the container can access this address. By binding to 0.0.0.0, we bind to all the IP addresses on the container, including localhost. In other words, we'll be able to reach the container through localhost on our machines.

5. Build the image:

      
      docker build -t railsdockerapp .
      
      
          

      The command above builds an image using the Dockerfile and a build context. The build context is the path Docker will use to get the files needed to build the image. The command above references the current directory using a period (.). The -t flag lets us tag the image in the name:tag format. This way, we can reference the image by a name rather than by its ID.

      6. Create and run a container

      
      docker run -p 3000:3000 railsdockerapp
      
      
          

      In addition to creating and running a container, we publish the application to the outside world by mapping the host's port 3000 to the container's port 3000 using the -p flag.

      Now fire up your favorite web browser and send a request to localhost:3000. You should see the default Rails homepage!

      Conclusion

      After reading this article, I hope you understand what Docker is, why you would want to use it, and how you can use it. As well as how it supports the mission of DevOps, which I summarize as making software delivery more efficient. If you want to learn more about Docker, reference the documentation.

      If you’re interested in going beyond the basics, I would suggest learning about Docker Compose and Kubernetes. Docker Compose is a tool used to run multi-container applications. For example, say you want to set up an application and database — Docker Compose can help you with that.

      Kubernetes is one of the orchestration tools that can help automate tasks. It allows developers to to automate containerized applications' deployment, scaling, and management.

      Finally, Educative’s Docker for Developers by Arnaud Weil is a valuable resource that helped me develop my own understanding of the powerful tool.