Press "Enter" to skip to content

A Hands-on Approach to Docker

Table of Contents

  1. What is Docker?
  2. Installation and Setup
  3. Docker Containers and Images
  4. Caching
  5. Port Mapping in Docker
  6. Networking in Docker
  7. Volumes in Docker
  8. Environment Variables in Docker
  9. Docker Compose

What is Docker?

Docker is a platform designed to simplify the process of developing, shipping, and running applications. It leverages containerization technology, allowing developers to encapsulate an application and its dependencies into lightweight, portable containers.

Installation and Setup

  1. Visit Docker Desktop site and choose the application based on your operating system.
  2. Download the installer and run it.
  3. Follow the installation instructions as given by the installer.
  4. After installation is complete, open your terminal and run docker version to verify.
  5. If the version of docker is displayed, it has been installed correctly.

Image vs Container

A docker image is a standalone, lightweight package that contains all your code required to run your service. It serves as a template for creating docker containers.
Many images can be found pre-built on Docker Hub and can be directly downloaded onto your local device  by running the following command – docker pull image-name
To build your own docker image, you need a special file known as Dockerfile which contains all the information required by docker engine to build the image in a step-by-step manner.
Once the Dockerfile has been written, image can be built by running the command –
docker build your-dockerfile-directory
To give the image a name, you can use  the flag -t to tag it: docker build -t image-name . The dot specifies the directory your dockerfile is in.

A docker container in a runnable instance of a docker image. It wraps the application in an isolated environment along with its dependencies and executes it. Containers are portable and can be moved between different environments. To start a container, you need to run the command –
docker run --name container-name image-name
If you do not include the –name flag then, docker assigns a name to the container itself.
Flag -d can also be used to run a container in the background.
So it will be like: docker run -d --name container-name image-name

You can see the list of running containers using the command – docker ps in your terminal.

How to write a Dockerfile?

A dockerfile consists of a list of step-by-step instructions to build an image.
Some of the keywords are-

  1. FROM – (used to pull a base image to build your own image on)
  2. WORKDIR – (used to specify the directory in which you want to store your files inside the container)
  3. RUN – (used to run a specific command while building the image)
  4. COPY – (used to copy files from the local device  to the container)
  5. CMD – (used to execute the command given only once when a container has started)

Below is a sample Dockerfile created for an express application –

FROM node:20
WORKDIR /usr/src/app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 4000
CMD ["npm", "run", "dev"]

In this dockerfile –
First, we get the base image of node version 20 from the docker hub. Then it that base image we set our working directory as /usr/src/app . Then we copy over the package.json app into our working directory.
For those who do not have experience with javascript, the package.json contains all the information on our application and also have the list of dependencies.

You will see that we run npm install after this, what it does is read the package.json file and install all the required dependencies for our application. Then we copy over the remaining files into working directory.
Next step, we write EXPOSE 4000 meaning, we expose port 4000 on our docker container. This has to be done to tell docker to accept all requests on 4000(the port we are listening to in our application).

After which CMD ["npm", "run", "dev"] is written which means our docker container will execute the command npm run dev after it starts.

For those who want the view of package.json file

//package.json

{
  "name": "docker-demo-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon index.js",
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "type": "module",
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2",
    "mongoose": "^8.0.4",
    "nodemon": "^3.0.2"
  }
}

 And this is index.js, the entrypoint of our app –

//index.js

import express from "express";
import mongoose from "mongoose";
import { createProduct, deleteProduct, getAllProducts } from "./controller.js";

mongoose.connect("mongodb://demo-mongodb-container:27017/Testing").then((data) => {
  console.log(`Mongodb connected with server: ${data.connection.host}`);
});

const app = express();

app.use(express.json());

const router = express.Router();

router.route("/products").get(getAllProducts);

router.route("/product/new").post(createProduct);

router.route("/product/:id").delete(deleteProduct);

app.use("/demo-api", router);

app.listen(4000, () => {
  console.log("Server is running on Port 4000");
});

Here you can see that we listening on port 4000.
In index.js,
We create a products collection in mongodb which contains the field name, description and price.
getAllProducts function fetches all the products from  mongodb database.
createProduct function create a new product.
deleteProduct function deletes an existing product from mongodb database.

Caching

Notice how in the dockerfile we seperately copy the package.json file and rest of the files. This is to reduce the time taken to build our image again and again.

In Docker, layer caching exists meaning each line in the Dockerfile is cached by Docker in layers one above the another and while rebuilding image, if a certain layer has not changed, the image layer is directly cached.
This means that even if one layer is changed, all the layers stacked on top of it will also be removed meaning we would have to run them all again.

That is why we have copied over the package.json seperately. Since during development, the dependencies will not change as often as the rest of the files which would mean the COPY package.json and RUN npm install layers would remain cached and only the commands below it would run if files are changed.
This would reduce build times.

Port Mapping in Docker

Whenever we write a backend application application or use a local database, we connect them to a port on the localhost to listen to. If we containerize these applications, they would get seperated from the ports of localhost as containers are isolated environments.
To prevent this, port mapping exists in docker which looks like -p port1:port2. This flag tells docker to forward all requests on port1 of the localhost of local device to port2 of the docker container.
For example, in the above index.js, it listens to port 4000 therefore we can write -p 4000:4000 to tell docker that all requests on localhost:4000 are forwarded to container port 4000.
We can include port mapping using the following commands –

  1. First we build our image using dockerfile just as given above.
  2. Then, we include the flag -p 4000:4000in our docker runcommand as following – docker run --name container-name -p 4000:4000 image-name

