Docker in Practice. part 2(Dockerfile, Volumes)

Docker in Practice. part 2(Dockerfile, Volumes)

If you are already a Developer or just started with development (Frontend, Backend, Node dev, Python-dev, etc) knowledge of Containers will be incredibly beneficial to your development journey. And if you are planning to learn or learning DevOps it is something necessary to know. This Blog on Docker will help you learn some of the intermediate concepts like Dockerfile and Docker Volume. Before starting with this part 2, I recommend you check part 1 of the "Docker in Practice" series where we covered the Basics of Docker.

Dockerfile

A Dockerfile is a text file that contains instructions for building a Docker image. A Docker image is a lightweight, stand-alone, executable package that includes everything needed to run a piece of software, including the application code, system tools, libraries, and runtime.

Create the application we will dockerize

We will containerize a simple Node.js application. Suppose you know Node and Express, then great. If you don't, then follow the steps.

You will find all the code we use here on my GitHub: https://github.com/debasishbsws/docker-in-practice/tree/main/code

  1. Open up any empty folder.

  2. If you have Node installed, you can open up the terminal, go to that directory, and run npm init -y, which will create a package.json file with all the default values. Also, you should install Express by running npm i express.

    Now your package.json file will look something like this:

      {
       "name": "code",
       "version": "1.0.0",
       "description": "",
       "main": "index.js",
       "scripts": {
         "start": "node index.js"
       },
       "keywords": [],
       "author": "",
       "license": "ISC",
       "dependencies": {
         "express": "^4.18.2"
       }
     }
    

    Here we just added the "start" script to this file so that the application will start by running npm start.

    If you don't have Node you can create the 'package.json' file and paste the above JSON code.

  3. Create the index.js file in the same folder and write a simple express app.

     const express = require('express');
     const app = express();
    
     //values from env
     const PORT = process.env.PORT || 3000;
    
     //request
     app.get('/', (req, res) => {
         res.send('<h1>Hello World!!</h1>');
     });
    
     //app listening
     app.listen(PORT, () => {
         console.log(`App listening at http://localhost:${PORT}`)
     });
    

Our application is ready to be Dockerized. If you have Node in your system, you can run npm start, open a web browser, and navigate to http://localhost:3000. You should see the message "Hello, World!!" displayed in your browser. We don't need Node installed for this tutorial, as we will run this app inside a container.

Creating a Dockerfile

Create an empty file called Dockerfile in the folder

touch Dockerfile

Open the Dockerfile in your favorite text editor.

We first need to define the base image (the image from which we want to build our image). Here we will use the latest LTS (long-term support) version 16 of node available from the Docker Hub:

# Comments
FROM node:16

Next, we create a directory to hold the application code inside the image, this will be the working directory for your application, and your application code will be inside this folder:

# Create an app directory
WORKDIR /usr/src/app

The base image comes with Node.js and NPM already installed, so we next need to install your app dependencies using the npm binary. We specify to copy our package.json file to the image working directory using COPY instruction and also run npm install inside the container terminal using RUN instruction

# Install app dependencies
COPY package*.json ./
RUN npm install

Now we copy our app's source code inside the Docker image.

# Bundle app source
COPY . .

Note that we are only copying the package.json file rather than copying the entire working directory. This allows us to take advantage of cached Docker layers. For a good explanation, check this.

Our application will listen to the port 3000, and we need to EXPOSE that port to have it mapped by the Docker daemon:

EXPOSE 3000

Last, define the command to run your app using CMD, which determines your runtime. Here we will use npm start to start the server. Or we can use node index.js :

CMD [ "npm", "start" ]

Now our Dockerfile should finally look like this:

# Comments
FROM node:16

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
COPY package*.json ./
RUN npm install

# Bundle app source
COPY . .

EXPOSE 3000
CMD [ "npm", "start" ]

Note that it is the general structure of a Dockerfile, but every other application may need extra care. Remember, Google is your best friend; always go to the documentation whenever you get stuck.

