🐳 Docker Up and Running

Docker helps developers build, share, run, and verify applications anywhere — without tedious environment configuration or management.


Introduction

When you install Docker, you get two major components:

  • The Docker client
  • The Docker engine (sometimes called the "Docker daemon")

The Docker engine is the core software that runs and manages containers, it implements the runtime, API and everything else required to run containers. Once installed, you can use the docker version command to test that the client and daemon (server) are running and talking to each other.

docker version
# Client:
# ...
# Server:
# ...

If you get a response back from the Client and Server, you’re good to go.

Images

Image, Docker image, container image, and OCI (Open Container Initiative ) image all mean the same thing. A container image is read-only package that contains everything you need to run an application. It includes application code, application dependencies, a minimal set of OS constructs, and metadata. A single image can be used to start one or more containers.

You get container images by pulling them from a registry. The most common registry is Docker Hub but others exist. The pull operation downloads an image to your local Docker host where Docker can use it to start one or more containers.

Pulling Images

docker images                                   # list all the local images, you can add the `--digests` flag to see the SHA256 digests.
docker pull <repository>:<tag>                  # pull the image from a registry
docker pull mcr.microsoft.com/powershell:latest # Pull the image from an unofficial repository

Note: tag is optional when you pull images, it default to latest, if the repository doesn't have an image tagged as latest, the command will fail. You should note the latest tag does not guarantee it is the most recent image in the repository.

Filtering the Output of Docker Images

docker images --filter dangling=true

A dangling image is an image that is no longer tagged and appears in listings as <none>:<none>. A common way they occur is when building a new image with a tag that already exists. When this happens, Docker will build the new image, notice that an existing image already has the same tag, remove the tag from the existing image and give it to the new image.

Docker currently supports the following filters:

  • dangling: Accepts true or false, and returns only dangling images (true), or non-dangling images (false).
  • before: Requires an image name or ID as argument, and returns all images created before it.
  • since: Same as above, but returns images created after the specified image.
  • label: Filters images based on the presence of a label or label and value. The docker images command does not display labels in its output.

For all other filtering you can use reference.

docker images --filter=reference="*:latest"

You can also use the --format flag to format output using Go templates.

docker images --format "{{.Repository}}: {{.Tag}}: {{.Size}}"

Deleting Images

When you no longer need an image on your Docker host, you can delete it with the docker rmi command.

docker rmi alpine:latest

Deleting an image will remove the image and all of its layers from your Docker host. This means it will no longer show up in docker images commands and all directories on the Docker host containing the layer data will be deleted. However, if an image layer is shared by another image, it won’t be deleted until all images that reference it have been deleted.

Images and Layers

A Docker image is a collection of loosely-connected read-only layers where each layer comprises one or more files. Docker takes care of stacking the layers and representing them as a single unified object. All Docker images start with a base layer, and as changes are made and new content is added, new layers are added on top.

Consider the following oversimplified example of building a simple Python application. You might have a corporate policy that all applications are based on the official Ubuntu 22:04 image. This would be your image’s base layer. Adding the Python package will add a second layer on top of the base layer. If you later add source code files, these will be added as additional layers. The final image will have three layers.

The docker inspect command is a great way to see the details of an image. The docker history command is another way of inspecting an image and seeing layer data. However, it shows the build history of an image and is not a strict list of layers in the final image.

docker inspect ubuntu:latest

Containers

A container is the runtime instance of an image. The simplest way to start a container is with the docker run command. The command can take a lot of arguments, but in its most basic form you tell it an image to use and an app to run: docker run <image> <app>. The following command will start a new container based on the Ubuntu Linux image and start a Bash shell.

docker run -it ubuntu:latest /bin/bash

The -it flags connect your current terminal window to the container’s shell.

The Commands

  • docker ps: list all containers in the running state. If you add the -a flag you will also see containers in the stopped (Exited) state.
  • docker exec: run a new process inside of a running container. It’s useful for attaching the shell of your Docker host to a terminal inside a running container. This command will start a new Bash shell inside a running container and connect to it: docker exec -it <container-name or container-id> bash. For this to work, the image used to create the container must include the Bash shell.
  • docker stop: stop a running container and put it in the Exited (0) state. It issues a SIGTERM to the process with PID 1 inside of the container. If the process hasn’t cleaned up and stopped within 10 seconds it will send a SIGKILL to forcibly stop the container. docker stop accepts container IDs and container names as arguments.
  • docker start: restart a stopped container. You can give it the name or ID of a container.
  • docker rm: delete a stopped container. You can specify containers by name or ID. It is recommended that you stop a container with the docker stop command before deleting it with docker rm.
  • docker inspect: show you detailed configuration and runtime information about a container. It accepts container names and container IDs as its main argument.

Containerizing an App

Containers are all about making apps simple to build, ship, and run. The end-to-end process looks like this:

  1. Start with your application code and dependencies
  2. Create a Dockerfile that describes your app, dependencies, and how to run it
  3. Build it into an image by passing the Dockerfile to the docker build command
  4. Push the new image to a registry (optional)
  5. Run a container from the image

Inspecting the Dockerfile

.dockerignore
node_modules
.git
.gitignore
*.md
dist

A Dockerfile describes an application and tells Docker how to build it into an image.

Dockerfile
FROM alpine
LABEL maintainer="foo@bar.com"
RUN apk add --update nodejs npm
WORKDIR /app
COPY package.json package-lock.json .
RUN npm install
COPY . .
ENV PORT 8080
EXPOSE 8080
ENTRYPOINT ["node", "./index.js"]