Now, our containerized application can recieve requests from outside web on port 4000.

Networking in Docker

Networking in docker means the containers ability to connect and communicate with other docker and non-docker applications.
There are primarily 6 types of existing network drivers – host, bridge, none, overlay, ipvlan and macvlan. Of these we will be discussing host, bridge and none networks. For the rest, you can refer to Docker Networking Docs.

Bridge Network driver – This is the network used by containers by default unless otherwise specified. With these network settings, a bridge is formed between the container and the local device’s network and requests on the local device can be forwarded to the container using port mapping as seen in the previous section.

Host Network driver – In this type of network, the isolation between the docker container and the host is removed and all the ports of the host are accessible to the container. Meaning any request on the host’s network can be listened to by the container.

None Network driver – In this type of network, the container is completely isolated from the host and the other containers. No network exists for the container and it can only work internally inside the container.

Now, in order to define your network using any of the pre-existing network – docker network create bridge demo-network
This command defines a network named my-network with properties of a bridge network which can be used my different containers to communicate between each other.

In the given index.js file above, on line 5, we can see the url of mongodb database as mongdb://demo-mongodb-container:27017. Here demo-mongodb-containeris the name of container having mongodb database

This is possible if both containers have same network –
docker run --name demo-app -p 4000:4000 --network demo-network demo-image
This is the container for the js application with network demo-network, port mapping 4000:4000, name demo-app and image demo-image.

For the database container –
docker run --name demo-mongodb-container -p 27017:27017 --network demo-network mongo
Name of this container is demo-mongodb-container, port mapping is 27017:27017 and network is demo-network.

Since both containers are on the same network, we can reference them by their container name.

Volumes in Docker

Whenever a container is started and then stopped, all the content inside it is destroyed which is not good for a database since it is required to store data between restarts. Therefore to tackle this, docker has a feature known as volumes which can store data from the specified application and update in on restart.

A volume can be setup using the command – docker volume create volume-name

To use a volume along with the container, you can add the flag -v volume-name:directory-for-data, here the directory-for-data refers to the location where the data you need to store in the volume is. For example in a container of mongo image, the database data is stored in the directory /data/db.

Therefore, to add volume to the above demo-mongodb-container, you can write the following commands –
1. docker volume create demo-mongodb-data

2. docker run --name demo-mongodb-container -p 27017:27017 --network demo-network -v demo-mongodb-data:/data/db mongo

Here volume flag is demo-mongodb-data:/data/db where demo-mongodb-data is the volume previously created and /data/db is the directory in which the database data is stored.

Now both the volume and the directory /data/db will be in sync, meaning if data is stored in /data/db it will also be stored in volume and on restart of container when /data/db is empty, it will recieve data from the volume hence mantaining peresistent data across restarts.

The command to remove a volume is – docker volume rm volume-name.

Environment Variables in Docker

Often their are certain values which you cannot hardcode into the codebase because of sensitive information like database passwords, JWT secrets etc. For these environment variables can be used to enter these values into codebase only at runtime of docker container.

For our example, we will use the index.js file.
In this lets replace 4000 in the last three lines of index.js by process.env.PORT . This is syntax for environment variables by javascript. So it will look like this –

 

app.listen(process.env.PORT, () => {
  console.log("Server is running on Port", process.env.PORT);
});

Now, our app has port as an environment variable which can be given as argument in docker during runtime with the flag -e MY_VARIABLE=value.
So the full command will be –
docker run --name demo-app -p 4000:4000 -e PORT=4000 --network demo-network demo-app

See here in the flag we have passed PORT=4000 meaning process.env.PORT equates to 4000 in our code.

Docker Compose

You may have noticed that to run an application with two or more services like our demo-app (consisting of js application and mongodb database) you need proportional amount of commands in terminal. Also the command gets more complicated as you add more attributes to the container.
To tackle this problem, there is a feature known as docker compose where all the containers information can be written in one file and just one command is used to run all of them.

All the information for docker compose is written in a file called as docker-compose.yaml

For the above containers with all their attributes containing the js application and mongodb database, the docker-compose.yaml will be –

#docker-compose.yaml

version: "3"
services:
  demo-app:
    build: ./
    ports:
      - "4000:4000"
    environment:
      - PORT=4000
    depends_on:
      - demo-mongodb-container
  
  demo-mongodb-container:
    image: mongo
    ports:
      - "27017:27017"
    volumes:
      - ./mongodb-data:/data/db

In this file, we can see in services, the names of the two containers demo-app and demo-mongodb-container. The demo-app has keys build(to get path of Dockerfile), ports(for port mapping), volumes and environment.
It also has additional attribute depends_on which tells docker to start this container only after the container demo-mongodb-container has started.

Similarly, the mongodb container has keys image (to get the name of image), ports(for port mapping) and volumes (to store data).

You might have noticed that their is no network attribute in the yaml file, that is because all the containers started from the same docker-compose file share the same network internally, therefore there is no need to mention the network name explicitly.

So with this we end our article on Docker.

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *