Docker, not just a container of confusion

« Back to posts

Hello everyone!

I’ve been doing web development for quite a while now, and I’ve seen a lot of tools and technologies come and go. Docker was one of those tools that I didn’t dig far into. For the longest time, I couldn’t confidently tell you the difference between a container and a virtual machine.

But now I can! A virtual machine is an emulated version of a computer system, complete with its own operating system and resources. A container, on the other hand, is just a different namespace on the host operating system, so it’s essentially just another process running on the machine.

Docker Commands

busybox is handy because it comes with common tools we might use. By default containers do not have any programs installed that they do not need. So ls and echo or other basic commands are not available on the hello-world image.

One of the most common uses of docker ps is to get a particular container’s ID to run commands on it.

If you are ever stuck in a container and CTRL+C does not escape, try CTRL+d.

Instead of using a dockerfile you can use docker commit commands to manually generate an image:

The result is an ID pointing to a new image based on the active container.

Dockerfile

Creating a Dockerfile

Writing a dockerfile is like being given a computer with no OS and being told to install Chrome on it. First you have to install the OS, then tools and libraries you’d need, then you can install Chrome.

Example Dockerfile:

FROM alpine
RUN apk add --update redis
CMD ["redis-server"]

Creating a Docker Image

Dockerfile -> Docker Client -> Docker Server -> Usable Image

Dockerfile statements:

Docker build steps in order (or Docker Instructions):

FROM

FROM statement defines the base image:

RUN

RUN statements defines commands to run on the image. Each RUN statement performs the following steps:

CMD

CMD defines the primary command on the container once it has been built:

Instead of spaces use an array:

CMD ["npm", "run", "start"]

No more steps! The output image is generated from the previous step.

Note that Docker will cache all build steps that had not previously changed. As soon as it reaches an instruction that is new or different, the rest of the instructions will be rebuilt not from cache. This means that if you need to make a change to a Docker image, it is usually better to put that change at the end of the Dockerfile to prevent busting the cache for earlier steps.

Tagging a Docker Image:

That was a basic example, but there are more Dockerfile commands.

COPY

Copy files from the local machine to the container.

WORKDIR

Set a working directory for the container. Any other commands will be relative to this directory.

EXPOSE

EXPOSE is only informational telling developers a port needs to be mapped to the container. Some services such as EBS read EXPOSE to map ports automatically.

Dockerfile Caching and Unnecessary Rebuilds

If docker detects a change to one of the files in a COPY instruction then all steps from that point on will be rebuilt. This can take a long time if one of the steps is to npm install from a package.json that was copied over in the directory. A way to deal with this can be to split the copy command into steps.

Instead of:

COPY ./ ./
RUN npm install

Try this:

COPY ./package.json ./
RUN npm install
COPY ./ ./

This way npm install will only be run when package.json is actually changed.

Docker Compose

docker-compose is a separate CLI that gets installed along with Docker. It is used to start up multiple Docker containers at the same time and helps automate some of the long-winded arguments that were passed to docker run.

Example docker-compose.yaml file:

version: '3'
services:
    redis-server:
        image: 'redis'
    node-app:
        build: .
        ports:
            - "8081:8081"

The services section defines different containers. The containers have a hostname that matches the name defined for the service. So to connect to the redis server from the node-app you can set the host to redis-server:

const client = redis.createClient({
    host: 'redis-server'
});

docker-compose Commands

Crashed containers just stop. They do not appear in the list of running containers with docker ps. Returning a code of 0 on edit signals that we stopped the process but its OK. Any other number on exit signals something went wrong.

Docker has Restart Policies:

version: '3'
services:
    redis-server:
        image: 'redis'
    node-app:
        restart: on-failure
        build: .
        ports:
            - "8081:8081"

Docker Volumes

Docker Volumes allow us to map files on our local machine to the container so we do not need to rebuild in order to see file changes.

When using the -v flag the : will determine whether we are mapping or bookmarking a particular path to not be mapped.

This can be set in the docker-compose.yaml:

version: '3'
services:
    web:
        build: .
        ports:
            - "3000:3000"
        volumes:
            - /app/node_modules
            - .:/app

The build: config can be set to use a nonstandard Dockerfile path:

version: '3'
services:
    web:
        build:
            context: .
            dockerfile: Dockerfile.dev

Multi-step Docker Builds

Dockerfiles can have multi-step build processes. Example of phases:

Build Phase:

Run Phase:

FROM node:16-alpine as builder
WORKDIR '/app'
COPY package.json .
RUN npm install
COPY . .
RUN npm run build

FROM nginx
COPY --from=builder /app/build /usr/share/nginx/html

Each FROM statement is starting a new container. Above is an example of a multi-step dockerfile with one container that builds the application and the next container copies over the files from the build container and serves them with nginx. The default run command of the nginx container is to start, and nginx serves its /usr/share/nginx/html directory by default.

Docker Development Workflow

Docker Compose Running Tests

Adding a second container with a different run command can allow tests to be automatically rerun for every change.

version: '3'
services:
    web:
        build: .
        ports:
            - "3000:3000"
        volumes:
            - /app/node_modules
            - .:/app
    tests:
        build: .
        volumes:
            - /app/node_modules
            - .:/app
        command: ["npm", "run", "test"]

Attaching to a container with a run command of npm run test would not attach to the running test process, but the npm process. So there is a trade-off because running the tests in an interactive terminal requires getting the container and manually attaching.trade-off because running the tests in an interactive terminal requires getting the container and manually attaching.

Back to top