The Dockerfile FROM instruction specifies the base image for the new image you’re building. It is also used to distinguish a new build stage in multi-stage builds. The RUN instruction lets you to run commands inside the image during a build. Each RUN instruction adds a new layer to the overall image. The COPY instruction adds files into the image as a new layer. It’s common to use it to copy your application code into an image. The ENV instruction sets the environment variables. The EXPOSE instruction documents the network port an application uses. The ENTRYPOINT instruction sets the default application to run when the image is started as a container.

Build the Image

Be sure to include the trailing period . and be sure to run the command from the same directory where Dockerfile exists.

docker build -t myapp:latest .

docker build is the command that reads a Dockerfile and containerizes an application. The -t flag tags the image, and the -f flag lets you specify the name and location of the Dockerfile. With the -f flag, you can use a Dockerfile with an arbitrary name and in an arbitrary location.

Run the App

docker run -d --name c1 \
  -e PORT=8080 \
  -p 80:8080 \
  myapp:latest

The -d flag runs the container in the background, and the -p 80:8080 flag maps port 80 on the host to port 8080 inside the running container.

Moving to Production with Multi-stage Builds

Dockerfile
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
 
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
 
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
 
FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
EXPOSE 8000
CMD [ "pnpm", "start" ]

The goal of the base stage is to create a reusable build image with all the tools needed to build the application in the later stages. The image created by this stage will only be used to build the app and not used for production.

Perform the build. You should see the prod-deps and build stages execute in parallel. This makes large builds faster.

docker build -t multi:stage .

Volumes and Persistent Data

Containers are designed to be immutable, which means it's read-only, you should not change the configuration of a container after it’s deployed. If something breaks or you need to change something, you create a brand-new container with the fixes or updates and replace the old container with this new one. You should never log into a running container and make configuration changes. However, many applications require a read-write filesystem in order to run – they won’t even run on a read-only filesystem. To help with this, containers created by Docker have a thin read-write layer on top of the read-only images they’re based on.

Volumes are the recommended way to persist data in containers. There are three major reasons for this:

  • Volumes are independent objects that are not tied to the lifecycle of a container
  • Volumes can be mapped to specialized external storage systems
  • Volumes enable multiple containers on different Docker hosts to access and share the same data

At a high-level, you create a volume, then you create a container and mount the volume into it. The volume is mounted into a directory in the container’s filesystem, and anything written to that directory is stored in the volume. If you delete the container, the volume and its data will still exist.

Creating and Mounting Docker Volumes

docker volume create myvol # create a new volume called myvol
docker run -it --name voltainer \
  --mount source=bizvol,target=/vol \
  alpine

The command uses the --mount flag to mount a volume called "bizvol" into the container at /vol. The command completes successfully despite the fact there is no volume on the system called bizvol. This raises an interesting point:

  • If you specify an existing volume, Docker will use the existing volume
  • If you specify a volume that doesn’t exist, Docker will create it for you

The Commands

  • docker volume ls: list all volumes on the local Docker host.
  • docker volume inspect: show detailed volume information. Use this command to see many interesting volume properties, including where a volume exists in the Docker host’s filesystem.
  • docker volume prune: delete all volumes that are not in use by a container or service replica. Use with caution!
  • docker volume rm: delete specific volumes that are not in use.

Deploying Apps with Compose

docker compose version

Compose uses YAML files to define microservices applications. The default name for a Compose YAML file is compose.yaml. However, it also accepts compose.yml and you can use the -f flag to specify custom filenames.

services:
  web:
    build: .
    command: python app.py
    ports:
      - target: 8080
        published: 5000
    networks:
      - counter-net
    volumes:
      - type: volume
        source: counter-vol
        target: /app
  redis:
    image: 'redis:alpine'
    networks:
      counter-net:
networks:
  counter-net:
volumes:
  counter-vol:

Within the definition of the web service, we give Docker the following instructions:

  • build: .: This tells Docker to build a new image using the Dockerfile in the current directory (.). The newly built image will be used in a later step to create the container for this service.
  • command: python app.py: This tells Docker to run a Python app called app.py in every container for this service. The app.py file must exist in the image, and the image must have Python installed. The Dockerfile takes care of both of these requirements.
  • ports:: The example in our Compose file tells Docker to map port 8080 inside the container (target) to port 5000 on the host (published). This means traffic hitting the Docker host on port 5000 will be directed to port 8080 on the container.
  • networks:: This tells Docker which network to attach the service’s containers to. The network should already exist or be defined in the networks top-level key.
  • volumes:: This tells Docker to mount the counter-vol volume (source:) to /app (target:) inside the container. The counter-vol volume needs to already exist or be defined in the volumes top-level key in the file.

Let’s use Compose to bring the app up.

docker compose up &

Normally you’ll use the --detach flag to bring the app up in the background. However, we brought it up in the foreground and used the & to give us the terminal window back. This forces Compose to output all messages to the terminal window.

The commands

  • docker compose stop: stop all containers in a Compose app without deleting them from the system.
  • docker compose rm: delete a stopped Compose app. It will delete containers and networks, but it won’t delete volumes and images by default.
  • docker compose restart: restart a Compose app that has been stopped with docker compose stop. If you make changes to your Compose app while it’s stopped, these changes will not appear in the restarted app. You need to re-deploy the app to get the changes.
  • docker compose ps: list each container in the Compose app. It shows current state, the command running inside each container, and network ports.
  • docker compose down: stop and delete a running Compose app. It deletes containers and networks, but not volumes and images.

Summary

We used to live in a world where every time the business needed a new application we had to buy a brand-new server. VMware came along and allowed us to drive more value out of new and existing IT assets. As good as VMware and the VM model is, it’s not perfect. Following the success of VMware and hypervisors came a newer more efficient and portable virtualization technology called containers. But containers were initially hard to implement and were only found in the data centers of web giants that had Linux kernel engineers on staff. Docker came along and made containers easy and accessible to the masses. Containers have taken over the world!