Docker basics: an introduction

In this article:
Subscribe to our blog:

Let’s go over some Docker basics — we’ll introduce you to one of the many technologies that makes up a developer’s toolkit.  Although it has been around for some time Docker is also one of the hottest dev tools right now.
However, Docker is still an impressive technology allowing developers, whether they’re working on Linux, macOS, or Windows — or a combination of all three — to rapidly create the environments (development, testing, staging, and production, etc) they need, all built exactly the same way.
So, if you’re not familiar with Docker and are just hearing about it for the first time, today, I’m going to take you from rote-beginner to someone comfortable using it, for your (and your team’s) daily development work.
We’re going to dockerize a small Zend Expressive (PHP) application, which uses Apache as its web server and Ubuntu as its operating system.
I’m using Zend Expressive, as that’s the software development framework I most like using, and the one that I write about most often.
However, if you want to use another technology stack or language, feel free to substitute it for Zend Expressive. I’m also using the Apache web server and Ubuntu, as they’re almost ubiquitous.

Getting Started

First, we need to install Docker. To do so, follow the instructions for your operating system or Linux distribution, available in the Docker documentation.

Create a Project Directory

When Docker is installed, you next need to create a new project directory and clone the application from my GitHub repository, using the following commands:

git clone git@github.com:settermjd/zend-expressive-3-skeleton.git path/to/your/project

You can either remove path/to/your/project or replace it with a path to where you want the project to be cloned.
After that, cd into the directory and run php composer install.

The Dockerfile Configuration

With Docker installed and the project directory initialized, now it’s time to create the configuration file. We’ll do this in a new file named Dockerfile.

Before we go any further, I need to draw your attention to two key terms: “image” and “container“. Sometimes, you’ll see these terms used interchangeably — they’re not the same thing!

An image is a template or design of a container. A container is what is run by Docker. If it helps, think of an image as a class and a container as an instance.

Now, let’s get back to the Dockerfile. The Dockerfile is the file the docker command references for the instructions on how to build your image. In the code sample below, you can see a rudimentary example.

FROM php:7.2-apache

WORKDIR /var/www/html
COPY ./ /var/www/html/
COPY ./docker/default.conf /etc/apache2/sites-enabled/000-default.conf
EXPOSE 80

Let’s start at the top. The FROM instruction specifies the container to use as the foundation for our container. Doing so might seem a bit strange at first, but, except for those you build from scratch, containers build upon other containers. It’s rather like object-oriented programming.

As our application needs PHP (7.2), Apache, and Debian, we want to build on a container that already has these available.
That’s why we’re using this image, which you can find on Docker Hub.

On Docker Hub, you can find containers for just about anything, including Redis cache servers (which we’ll make use of later), queueing servers, logging servers, and much more. I suggest that, unless you want to build a very custom container, this is the approach to take!

Looking at the container name, php:7.2-apache, on the left side of the colon, is the image’s name. On the right side is the version. The version, usually, gives a good indication as to the specifics.

After the RUN instruction, comes the WORKDIR instruction.
This instruction sets the working directory in the container, relative to which, any following RUN, CMD, ENTRYPOINT, COPY, and ADD instructions will execute.

Given that we’ve specified WORKDIR /var/www/html, the remaining instructions in our Dockerfile will start from /var/www/html. This makes sense, as that’s the root directory for Apache in this image.

Next, we use the COPY command to copy the contents of the current directory, including the application’s source code, into /var/www/html.

After that, we specify another COPY command, so we can replace the default virtual host configuration in the image, with our own, which is contained in ./docker/default.conf. It contains the following configuration.

<VirtualHost *:80>
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/html/public
    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

This configuration creates a virtual host that listens on port 80, updates the document root, and specifies where the error and access logs are written. You could be fancier if you wanted though.

Finally, we have the EXPOSE instruction. This instruction tells the container to expose that port to the container’s host. In effect, you’re making this port available to the outside world. If we didn’t do this, requests to our web server would not be possible.

Building the Initial Container

Now, we have to build an image using the configuration. To do that, we’re going to use our first command docker build, which you can see in the example below, to build the image and name it simple-docker-app.

docker build -t simple-docker-app .

When run, you should see output like the example below.

Sending build context to Docker daemon  14.49MB
7.2.6-fpm-alpine3.7: Pulling from library/php
ff3a5c916c92: Already exists
b9833b0afc68: Already exists
1253fa17a201: Already exists
01ce3cad229c: Already exists
fb7f31cba009: Downloading [==========================>                        ]   6.52MB/12.13MB
4e055319a621: Download complete
1cd57068fe96: Downloading [============>                                      ]  4.361MB/17.24MB
68091c8b915a: Download complete
d6bb142761f6: Download complete
fa5cad48a313: Download complete
db8438f78807: Download complete

Step 1/5 : FROM php:7.2-apache
 ---> d3e979c9935d
Step 2/5 : WORKDIR /var/www/html
 ---> Using cache
 ---> ecbd980edda5
Step 3/5 : COPY ./ /var/www/html/
 ---> ad9a46908a82
Step 4/5 : COPY ./docker/default.conf /etc/apache2/sites-enabled/000-default.conf
 ---> eb505e83b589
Step 5/5 : EXPOSE 80
 ---> Running in 65bf7f653957
Removing intermediate container 65bf7f653957
 ---> d0990d626362
Successfully built d0990d626362
Successfully tagged simple-docker-app:latest

The first half shows the images this image is built on, being downloaded and prepared for use. The second part shows the instructions in our Dockerfile being executed on top, to finish the creation of our image.

Verify the Built Container

Now the image has been built, like any proper development process, we need to verify it’s been created as expected.
To do that, we use the docker images command, as in the example below. This command lists the images in your local Docker repository.

docker images simple-docker-app

While Docker Hub is a remote, world-accessible Docker image repository, you also have a local one, accessible only to you, managed by the docker . It’s rather like working with Git or Mercurial, the distributed version control technologies.

You can have a local copy, which no one else sees. Alternatively, you can have a local copy and also publish to one or more remote repositories. Use this analogy, if it helps.

If you run the command above, you should see output similar to the example shown below. The output shows the application’s name, tag, image id, when it was created, and its size.

REPOSITORY          TAG                 IMAGE ID            CREATED              SIZE
simple-docker-app   latest              d0990d626362        About a minute ago   389MB

We’re not going to worry about image id in this series.
Instead, we’re going to focus on repository, tag, and size.

The repository should be self-explanatory. It’s the name that we provided to the docker build command. Tags are new though. Similar to using git tags, tags shows a particular version or release of our image.

Tags are handy for creating variations of an image for different purposes. Such purposes can include:

  • Different versions of a software language, such as PHP 7.0, 7.1, or 7.2.
  • Different web servers, such as NGINX or Apache; and
  • Different Linux distributions, such as Ubuntu, Debian, or Alpine Linux.

Finally, the size is 389mb. This size might seem small to you, given how large modern Linux distributions and software updates tend to be. However, it’s important to make it as small as possible.

Why? Well, if you’re building an image as part of your continuous development, integration, or deployment pipeline, the larger the image, the longer it’s going to take to build — and the longer it’s going to take to deploy; so ideally, the smaller, the better.

I’ve used Ubuntu in this example (on which Docker initially based its default image). However, there are other, far smaller Linux distributions available, such as Docker’s current default, Alpine Linux.

Run the Container

With the image built, we’re ready to use it; let’s do that by using docker run, as in the example below.

docker run -p 2000:80 --name simple-docker-app simple-docker-app

This command runs an image (or command) in a new container, effectively launching our containerized application. Breaking the command down, the container will:

  • Use our simple-docker-app image.
  • Assign it the name simple-docker-app, so it’s easy to use other docker commands later on. If we don’t specify a name, Docker auto-generates one — one we’ll have to look up and remember.
  • Bind port 2000 on the host machine (your development machine in this case) to the exposed port 80 on the container.

You should see output similar to this:

AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.2. Set the 'ServerName' instruction globally to suppress this message
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.2. Set the 'ServerName' instruction globally to suppress this message
[Fri Jun 15 08:47:36.409695 2018] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.25 (Debian) PHP/7.2.3 configured -- resuming normal operations
[Fri Jun 15 08:47:36.410448 2018] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'

Now that the container is up and running let’s connect to our application. To do this, open your browser to http://localhost:2000.

Unfortunately, if you open up your browser’s developer tools, you’ll see Apache has returned an HTTP/1.1 500 response code.
Why? That’s a good question. Let’s find out by inspecting the container’s logs.

View a Container’s Log Files

To view a container’s log files, likely the first step when debugging an issue in your container, you use the docker logs command; as I have in the example below.

docker logs --follow simple-docker-app

This command tails the log files, continuously, as I’ve passed the --follow switch. In the console, you should then see output similar to what’s shown below.

AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.2. Set the 'ServerName' instruction globally to suppress this message
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.2. Set the 'ServerName' instruction globally to suppress this message
[Fri Jun 15 08:57:33.007256 2018] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.25 (Debian) PHP/7.2.3 configured -- resuming normal operations
[Fri Jun 15 08:57:33.007460 2018] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'
[Fri Jun 15 08:57:35.302988 2018] [core:alert] [pid 16] [client 172.17.0.1:51186] /var/www/html/public/.htaccess: Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included in the server configuration
172.17.0.1 - - [15/Jun/2018:08:57:35 +0000] "GET / HTTP/1.1" 500 801 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:60.0) Gecko/20100101 Firefox/60.0"

In the second last line, you can see the cause of the error:

Invalid command ‘RewriteEngine’, perhaps misspelled or defined by a module not included in the server configuration.

It turns out; our simple configuration was too simple. We forgot to enable mod_rewrite. However, not enabling it isn’t a bad thing, as it lets us learn how to update and redeploy a Docker image.

Update and Rebuild the Docker Image

To enable Apache’s rewrite module, we need to add the command RUN a2enmod rewrite at the end of our Dockerfile. To rebuild the image, however, involves four steps, which you can see below.

docker stop simple-docker-app
docker rm simple-docker-app
docker build -t simple-docker-app .
docker run -p 2000:80 --name simple-docker-app simple-docker-app &

The first two commands stop and remove the container because we can’t (re)build an already existing container. The third command, as before, builds the image.

The fourth command, as we’ve previously seen, relaunches the container. With those four steps completed, open your browser again to http://localhost:2000, or reload it. It shows everything is working as expected.

View Container Statistics

Now that our application is running, what about some other diagnostic commands? There are plenty, but I’m going to touch on just two: stats and port. Stats displays a live stream of a container(s) resource usage statistics.

docker stats simple-docker-app

CONTAINER ID        NAME                CPU %               MEM USAGE / LIMIT     MEM %               NET I/O             BLOCK I/O           PIDS
61f2e4f2f5b8        simple-docker-app   0.01%               15.21MiB / 1.952GiB   0.76%               2.04kB / 2.98kB     29.8MB / 0B         7

In the example above, you can see the stats command and the resultant command output. It shows the container’s internal id and name, along with its CPU, memory, and network usage statistics.

View a Container’s Port Mappings

The port command lists a container’s port mappings or a specific mapping for the container. Displaying this information is handy for debugging which ports on a container are mapped to which ports on a host.

docker port simple-docker-app

80/tcp -> 0.0.0.0:2000

In the example above, you can see the container’s port 80 maps to the host’s port 2000, just as we would expect.

Tag the Image

At this stage, everything’s working as expected, so let’s tag our image. Doing so is handy if:

  • We want to share it on Docker Hub (or some other image repository)
  • We’re going to build one or more variations on it; or
  • If we’re going to use it locally with docker-compose, which we’ll see next

To tag it, we’ll use the docker tag command, as in the example below.

docker tag simple-docker-app simple-docker-app:0.0.1

In this example, we’ve provided it a name and a source image and tag. So in our local repository, it will be simple-docker-app:0.0.1.

Docker Compose

With our image created and tagged, we’re now ready to move on and learn how to use the image as part of a large configuration.
I didn’t explicitly say it up until now, but Docker containers should only contain one service or application.

They shouldn’t attempt to be another VM, and contain everything an application needs; such as a database, caching, or queueing server.

However, unless your application is very modest, it needs one or more external services, such as these three. Docker helps you containerize an application that may need external services through the docker-compose command and configuration file, which we’ll walk through shortly.

Docker Compose helps you, in effect, setting up a cluster of containers, rather like setting up a group of VMs or physical servers.

As a practical example, we’re going to build an initial Docker Compose configuration which makes use of a caching server (Memcached). The Memcached server will store a cached copy of the page output so our application’s performance improves.

The Docker Compose Configuration File

To do that, we need to create a new file, in the root of our project directory, called docker-compose.yml. You can see its configuration below.

version: '3'

services:
  web:
    image: settermjd/simple-docker-app:0.0.1
    deploy:
    ports:
      - "2000:80"

Stepping through, it starts off by specifying the docker-compose file format version. This is important, because we may be targeting a specific version of Docker Compose because that’s what our hosting provider supports. This version lets us know what features we can make use of, or not.

Next, we have the service definitions. These provide the details of the containers in the cluster and are analogous to the Dockerfile configurations. Let’s start with the web service (or container) definition below.

  web:
    image: settermjd/simple-docker-app:0.0.1
    deploy:
    ports:
      - "2000:80"

This one is named web, which will also be its hostname within the cluster. It uses the Docker image we created earlier and maps port 2000 on the host (or development machine) to port 80 on the container.

To have a running cluster (even if it only contains one container), this is all we need to do. However, it wouldn’t be much of a cluster without another service.

So we’ll add a Redis container, whose name and hostname will be redis, using the official Docker Redis image, by adding the following service configuration:

  redis:
    image: redis:alpine

Naturally, our web container needs to contain a version of the code that uses Redis to cache the page output. To do that, change the web image’s image setting to settermjd/simple-docker-app:0.0.2.

Start the Container Configuration

With the configuration file ready, it is time to start the container cluster. To do that, we’ll use the first Docker Compose command: docker-compose, which you can see in the example below.

docker-compose up -d

Starting code_web_1 ... done
Starting code_redis_1 ... done

The up command builds, (re)creates, starts, and attaches to containers for a service. The -d switch detaches the containers, running them in the background.

If you don’t have one or other image available, then, as with docker build, you’ll likely see output similar to the below. There, as before, the images are being downloaded in preparation for use.

docker-compose up -d

Pulling redis (redis:alpine)...
alpine: Pulling from library/redis
ff3a5c916c92: Already exists
aae70a2e6027: Pull complete
87c655da471c: Pull complete
fc6318d8b86d: Pull complete
0ce2c55a2dbe: Pull complete
c0f508e83020: Pull complete
Digest: sha256:8a2bec06c9f2aa9105b45f04792729452fc582cf90d8540a14543687e0a63ea0
Status: Downloaded newer image for redis:alpine
Creating code_redis_1 ... done
Recreating code_web_1 ... done

We could assume, from the above output, that our container cluster is ready to go. However, as a university lecturer of mine once said: “to assume makes an ass out of you and me“.

So let’s not assume, and instead learn another command docker-compose ps, which, like its docker counterpart lists the available containers and their states.

docker-compose ps

   Name                 Command               State          Ports
--------------------------------------------------------------------------
code_redis_1   docker-entrypoint.sh redis ...   Up      6379/tcp
code_web_1     docker-php-entrypoint apac ...   Up      0.0.0.0:2000->80/tcp

In the example above, you can see both containers are running, and the available ports. If one or both showed Exit under the State column, then we’d have to debug them, but neither do.

And that’s how to set up a local Docker cluster, one which emulates a remote setup for your application. However, before we go, let’s look at some other Docker Compose commands, so we can learn more about the cluster, in case we need to.

Validate a Docker Compose Config File

First off, you want to be sure your docker-compose.yml file doesn’t contain any syntax errors. To do that, you need the docker-compose config command. This command lets you validate and view the Compose file.

docker-compose config

