Getting started with Docker
We have created a GitHub repository to share code examples for this book. The repository can be found at https://github.com/PacktPublishing/Docker-for-Developers. You should fork this repository, and then clone it to your host. Creating the fork means you can manage your copy of the repository as you see fit without requiring permissions. The code of interest for this section is in the chapter2/ directory. The code here implements a small Apache+PHP application that is designed to run in a container. There are sh scripts to perform the Docker command lines, so you don't have to keep typing in a long string of command-line arguments.
Before we get into the code, let's make sure that Docker is installed properly. The docker ps command prints a list of all running Docker containers. We can see we have no containers running and there is an actual docker command:
% docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
%
A Dockerfile is a text file that defines how to build a Docker container image. The container is not started; it is just created on disk. Once built, you can start as many instances as you wish.
Automating Docker commands via sh scripts
We're going to make heavy use of the docker cli command and sh scripts to automate command-line use. The use of sh script files has a few advantages. Once the script file is created, you don't have to remember what all the command-line switches to the command are. Once the script is correct, you won't have any issues due to typos or improper command-line switches. Typing the script filename is much shorter and your shell should autocomplete it when you type the first few characters of the name and hit the Tab key. Finally, the names of the scripts are mnemonic: build.sh means build the container, run.sh means run the container, and so on.
The sh scripts we provide are as follows:
- ./build.sh: This builds the container from the Dockerfile. You will want to run this script whenever you edit the Dockerfile, or if the container otherwise needs to be built.
- ./debug.sh: This runs the container in debug mode. In debug mode, Apache is run in foreground mode and you can hit ^C to stop the container.
- ./run.sh: This runs the container as a daemon. Unlike the ./debug.sh script, you will be returned to the command-line prompt, with the container running in Docker. You will use this script to run the container locally, as if in production, so that you can test production behavior.
- ./stop.sh: When you have your container running in the background, this script can be used to stop it.
- ./shell.sh: Sometimes, when creating your container and editing the Dockerfile, things do not work as expected. You can use this script to get a Bash command line running within the container. From this command line, you can inspect and diagnose the problems.
- ./persist.sh: This script demonstrates using a named volume to persist the application state within the container. That is, with a named volume, you can stop and restart the container and the contents of the volume are persisted. The volume is mounted in the container as if it were a disk.
To demonstrate how building a container using a Dockerfile works, we've created one in the GitHub repository, in the chapter2/ directory (file named Dockerfile):
# we will inherit from the Debian image on DockerHub FROM debian # set timezone so files' timestamps are correct ENV TZ=America/Los_Angeles # install apache and php 7.3 # we include procps and telnet so you can use these with shell.sh prompt RUN apt-get update && apt-get install -y procps telnet apache2 php7.3 # add a user - this user will own the files in /home/app RUN useradd --user-group --create-home --shell /bin/false app # set up and copy files to /home/app ENV HOME=/usr/app WORKDIR /home/app COPY . /home/app # The PHP app is going to save its state in /data so we make a /data inside the container RUN mkdir /data && chown -R app /data && chmod 777 /data
# we need custom php configuration file to enable userdirs COPY php.conf /etc/apache2/mods-available/php7.3.conf # enable userdir and php RUN a2enmod userdir && a2enmod php7.3 # we run a script to stat the server; the array syntax makes it so ^C will work as we want CMD ["./entrypoint.sh"]
Let's look at what the Dockerfile does, step by step:
- The Dockerfile inherits from the Debian image on Docker Hub.
- We set the time zone for the container to match the time zone of the host; in other words, ensure that the timestamps of files inside the container and on the host match. This is important when mapping host directories to the container's filesystem.
- We then install Apache and PHP 7.3. These are installed in the container's filesystem and not on the host's filesystem. We have avoided the pollution problem of having a version of both installed on the host that later become unused when not working on this project.
- We also installed some command-line utilities that allow us to examine the state of the built container from a Bash shell running within the container.
- By default, the user and group that will be running the project in the container is root. In order to provide some typical Unix/Linux security, we want to run as an actual user; in our case, the username is app. So we add the user to the container's environment with useradd.
- We are going to put our PHP scripts in /home/app, with the ability to map our working directory with our PHP scripts on the host over /home/app.
- Our demo app writes its state to /data, so we need to create it and ensure that the PHP script running as a user app can read and write files there.
- We created a custom PHP configuration file that we want to use within the container, so we copy it to the container in the correct location in the filesystem.
- We need to enable the userdir and php7.3 modules. This allows us to run PHP scripts from Apache as well as have our PHP scripts in /home/app/public_html accessed via a URL such as http://localhost/~app/index.php.
- When the container is started, it needs to run some program or script within the container. We use an sh script named entrypoint.sh in the /home/app directory to start the application. We can edit this file to suit our needs during development.
We could have chosen from a variety of Linux flavors from which to start. We chose Debian here because the configuration commands should be familiar to most readers. If you install Debian in a virtual machine, you'd use the same commands to install and maintain your system. Debian isn't the smallest or most lightweight of Linux images to start from; Alpine is a great choice if you want to make your container use fewer resources. If you choose to use Alpine, be sure to read up on how to install packages and maintain the system using Alpine.
Note that whichever Linux image you start from, it's sharing the Linux kernel with your host machine. Only within the container is it Debian – your host operating system can be some other Linux distribution. What you install inside the container is not installed on your workstation, only within the container. Obviously, you shouldn't mix, say, Debian commands and installed packages directly on an Arch Linux workstation.
When you install Apache on an actual host or virtual machine, you configure it by using the a2enmod and a2dismod commands, as well as by editing the various configuration files in /etc/apache2. What we do here is edit the configuration file locally on our workstation, and then we copy that configuration file to the container.
The Dockerfile installs a few Debian applications within the container using apt-get. The RUN command that spawns apt-get within the container uses the -y switch to answer yes to any questions apt-get might ask, the -qq switch to make the apt-get command less verbose, and the >/dev/null redirection of stdio to make the Docker build (build.sh) output compact. Without the -qq and stdout redirection, the build output would contain every package and dependency downloaded, along with all the installation commands for all these packages.
Note that the final line in the Dockerfile is a CMD, the command to run when the container is instantiated. In our case, we use an array with one item, entrypoint.sh. The array makes it so that you can hit Ctrl + C to stop the container. The entrypoint.sh script runs Apache in the container after performing the necessary initialization. Also note that we enabled both the userdir and php7.3 modules in the Dockerfile.
Now that we have a Dockerfile, we need to be able to build the container so that we can then use it. This is where the first of our .sh scripts comes into play.
Understanding build.sh
The build.sh script is used to build the container. You will need to build the container at least once so that we can edit files on the host and see the changes in action within the container. You will need to rebuild the container each time you want to try the container in production mode and have the latest versions of the files:
#!/bin/sh
# build.sh
# we use the "docker build" command to build a container named "chapter2" from . (current directory)# Dockerfile is found in the current directory, and determines how the conatiner is built.
docker build -t chapter2 .
The -t flag says to name the container chapter 2. The Dockerfile is found in the current directory. The output of the build.sh script is lengthy, so it is omitted here.
You can see that each step printed in the output while building the container corresponds to a line in the Dockerfile:
Sending build context to Docker daemon 15.87kB Step 1/11 : FROM debian ---> 67e34c1c9477 Step 2/11 : ENV TZ=America/Los_Angeles ---> Using cache ---> 7bfa02a200a8 Step 3/11 : RUN apt-get update -qq >/dev/null && apt-get install -y -qq procps telnet apache2 php7.3 -qq >/dev/null ---> Running in 98a4e3192e22 debconf: delaying package configuration, since apt-utils is not installed Removing intermediate container 98a4e3192e22 ---> 86aa2b03b3b1 Step 4/11 : RUN useradd --user-group --create-home --shell /bin/false app ---> Running in 917b16b86dc5 Removing intermediate container 917b16b86dc5 ---> ef96ff367f1f Step 5/11 : ENV HOME=/usr/app ---> Running in c9706abf0afd Removing intermediate container c9706abf0afd ---> 4cc08031746b Step 6/11 : WORKDIR /home/app ---> Running in 08c2b9c79204 Removing intermediate container 08c2b9c79204 ---> 9b68722d6776 Step 7/11 : COPY . /home/app ---> d6a7b4a1a4f3 Step 8/11 : RUN mkdir /data && chown -R app /data && chmod 777 /data ---> Running in fe824496056c Removing intermediate container fe824496056c ---> 75996f4d08bc Step 9/11 : COPY php.conf /etc/apache2/mods-available/php7.3.conf ---> c6a3b094a041 Step 10/11 : RUN a2enmod userdir && a2enmod php7.3 ---> Running in 1899c1d01a2e Removing intermediate container 1899c1d01a2e ---> ae6ddd93786c Step 11/11 : CMD ["./entrypoint.sh"] ---> Running in cb0ffeaefca6 Removing intermediate container cb0ffeaefca6 ---> 9c64d1cb6bd3 Successfully built 9c64d1cb6bd3 Successfully tagged chapter2:latest
The container is incrementally built, as described by the Dockerfile. Each step is built in an image layer denoted with a hash value – those are the hex hash values printed. When you build the container again, Docker can start from the state of any of those layers' / hash values, reducing the need to constantly rebuild the container from scratch. Each layer is simply a diff (difference) between the current layer's requirements and the state of the previous layer.
The first layer is the Debian image. The next layer is an intermediate image, the diff between the result of the ENV command in the Dockerfile and the original Debian image. The next layer is the diff between this previous intermediate image and the result of the apt-get installed packages. Note that we use && to pack a few apt-get commands into one layer in the container. This greatly speeds up the build process. The layering continues as each command in the Dockerfile is processed by the Docker build command.
Docker is smart about how it caches and works with the layers. It doesn't have to download the Debian image each time you build; it can start building from a previous intermediate stage if it knows the previous steps have not changed the state of the container to that point.
Whenever we need to build the container, because we've made changes to the Dockerfile, we use the build.sh script. Once we have the container built, we have a few ways to use it. The debug.sh script is probably the most common script you'll use during development.
Understanding debug.sh
The debug.sh script runs the container image that is not in daemon mode. You can hit Ctrl + C to stop the program:
#!/usr/bin/env bash
# debug.sh
# run container without making it a daemon - useful to see logging output
docker run \ --rm \ -p8086:80 \ --name="chapter2" \ -v `pwd`:/home/app \ chapter2
The docker run command takes many optional arguments that are too numerous to detail here. For more complete information on all of the possible command-line arguments to docker run, refer to the docker run documentation on the Docker site: https://docs.docker.com/engine/reference/run/. We'll only cover the ones used in our scripts:
- Here, we use –rm, which tells Docker to clean up when the container exits, removing the container and filesystem for the container.
- The -p flag tells Docker to map port 80 from the container (HTTP) to port 8086 on the host; you can access the HTTP server in the container by using port 8086 on the host.
- The –name argument names the running container; if you don't provide a name, you'll have to use docker ps to get the hash that identifies the container to stop it using docker stop.
- The -v switch mounts volumes in the container. A volume can be a directory of a file on the host, a named volume that Docker manages for you. If you want to stop and restart the container and retain data that is written to the filesystem by the container, you must mount a volume and the container must write to this volume. You can mount multiple volumes, if you like. In our debug.sh script, we mount the current directory with the sources over /home/app, so we can modify the sources and the container programs see that the files are changed (because the file timestamps are newer) as if they were inside the container, too. For this demo, you can edit the index.php script and reload the page, and you'll see the change in action. If you don't mount this volume, then the container will access the files copied to /home/app by the Dockerfile and the build.sh script; this is what you want for production.
- The last argument to docker run is the name of the container to start – in our case, it's chapter2, the container image we created using the build.sh script.
Note:
We do not persist /data in the container. We can do this by adding the -v switch to map a Docker volume to /data, which we will do in the persist.sh script.
Running our chapter2 container with debug.sh
Let's see the container in action. We run the build.sh script and see that it succeeds. Then, we use the debug.sh script to launch the container in debug/foreground mode. Note that we did not do any configuration of the hostname for the container, so there is a warning message printed by Apache:
% ./debug.sh
entrypoint.sh
----> Point your browser at http://localhost:8086/~app/index.php
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.5. Set the 'ServerName' directive globally to suppress this message
On the host, we can use a browser to fetch http://localhost:8086/~app/index.php.
Remember, we mapped port 8086 to port 80 of the container, we enabled the userdir module, and, in the Dockerfile, we copied the index.php script to /home/app/public_html (the userdir module).
We could have configured Apache with a default host and copied our files to /var/www in the Dockerfile and build process. This would have given us a cleaner URL, and this is what you would want to do for an actual production site. For our purposes, it's good to see the Apache modules enabled and working within the container:
When we reload the page in the browser a few times, we can see that the counter is being properly maintained:
Note that we aren't generating any HTML (yet). If you're trying this yourself, you can now edit the index.php file, change Counterx: to Counter: and reload the page, and you will see that the page prints Counter: now.
We are now set up for PHP development.
If we want to add, say, MySQL support, we'll have to modify the Dockerfile to install the PHP MySQL module, and enable it as we did with userdir and php. If we want to add a PHP framework, we either need to install it within the container via the Dockerfile, or add it to the chapter2/ directory that is copied to the container's /home/app directory and, for development, mounted/bound in the container by replacing /home/app.
We can check to see that the container is running by using the docker ps command:
% docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
54925e51e404 chapter2 "./entrypoint.sh" 2 seconds ago Up 1 second 0.0.0.0:8086->80/tcp chapter2
We can exit or kill the container by pressing Ctrl + C in the window where we started it with debug.sh.
When we run the container with the run.sh script, we don't see any output from the container, not even the Apache warning:
% ./run.sh
1707b1ff84fabed4d9696aadbcd597cee08063eaa7ad22bfe572c922df 43997e
Again, we use docker ps to see that it is running:
% docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1707b1ff84fa chapter2 "./entrypoint.sh" 41 seconds ago Up 39 seconds 0.0.0.0:8086->80/tcp chapter2
Loading the same URL in the browser, we see that the counter is again 1. Reloading a few times, we see the counter increments as we designed.
We can restart the container using docker restart. Note that the container was first instantiated 3 minutes ago, but since we restarted it, the status is Up 1 second:
% docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1707b1ff84fa chapter2 "./entrypoint.sh" About a minute ago Up 1 second 0.0.0.0:8086->80/tcp chapter2
Since the container was only restarted, its filesystem remains intact. Reloading the URL in our browser, we see that the counter continues to increment. We can stop the container using docker stop, or the stop.sh script. The docker ps command shows no containers running. Then we start it up again:
% docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Now, when we reload in our browser, the counter is reset to 1. This is because we are writing to the container's filesystem. The filesystem goes away when the container exits.
If we want the counter to persist between container start/restart, we'd have to write it to a volume that is mounted on the container.
We write to /data/container.txt, so we can do the following:
- Mount our own container.txt on the host to /data/container.txt on the guest.
- Mount a directory on the host as /data on the guest.
- Have Docker create and maintain a named or anonymous volume for us.
Since the advent of named volumes, they are the better choice. A named volume is created and maintained using the -v switch to docker run with just the name of the directory on the guest; for example, -v name:/data. We have a script, persist.sh, designed to make using the named volume easy.
persist.sh
The persist.sh script does the same thing as the debug.sh script, except that it adds the -v name:/data switch to the docker run command:
#!/usr/bin/env bash
# run container without making it a daemon - useful to see logging output # we are adding an anonymous volume for /data in the container so the # counter persists between runs.
docker run \ --rm \ -p8086:80 \ --name="chapter2" \ -v `pwd`:/home/app \ -v name:/data \ chapter2
When we run it and point our browser at http://localhost:8086/~app/index.php, we see that the counter works, even if we stop and restart the container.
run.sh
The run.sh script runs the container in daemon mode – you won't be able to see the application's output without using the docker log command. It also does not mount the host directory as a volume in the container. This simulates the production environment:
#!/usr/bin/env bash
# run.sh
# run the container in the background # /data is persisted using a named container
docker run \ --detach \ --rm \
--restart always \ -p8086:80 \ -v name:/data \ --name="chapter2" \ chapter2
We are using the docker run command, once again, but with slightly different arguments:
- The –detach flag to Docker Run is what causes the container to run in the background.
- The named volume is used, so the data is persisted between starting and stopping the container.
- The development working directory is mounted on /home/app within the container.
- The –restart switch always tells Docker to restart the container when the system is rebooted. This is handy since you won't have to figure out some way to automatically start your container(s) when the operating system starts.
The container is only able to run using the files copied to it using the Dockerfile and build.sh. If you edit files on your host, you will not see the changes within the running container, as with persist.sh. You will need to run the build.sh script every time you edit files and want them changed within the container for the purposes of run.sh.
We'll need a way to stop our running container. This is where stop.sh comes in.
stop.sh
The stop.sh script will stop your chapter2 container. This is particularly useful when you've used the run.sh script to launch your container in the background:
#!/bin/sh
# stop.sh
# stop running container - typing stop.sh is easier than the whole docker command
docker stop chapter2
Let's see run.sh and stop.sh in action:
build.sh debug.sh Dockerfile entrypoint.sh install-virtualbox-macos.sh persist.sh php.conf public_html README.md run.sh shell.sh stop.sh % docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES % ./run.sh 7d6bc5195a583b3979a2533b50708978d96981d3d9ac59b266055246b6 fad329 % docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 7d6bc5195a58 chapter2 "./entrypoint.sh" 2 seconds ago Up 1 second 0.0.0.0:8086->80/tcp chapter2 % ./stop.sh chapter2 % docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES %
The shell.sh script runs the container and starts the Bash shell so that you can use command-line programs to diagnose issues with the container as it's built:
#!/usr/bin/env bash
# shell.sh
# This script starts a shell in an already built container. Sometimes you need to poke around using the shell # to diagnose problems.
# stop any existing running container ./stop.sh
# fire up the container with shell (/bin/bash)docker run -it --rm --name chapter2 chapter2 /bin/bash
The following code snippet shows the shell.sh script in action:
% ./shell.sh Error response from daemon: No such container: chapter2 root@f10092244abe:/home/app# ls -l total 44 -rw-r--r-- 1 root root 871 Dec 13 10:28 Dockerfile -rw-r--r-- 1 root root 808 Dec 5 14:56 README.md -rwxr-xr-x 1 root root 38 Dec 4 12:15 build.sh -rwxr-xr-x 1 root root 197 Dec 4 16:12 debug.sh -rwxr-xr-x 1 root root 411 Dec 13 10:28 entrypoint.sh -rw-r--r-- 1 root root 75 Dec 2 17:31 install-virtualbox-macos.sh -rwxr-xr-x 1 root root 315 Dec 13 10:26 persist.sh -rw-r--r-- 1 root root 860 Dec 4 16:24 php.conf drwxr-xr-x 1 root root 18 Dec 13 10:27 public_html -rwxr-xr-x 1 root root 152 Dec 5 13:01 run.sh -rwxr-xr-x 1 root root 308 Dec 4 17:40 shell.sh -rwxr-xr-x 1 root root 115 Dec 4 17:41 stop.sh root@f10092244abe:/home/app# ls -ldg /data drwxrwxrwx 1 root 0 Dec 13 10:28 /data root@f10092244abe:/home/app# exit %
We can see that /data was created and has world write permissions.
These few sh scripts are enough to get you developing and using your own containers. As you work with Docker, you'll likely come up with additional scripts of your own! However, we will see in Chapter 4, Composing Systems Using Containers, a way to work with Docker without the sh scripts.