.dockerignore

Now, if you think about a real scenario, our source folder will not only have the essential files, but there will be many files we do not want to include inside our containers, such as compiled binaries, temporary files, test files, git information, or a README.md file from your image to keep it as small as possible. Here we use the .dockerignore file.

This file is a list of patterns that tells Docker which files and directories to ignore when building an image.

Create a .dockerignore file and open that up:

touch .dockerignore

And include these lines in there:

node_modules
.git
.idea
test/*
Dockerfile
.dockerignore
docker-compose*.ymal

This .dockerignore file tells Docker to ignore the node_modules directory, the .git directory, the .idea directory, any docker-compose file, and the test directory; also, we don't want to include the Dockerfile inside our container, not even the .dockerignore.

Build the image

To build a Docker image from a Dockerfile, you can use the docker build command. It reads the instructions in the Dockerfile and creates an image based on those instructions.

Here is the basic syntax of the docker build command:

docker build [OPTIONS] PATH
  • PATH is the path to the directory that contains the Dockerfile and the application source code.

Here are some common options for the docker build command:

  • -t or --tag: Specifies the name and optionally a tag to the name in the name:tag format.

  • --file: Specifies the name of the Dockerfile (default is "Dockerfile").

  • --build-arg: Sets build-time variables for the image.

Here we will build a Docker image from a Dockerfile located in the current directory:

docker build -t my-node-app .

This command will build an image with the tag my-node-app using the instructions in the Dockerfile located in the current directory (.). Now if you run docker image ls you can see an image named my-node-app which is the image we build. And if we run that image as a container the app will print "Hello World!" into the browser.

There are also some other types of ways to build an image:

To build an image from a Git repository, you can use the URL of the repository as the PATH argument:

docker build -t my-image https://github.com/user/repo.git

This command clones the Git repository at the specified URL, build an image with the tag my-image using the instructions in the Dockerfile located in the repository, and then deletes the cloned repository.

You can also build an image from a Dockerfile located in a remote repository by specifying the URL of the Dockerfile as the PATH argument:

docker build -t my-image https://github.com/user/repo/blob/master/Dockerfile

This command will download the Dockerfile from the specified URL, build an image with the tag my-image using the instructions in the Dockerfile, and then delete the downloaded Dockerfile.

Run the Image as a container

Now to run the image as a container, we will use the following:

docker run -p 3000:3000 -d --name node-app my-node-app

Environment variables:

Now, if you watch the code of the index.js file, there is a line const PORT = process.env.PORT || 3000; that determines the PORT the application will listen. Here we expect to take the PORT as an Environment variable, but we are hardcoding it everywhere. How can I set an ENV VAR in a container?

We use-e or --env flag when running a Docker container using the docker run command.

Here is the basic syntax of the docker run command with the -e flag:

docker run -e VARNAME=value image [command] [arguments]

Now we can replace our docker run command with the new one:

docker run -e PORT=3000 -p 3000:3000 -d --name node-app my-node-app

But In a real scenario, we may have multiple Environment variables such as Database URL, Authentication Key, API key, etc., and it is impractical to add all of them one by one in the command line.

Instead, we can also pass environment variables to a Docker container using a file which is a more practical way when we have a bunch of ENV variables. To do this, we can use the --env-file flag and specify the path to the file that contains the environment variables. The file should contain one environment variable per line in the VARNAME=value format.

To pass the environment variables from a file called .env to the Docker container, we can use the following command:

docker run --env-file .env -p 3000:3000 -d --name node-app my-node-app

Using environment variables can be a helpful way to configure the Docker containers and customize their behavior at runtime.

We should also change the Dockerfile where we have specified to EXPOSE PORT. We can replace it with

ENV PORT 3000
EXPOSE $PORT

Here if we specify the PORT while running, it will take that PORT, and if we do not pass anything, it will use the default 3000.

In part 3, we will dive deeply into docker volumes, and more on next parts.