ERROR: The Compose file './docker-compose.yml' is invalid because:
Unsupported config option for services.web: 'syctls'```

As in the example above, where I deliberately inserted an error, you can see it’s found and printed out to the console.
You can also print out the registered services by using the --services switch if you’re interested in quickly seeing what’s registered.

View Docker Compose Log Files

As with docker run, it’s important to be able to view a Docker Compose cluster’s log files, so you can see if anything is going wrong.

To do that, you can use the docker-compose logs command, as in the following example, where we’re tailing the log files for all services in the configuration.

docker-compose logs --follow
Attaching to code_web_1, code_redis_1
redis_1  | 1:C 22 Jun 12:44:47.087 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
redis_1  | 1:C 22 Jun 12:44:47.088 # Redis version=4.0.9, bits=64, commit=00000000, modified=0, pid=1, just started
redis_1  | 1:C 22 Jun 12:44:47.088 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
redis_1  | 1:M 22 Jun 12:44:47.089 * Running mode=standalone, port=6379.
redis_1  | 1:M 22 Jun 12:44:47.089 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
redis_1  | 1:M 22 Jun 12:44:47.089 # Server initialized
redis_1  | 1:M 22 Jun 12:44:47.089 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
redis_1  | 1:M 22 Jun 12:44:47.089 * DB loaded from disk: 0.000 seconds
redis_1  | 1:M 22 Jun 12:44:47.089 * Ready to accept connections
web_1    | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.26.0.2. Set the 'ServerName' directive globally to suppress this message
web_1    | [Fri Jun 22 12:44:49.764933 2018] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.25 (Debian) PHP/7.2.3 configured -- resuming normal operations
web_1    | [Fri Jun 22 12:44:49.765411 2018] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'
web_1    | 172.26.0.1 - - [22/Jun/2018:12:44:56 +0000] "GET / HTTP/1.1" 200 2595 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:60.0) Gecko/20100101 Firefox/60.0"
web_1    | 172.26.0.1 - - [22/Jun/2018:12:46:49 +0000] "GET / HTTP/1.1" 200 228 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:60.0) Gecko/20100101 Firefox/60.0"

As you can see, the container is listed in the first column, and the standard log file entries are printed out, after the pipe, on the remainder of the line.

Access a Running Container

If you need to access a running container, perhaps because something’s not working as you expected, and the log files don’t quite tell you enough, you can use the docker-compose exec command, as shown in the example below.

docker-compose exec -u www-data web /bin/bash

In this example, we’re executing /bin/bash, as the user www-data, in the web container. In effect, this lets us ssh into that container and then run any command we need, that’s available to be run, just like we were ssh’ing into any other server, anywhere on the planet.

What you have to remember, at least in this case, is that the user and shell, have to exist in the container; otherwise, the exec command fails.

That’s a Wrap

OK, this has been a rapid introduction to Docker and how to build Docker images and container configurations, using both docker and docker-compose. It’s been a bit of a long read, and there’s so much not covered.

However, despite that, you’ve had a solid introduction to the essentials you need to know to get you on your way to using Docker on a day-to-day basis, as well as to become an expert.

After learning these Docker basics, I encourage you to experiment with the command and configurations, in addition to diving deep into the Docker documentation (which you can find further links to below).
If there’s anything you’d like to know, feel free to tweet me, anytime, I’m @settermjd.

Alternatively, join the Docker Community Slack channel (dockercommunity@slack.com), check out the docker community on the web, as well as to participate in the Docker forums.


Codacy is used by thousands of developers to analyze billions of lines of code every day!

Getting started is easy – and free! Just use your  GitHub, Bitbucket or Google account to sign up.

GET STARTED

RELATED
BLOG POSTS

Five Ways to Slim Docker Images
Docker is a pretty awesome tool, I hope you’ll agree. With it, we can create environments for development, staging, production, and testing — or...
Docker Tutorial: Dockerizing Scala
Prerequisites A working docker installation (boot2docker is fine)
How to use GraalVM to make Scala applications faster
A small journey on how to make your Scala applications faster and slimmer taking advantage of GraalVM native-image.

Automate code
reviews on your commits and pull request

Group 13