Transcription
Hello everyone! I am Evgeny Kovalchuk, and you are on the WebD channel, dedicated to web development and everything related to it. Welcome to another course, this time focused on Docker.
Docker is a platform for developing, delivering, and running containerized applications. In other words, you can package any application you create into what is called a container. This container will include the necessary operating system, the required development environment, additional modules, and packages needed to run your application. Such a container ensures functionality, isolation, ease of deployment, and predictability. By downloading and running it, you will get a fully operational application.
You won’t need to update, for example, the version of Node on your computer or install dependencies through npm as we usually do, downloading the project, setting variables, or configuring anything. Everything works out of the box, just as if the application were running on the computer of the person who developed it. All of this is made possible by Docker and its components.
In this course, we will thoroughly explore the main concepts such as images, containers, volumes, and so on. We will learn how to create an image and, based on it, launch a container that will contain our application. During the training, we will containerize Node.js and React applications, after which we will connect them using Docker Compose, resulting in a running application in a container with a backend and frontend.
In conclusion, we will upload the image we created to Docker Hub for your study. You will need some basic knowledge of Node.js and React because the main code will be written using these technologies. You can find playlists with these video lessons on the channel. I hope you enjoy the course, so be sure to subscribe to the channel and hit the bell icon to stay updated on new video releases. Let’s get started!
Welcome to the first lesson dedicated to the Docker platform. In this video, we will discuss the basic concepts, principles, and features of this technology.
So, let’s start by figuring out what Docker is. Simply put, Docker is a platform for developing, delivering, and running containerized applications. It allows you to create containers, automate their launch and deployment, and manage their lifecycle.
A little further on, we will break everything down in detail. From personal observations, I can say that Docker is one of those technologies that new developers sometimes overlook because, at first glance, it’s hard to understand why it’s even necessary. Nevertheless, a huge advantage of Docker is that its use in a project can simplify application development, whether done alone or in a team, and make it easier to manage.
This is done using the aforementioned containers to run applications in isolated environments on your computer, for example, a Node application or a React application, or a MongoDB service.
For clarity, let’s consider a situation: I work in a team of developers and am developing an application in Node.js. The version of Node.js required for my application must be the latest because I want to use the newest features. Now, imagine I want to share the application with another team member so they can run it on their computer.
Before I can do that, the developer must set up their development environment to match mine so that the application works correctly. For example, they will need to install the same version of Node.js required for the application to function properly, install all project dependencies from the package.json file, and configure settings such as environment variables to ensure everything works just like it does on my machine.
This can be a complicated and sometimes lengthy process just to run the application on another computer. Now, imagine the same situation but with several applications that also require their specific environment settings, and all these applications can be run on many different machines. This means that every time we want to run an application that requires a different development environment, even on our own machines, it will be quite a labor-intensive process.
Yes, if we talk about it, one option is to use nvm. But if we are talking about Python or PHP, then it becomes much more complicated. And it is precisely in such situations that Docker comes to the rescue. Docker packages the operating system, runtime environment, all configurations, and application settings, and so on.
After that, if we need to run the application, we execute one command that starts everything necessary in an isolated environment, meaning we don’t need to rebuild, install, or reconfigure anything on our computer.
There are several important concepts in Docker that you need to understand before working with it. The first is the image. An image is a kind of blueprint for a container. It contains elements such as the working environment, a specific version of Node, the application code, all dependencies required for the application to work, any configurations like environment variables, and any additional instructions such as commands that need to be run for the application to work.
Images also have their own file system that is independent of the rest of your computer. Images are read-only, meaning once you create an image, it cannot be changed. If you need to make any changes, you must create a completely new image to include those changes.
In summary, an image is a kind of blueprint for containers, including every detail necessary for the application to work correctly.
The next concept is the container. Containers are executable instances of images. In other words, we create an image that serves as a blueprint for a container. Then, when we run it, it creates a container that is a process and can run the application exactly as intended.
These containers, which are processes running our application, are also known as isolated processes, meaning they operate independently of any other process. It’s as if our applications are running in their own box somewhere on our computer, packaged together with everything they need to work, and they are completely isolated from any other processes.
What possibilities does this open up for us? Essentially, it means that I can create an image that contains everything necessary for the application to run: the operating system, the appropriate development environment, dependencies, source code, and so on. Then I can run this image to create a container and run my application inside it.
It doesn’t matter what version of Node or Python or anything else is installed on my local computer because everything runs inside the container, which contains all the correct versions of what the application needs to work. This also means that I can simply share this Docker image with anyone who needs to run this application on their own computer. They just need to download it and then run it, which in turn will create a container that will run the application.
Again, this will be a separate process with all the necessary environments and versions because everything is pre-packaged inside the image and container. This provides a predictable, consistent, and isolated environment, and the only thing I or anyone else needs to work with these containers on their computer is Docker to manage them.
That is the essence of Docker: it is a tool for managing containers.
In conclusion, I would like to address a question that may arise: what about virtual machines? Don’t they solve the same problem? Yes, they can, but there are several nuances that sometimes make containers a more preferable option.
First, each virtual machine you create runs its own full operating system with its own kernel, which operates on top of the operating system of your host computer. Containers do not do this; they use the kernel of your host operating system, making them lighter and less resource-intensive.
This also means that they usually start faster and use less memory on the computer. Containers include a lightweight version of a specific operating system, but they still use the host machine's kernel as a base. So, in essence, containers are much lighter and faster than virtual machines, although both solve the same problems.
In this video, we will install and set up the necessary environment. After we have a basic understanding of what Docker is, it’s time to install it on our computer.
You can find the necessary installer for your operating system on the official website, the link to which I will add to the course repository. For Mac, Windows, and Linux, we install Docker by downloading and using a tool called Docker Desktop.
Although the installation process has been simplified and unified over the past few years, the website has a separate page with installation nuances for different operating systems. So, in case of any issues, I recommend looking for answers there.
Overall, the installation process on Mac is the simplest. However, I encountered a small issue due to my operating system version. I am using an older version and do not plan to upgrade, and the latest version of Docker is incompatible with this operating system. Therefore, I had to download and install Docker version 4.24.0, which is the last version supporting my OS. I will also add a link to the page with different versions in the repository.
After downloading the installer and running it, all you need to do is drag Docker into the Applications folder. Once completed, you can open Docker Desktop, and the interface will look as follows:
At the top, there is a search bar for container images, extensions, and so on. On the left side, there is a navigation menu between images and containers. We previously discussed these definitions, so you should have a general understanding.
There is a separate tab for additional extensions that simplify and speed up development, monitoring, and debugging when using Docker. You can explore these and download them to your computer if necessary.
The bottom part displays various statistical information. The most important thing is that every time you use Docker on your computer, you must ensure that it is running and working in the background. This means that you should see an icon with a whale on the panel, which, when clicked, will drop down a menu showing that Docker is running.
I want to note that you can work with Docker not only through the Docker Desktop interface but also through the terminal using special commands. Throughout the course, we will explore both options, as we did in the MongoDB video. You can determine what works best for you.
To ensure that we did everything correctly and can download the images we need, let’s open the terminal and run the command `docker run hello-world`. The result will be a message confirming successful installation. However, this is not the only action that occurred.
Let’s do a general overview of what happened without going into too much detail, as we will cover everything in the following lessons. Initially, Docker tried to find the hello-world image locally. Yes, hello-world was not just a message; it was an attempt to run a container with that name. Naturally, nothing was found, so it downloaded the image from Docker Hub, which is the default repository.
After downloading the image, Docker created a container from it and ran it, resulting in the application inside the container also running. The message confirms that the image was successfully downloaded. You can see this if you open Docker Desktop and click on the Images tab.
As for the attempt to run, the information will be found in the Containers tab. Let’s move on.
As an editor, I will use Visual Studio Code with the Menlo font and the Material theme. Additionally, for more convenient work with containers and images directly from VSCode, you can install the Docker extension. It adds an additional item to the sidebar menu where you can view the downloaded images and running containers and work with them directly from the editor.
In the course, we will not use it for greater clarity, but if you seriously plan to study the topic of containerization, this extension is a must to avoid constantly switching between the interface and the editor.
Another useful program that we will need is Postman. This application is great for configuring and creating any type of requests, as well as for testing backend or API applications. If you watched the MongoDB course, you should already be familiar with Postman. We will use it to make requests to our server application.
Of course, it will be useful for you to check the course repository, which contains all the necessary links to useful resources and tools, a file with commands used in the course, tips on how to run the project, and a list of all the course lessons. Naturally, it will also contain all the code we will write. The code for each lesson can be found in the corresponding lesson branch, so keep that in mind.
These are all the tools we will need for this course. In this video, we will take a closer look at the concept of containers through practical examples.
As a basis, we will take an image called `busybox`. This image can be used to create your Linux container. Busybox is a lightweight Linux image used for creating minimal containers. Due to its minimalism, it is often used for testing and debugging when working with this software.
In the terminal, as in previous videos, we run the command `docker run busybox`, but this time with the value `busybox`. In the output, we can immediately see information that we do not have this image locally, so we will take it from the remote repository, that is, from Docker Hub.
This is an important nuance because in the future, we will download the necessary image and use it as a basis for creating our own image. In this case, the downloaded image will be taken locally.
After installation, if you open Docker Desktop and look at the Images tab, you will see two images: hello-world from the previous lesson and the newly installed busybox. If you go to the Containers tab, you will see that a container has been launched based on the busybox image, which executed and then automatically exited. This is indicated by the status "exited."
Perhaps in the previous lesson, you asked yourself the question: why do containers close immediately after starting? It’s simple: there were no connections made to them. As I mentioned in the introductory video, a container is its own encapsulated environment. If it is not interacted with, to avoid wasting resources on your host computer, it will terminate.
We can run it with additional flags `-i` and `-t`. `-i` stands for interactive mode, and `-t` is for terminal. After that, we specify the name of the image based on which we are launching the container.
As you can see this time, first, a hash appeared in the terminal before the cursor, indicating that we are currently in the shell of our container. Secondly, if we check Docker Desktop, we will see that the new container is in the "Running" status.
I hope the concept of how containers work is clear. Now that we are inside the container, we can interact with it. For example, we can check the structure using the standard `ls` command in the terminal. You can see all the root files and folders.
The structure will be clearer if we open Docker Desktop and go to the Files tab. As mentioned earlier, this structure is a lightweight Linux image. Now we have confirmed that the container has an operating system. Additionally, any development environment can be installed on this operating system, such as Node.js.
This means that on top of the operating system layer, we can create a layer with the development environment. Let’s move on. By entering the command `hostname`, we can see what name has been assigned to this container. The name we receive is the ID of the running container. We can match this value with what is displayed in Docker Desktop, and as you can see, they match.
If we enter the `hostname` command again but with the `-i` flag, we can see the assigned IP address of the container. Each container has its own IP address, which is automatically assigned by Docker at the moment the container is launched.
Additionally, this container has access to the internet, which can be easily checked using the standard `ping` command. We can set the value to `8.8.8.8`, which, for those who don’t know, is the DNS address of Google’s server. As a result, we can see the beginning of packet exchange. To stop this process, simply press `Ctrl + C`.
Now we can just type `ping google.com`, and we will again see packet exchange. This indicates that the DNS server has worked because, to get a response from the server by addressing it by name, the name must first be processed by the DNS server, which redirects it to the corresponding IP.
If this part is difficult for you to understand, I recommend checking out the corresponding lesson from the Node.js course. I will leave a link to it in the repository.
The main point of this block is that our container is a separate process with its own operating system and internet access, both by domain names and by IP.
To exit the running container and stop it, we just need to execute the command `exit`. Now, if we look at Docker Desktop, we will see that the container we were working with has stopped.
This is all the basic information that is useful to know and understand about Docker containers. In this video, we will take a closer look at what a parent image is and how it is created, as well as discuss working with Docker Hub, which is similar to GitHub for Docker.
As you remember from the previous video, an image is a kind of blueprint for a container that is used to launch it. It contains various elements such as the working environment, application code, and so on.
How are such images created? First, it should be noted that images consist of different layers, where each layer is essentially an incremental addition to the base image. The order of the layers matters, and usually, an image starts with an entity called the parent image.
Essentially, this is the first and main layer. This layer usually describes the operating system because we can create a container with a parent image that has a specific version of Node installed on a Linux distribution. This layer itself already represents a pre-created Docker image.
Therefore, we only need to create our new image on top of it. So, the parent image is the initial layer that usually includes a lightweight operating system and a working environment.
The subsequent layers built on top of this layer can be anything, such as copying the application source code into the image, installing dependencies, specifying any initial configurations that need to be run, and so on.
The main idea here is that an image represents a layered structure, the foundation of which is the operating system with a working environment, on top of which everything necessary for the application to run and work is installed.
Great, we’ve got that figured out! The next important resource needed when working with Docker is Docker Hub. Docker Hub is an online repository for Docker images, and as mentioned, it is somewhat similar to GitHub.
However, this resource contains many pre-created parent images that we can use as the first layer in our own images. We can search for and download these pre-created images to our computer.
For example, let’s say we want to create a new Docker image for a project. This image should work in a Node.js environment. Therefore, the starting layer of our image will be the parent Node image.
So, all we need to do is enter the word "node" in the search bar and hit Enter. As a result, we find the Node image, which is the official image for Node.js. By the way, this is also indicated by the "official" label.
Here, we can also see the image for MongoDB, which can also be downloaded and run, along with many other pre-created images for things like AWS, WordPress, MySQL, and so on.
We can download the image we need in two ways: by clicking the "Pull" button or by entering the command `docker pull node` in the terminal. Since we are developers, let’s use the second approach.
It doesn’t matter which directory we are in the terminal; Docker will save it in a special place. You can see that Docker uses the standard tag "latest" because we did not specify a tag for a specific version of the image; it automatically downloads the latest version.
While the installation is in progress, we can take a closer look at the Node image. If we scroll through this image, we can see that these are different variations of Node images that can be used. They specify a specific version of Node as well as the base Linux distribution.
That’s why the command `docker pull node` can be run with a specific tag, and thus you will install exactly the specific versions. All available tags can be viewed in the corresponding dropdown, and you can read about the features of each on Docker Hub.
As soon as the download is complete, in Docker Desktop, we open the Images tab, where we can see the Node image we just downloaded, along with additional information about the tag, release, and size of the image.
Using the "Run" button, we can launch this image to create a container. I remind you that earlier I mentioned that this image will become the parent image, and we will use it as the starting layer in our own image.
However, it’s important to understand that the downloaded Node image is also just an image, and it can be used to launch a container that will contain the Linux distribution and the installed Node.
In the Containers tab, we can see the history of launched containers and their current statuses. At this moment, our container is stopped, meaning we launched it, it executed, and then was stopped.
There are several options for starting a container. We can start a container through the terminal, as we have done in previous videos. For this, there is the command `docker run`, followed by the name or ID of the image based on which the container will be launched.
If we use it without flags, the container will start, see that there are no connections to it, and stop. If we start it with the flags `-i` and `-t`, this combination of keys will provide interactive access to the container’s shell, allowing us to interact with it.
Alternatively, we can run the container in detached mode, and it will run in the background, meaning the terminal will remain available for commands, and we will not enter the container.
Now let’s open Docker Desktop and see what we have. Let’s go to the Containers tab. At this moment, we have launched three containers: one from the hello-world image, the second from busybox, and the third from the Node image.
By the way, despite the fact that some containers are stopped, they are still available in Docker, and each of them can be restarted if necessary. You can view the list of containers not only through Docker Desktop but also through the terminal using the command `docker ps`.
When this command is run, a table with running containers is returned. In our case, this is one running Node container. If we run this command with the `-a` flag, which stands for "all," it will display all containers, both active and inactive, along with all information about them, which essentially duplicates the interface.
In the next lesson, we will look at how to create our own image using the downloaded Node image as the parent image. In this video, we will create our first image using a Dockerfile.
In the previous lesson, we briefly discussed how Docker images consist of different layers. We also downloaded the so-called parent image, which contains the operating system and the runtime environment for the application.
Now our task is to add our own additional layers on top of the initial one, which will expand the capabilities of the image with additional functionality.
The layers can be thought of as incremental changes to the image that we ultimately create. As an example, we will use a simple application that uses Express to create a server and return an array of data at the root route.
All the logic is described in the file `server.js`, which contains the script to launch the application and the dependencies used in it. To turn our project into a Docker image, we need to create a Dockerfile in the root.
We create a new file with the corresponding name, capitalized and without an extension at the end. Essentially, this is a set of instructions that tells Docker how to create a specific image with all its various layers. Each instruction will be on a separate line within this file, and in general, each instruction in the Dockerfile represents a different layer in the final image.
The first instruction, the first layer of our image, will be the parent image, which is the Node image we downloaded from Docker Hub. This is specified at the top of the file since this will be the first layer or starting layer of the image we want to create.
To do this, we simply write `FROM` in uppercase, followed by the name of the image we will be using, which in our case will be `node:latest`. This is the name of the local image.
The next layer I want to add is the instruction to copy the application code, meaning everything in the `api` folder. To do this, we specify the command `COPY` in uppercase, followed by a dot for the source path. The first dot is a relative path to the directory from which we want to copy files; in our case, everything is at the same level, so it’s just a dot.
If all the code were in the `src` folder, the line would look like `COPY ./src`. The second argument, `app`, is the path inside the image where I want to copy the source code, meaning a folder named `app` will be created inside the image where all project files will be copied.
We can verify this when we create our image and run the container. The next layer of the image will be the installation of all dependencies necessary for the application to work. Usually, the command `npm install` is used for this task.
To make the application work, we need to do something similar in our image. For this, we use the `RUN` instruction, specifying the command we want to execute, which is `npm install`. Now we can be sure that all dependencies listed in the `package.json` file will be installed.
However, we have one small problem: we copied all the project code in the previous line to the `app` folder, but we are trying to run the `RUN` instruction from the root of the image. Accordingly, this option will not work.
To make this work, we need to execute `RUN` in the same directory as our `package.json`, which is the `app` folder. This problem can be solved by specifying the working directory for the image.
To do this, we add the instruction `WORKDIR`, where we define the path to the working directory. In other words, when the image is launched, Docker tries to find the working directory; if it doesn’t exist, it will create it. After that, all interactions will occur from there.
Therefore, we need to adjust the `COPY` instruction, replacing the path with just a dot. This will indicate that the application code should be copied to the root of the working directory, which is `app`.
So far, everything should be clear. The final step, after we have copied everything and installed the dependencies, should be to run the application itself. We use the command `npm run`, which is defined in the `package.json` file.
It executes the command `node server.js`. One might assume that we simply add another `RUN` instruction after which we specify `node server.js`, but that’s not the case.
When we add a `RUN` instruction, it executes the command during the image creation. However, the image is not a running application; it is merely a template for a container, and a container is what actually runs the application.
In other words, it makes sense to use the `RUN` instruction to install dependencies during the image creation so that they are included in the image and ready for use when launching a container based on this image.
But there is no point in executing the command `node server.js` during the image creation because we are not trying to run the application at that moment. Instead, we just want to execute the command `node server.js` when running an instance of the image in the container.
Therefore, instead of using the `RUN` instruction, we use the `CMD` instruction, which allows us to specify commands that should be executed when the container starts. However, the way to write this command is slightly different; it is specified as an array of strings in double quotes.
So the first string will be `node`, and the second will be `server.js`. When the container starts, this command will be executed, and our application will run inside the container.
But there is also a small nuance here: we described the internal structure of launching the project, meaning the direct call of the file using Node. However, those who are engaged in development know that such commands can be very long, specifying additional keys, paths, or configurations.
So a simpler option is to specify not the contents of the command but the command itself, `npm run`, breaking it into the corresponding strings. This is a more universal and simpler approach.
Another thing I would like to add to this Dockerfile is the `EXPOSE` instruction, which tells Docker which port should be open for the container. As I mentioned at the beginning of the course, a container is an isolated process. To access this process, we need to expose a port, which we will do next.
Now we have a Dockerfile that describes how to create a Docker image. We can proceed to create this image using the command `docker build`. Then we can use the `-t` flag, which stands for "tag," allowing us to name the image, for example, `server-app`. At the end, we specify a dot, which denotes the relative path to the Dockerfile from the directory we are currently in the terminal.
Since we are in the same directory as the Dockerfile, the path is simply a dot, indicating the current directory. We press Enter, and Docker will start the process of creating the image. It will go through each of the instructions in the Dockerfile and execute them one by one.
Each time it executes one of them, it essentially adds a new layer to the Docker image we are currently creating. You can see how each of these steps occurs by observing the build process in the terminal.
When everything is finished, the image will be created, but we won’t see any additional files in our project representing this image. Everything is stored in a special folder on our computer.
However, if we open Docker Desktop, we will see the created image `server-app`. Based on it, we can launch a container by clicking "Run," and if everything is done correctly, the logs will show a message about successfully listening on the specified port.
Congratulations! Additionally, by clicking on the "Files" tab, we can look at our image. We are interested in the `app` folder, which is exactly the folder we specified in the Dockerfile as the working directory. If we go into it, we can see all the copied project files.
As a final point, I would like to show one nuance. As mentioned, our created image uses the local image we downloaded, which is `node:latest`, based on Ubuntu, which is a fully functional operating system with many pre-installed libraries and utilities.
That’s why its size is over 1 GB. However, in our small application, such serious OS functionality is not needed. Therefore, we will replace the first line with `node:22-alpine`. This image is based on Alpine Linux, a distribution known for its minimal size and security orientation.
For our personal Node.js application, it will be sufficient. Therefore, we can delete the current image. First, we will go to the container and delete the container because you cannot delete an image if there are associated containers.
Now we return to the images and delete the image. After that, we will recreate it. The difference in size will be significant. However, keep in mind that choosing the operating system for your images is also an important step.
If your application uses a lot of internal utilities or synchronization, it’s better to look towards a full-fledged operating system. If it only needs minimal functionality, it makes sense to consider lightweight versions.
In this video, we will review the main flow of working with containers and also try to run our application and access it from the outside. As a result of this lesson, we created a Docker image and even launched a container based on it.
Now let’s learn how to run our containers in such a way that we can access them from the outside. After all, we have a server that returns data, and it would be nice to be able to retrieve this data.
Let’s open Postman and check that accessing `localhost:3000` returns nothing. As you can see, when we click "Send," we received an error.
Let’s return to Docker Desktop, go to the Containers tab, and stop the running container. After that, we will delete it. Starting and deleting containers, as well as building images, are the most common operations we will have to work with.
Now let’s run the container so that we can receive data when we access it. In fact, this is a pretty simple operation, and we will perform it first from Docker Desktop.
To do this, we return to the Images tab and click the "Run" button on the `server-app` image. In the opened window, we have the option to choose additional parameters.
First, we can give the container a name for simplicity. I will call it `test1`. Then we can also specify the port through which we will be able to connect to this container on our computer, defining it as `3001`, for example.
By the way, this port mapping option in Docker Desktop will only be available if you added the instruction in your Dockerfile. If you didn’t do that, it won’t be here.
So, if we click the "Run" button, the container will start, and we should see this container in the Containers tab. I want to draw your attention to the port mapping: `3000:3001`.
Once again, port `3000` is the port of our application where requests are listened to, but we cannot access this port directly through our host because the application is running in an isolated process.
Therefore, to access it from the container, we need to expose our port, which is `3001`. Again, we see a message about successful listening. If we click on the Containers tab, you will see that our container `test1` is running.
Now, if we open Postman and change the local port to `3001`, after making a request, we will see the data returned from our container application. Great!
Now let’s do the same operation, but through the terminal. First, let’s check the available images using the command `docker images`. As expected, we have two available images: the Node image we downloaded and the `server-app`, which is our overlay.
To run a container based on the image, we use the command `docker run`, then apply the `--name` flag to give the container a name we will call `test2`. After that, we will need another flag called `-p` to define its value as `3002`.
Why this? I want to remind you that at this moment we already have a running container named `test1` using port `3001`. We are not stopping anything, and everything is still working. That’s why we define our new external port for the container as `3002`.
Finally, we specify the name of the image or its ID. In our case, we will use the name. We press Enter to create the new container. Now we see that the application has started working in this container, and in the end, we receive a message indicating that requests are being listened to on port `3000`.
If we check Docker Desktop, we will see two running containers. This means that based on one image, we were able to start two separate independent processes.
If we go to Postman and check the result, we will get a response when we send a request to port `3001`, and we will receive the same if we ping port `3002`.
Now we know how to run containers both through the interface and through the terminal. What if we need to stop a container? The first option is the "Stop" button that appears on the running container, which we already clicked at the beginning of the lesson.
The second option is the terminal, where we can stop the container we started in the terminal. To exit this process, simply press `Ctrl + C`, and the process and container will be stopped.
However, we can also run a container in detached mode, meaning without blocking. We have already done this in previous videos. For this, at the very beginning, we just need to add the `-d` flag, and that’s it.
Additionally, we will change the name to `test3`. We press Enter, and in the terminal, we receive information about the ID of the created container. If we go to Docker Desktop, we will see the third container running.
Constantly switching from the terminal to the interface to check the containers can be a bit tedious, so for this purpose, we can use the command `docker ps`, which will show all running containers and information related to each in a tabular format.
The third and most common option used to stop a container is the command `docker stop`, followed by the name or ID of the container. Therefore, by entering `docker stop test1`, we can stop the first running container.
If we check the current containers, we will see only one value. By the way, an interesting point: in addition to the stop command, you can use the kill command. The difference is that `docker stop` stops all processes and tasks inside the container and then terminates it.
The documentation states that this process can take no more than 10 seconds. `docker kill` is a hard stop, where the container stops instantly.
The history of all containers, both installed and running, is preserved. We can view it by executing the same `docker ps`, but this time adding the `-a` flag. This time, in the list, we see our three containers, two of which are stopped and one is still running.
In conclusion, if we want to restart a stopped container, we can enter the command `docker start`, followed by the name or ID of the container. In our case, we will use the name.
When restarting, we don’t need that long record where we defined the port, container name, and so on because we are starting an already existing container with already configured parameters.
Containers can not only be created, started, and stopped but also deleted. To do this, we can use the command `docker rm`, followed by the name or ID of the container to be deleted. However, there is one nuance: a running container cannot be deleted.
So if I try to delete the container `test1`, the terminal will return an error stating that it must be stopped first. Similarly, if you want to delete an image, you will first need to delete all containers running based on it.
So we change `test1` to `test2`, which is the name of the stopped container, and this time everything goes smoothly. By executing the command `docker ps` with the `-a` flag, we will see that we have only two containers left, and both are currently running.
If, by chance, the regular deletion ended with some error and you cannot delete the created container, you can invoke deletion with the `--force` flag. To do this, after `docker rm`, just add the `-f` flag, and you will effectively perform a hard deletion of the container.
With these simple commands, today we learned how to work with containers. In this video, we will discuss the concept of layer caching and also try to optimize the image build process.
In previous lessons, we reviewed how to create images and run images to start containers, as well as how to stop containers, and all of this works perfectly with Docker. However, we can improve this process by using an approach called layer caching.
Remember how I mentioned that practically every line we write inside the Dockerfile represents a new layer in the created image? This is because each line adds something new to the image, thus layering it.
If we look at our created Dockerfile, we can see what happens: for the first layer, we load the Node image; then, for the second layer, we specify the working directory, which adds information as a layer to the final image.
Next, for the third layer, we copy the source files into the image, again adding something new. After that, we install dependencies, again adding a new layer to the image, and so on.
Therefore, each line here represents the addition of a new layer to the image. This is how images are created: by layering one on top of the other.
Every time we add a layer, we essentially change the image, meaning we give Docker additional work to add something to the image during its creation, and each time this takes some time to execute.
For example, it may take several seconds to load the parent image, about 20 seconds to install all dependencies, and so on. For instance, if we change the parent image to another version of Node, say version 21, we will see the image creation process in the terminal.
Let’s run `docker build` again to create a new image, then specify the `-t` flag to give it a tag or name, for example, `server-app-2`. At the end, we put a dot, which represents the relative path to the Dockerfile.
When we press Enter, Docker will go through each of these steps to create a new layer in the image we are creating. We can see on the screen how this takes some time in the terminal.
When everything is ready, we can scroll up and see how long each operation or instruction took. This is quite visual since it is displayed separately in the right column.
We can also see that each of the layers has been added to the new image we created. One of the four layers is the parent image, the second is the working directory, the third is copying the source files, and the fourth is installing dependencies.
Now imagine that we made changes to our `server.js` file, for example, adding an exclamation mark to the console message. Since we need to create a new image, I mentioned this step in the initial videos.
That is, when making changes to applications, images need to be rebuilt because if this is not done, containers will be created based on the old code or old image. Therefore, if we make changes to any part of our application, we need to create a new image to capture those changes.
Let’s run the creation of a new image, but this time we will name it `server-app-3`. Even with the naked eye, it was clear how much faster the creation happened this time.
If we look in the terminal, we can see that the second, third, and fourth steps were taken from the cache, and only the layer copying the project was run from scratch. As you can see, the optimization process worked as planned.
To ensure everything works correctly, let’s create a container based on the image. We use the command `docker run` with the `-d` flag to run the process in the background, define the name as `test4`, set the port to `3004`, and run the container from the `server-app-3` image.
We press Enter and see the ID of the running container, meaning everything went successfully. Let’s check by opening Postman, changing the port to `3004`, and making a request. The data returned means the container is working correctly.
In summary, I can say that the topic of build optimization is quite extensive, and it all depends on specific cases and configurations. However, in this video, we thoroughly examined the mechanism of layer caching in Docker and, through a specific albeit small example, looked at how to speed up the build process.
Additionally, if you still have problems understanding the topic, I recommend checking out the article on Docker Build Cache on the official documentation site. I will leave a link to it in the repository.
In this video, we will discuss image management and the concept of tagging images. During the previous lesson, when we discussed layer caching, we created several images. They were needed to test this mechanism, but now they are clearly redundant since each image is practically a complete copy of the previous one.
Whenever you work with Docker, you will need to control the images and containers you have and delete unnecessary ones. We have already reviewed something similar in the context of containers. Now let’s look at what commands are available for managing images.
To see the complete list of images, you can use the command `docker images`. It will output standard information with the image name, its tag, ID, and size.
Suppose we want to delete an image. For this, there is the command `docker rmi`, followed by the image name or its ID. In our case, we will use the name, for example, `server-app-4`. We press Enter and receive information that the image with the corresponding ID has been deleted.
You can verify this by running the `docker images` command again. By the way, for deletion, you can use the shortened form of the command. Instead of writing `docker rmi`, you can just use `docker rmi` followed by the image name or ID, and the result will be the same.
Now let’s do another deletion. This time, let’s try to delete `server-app-5`. When we run it, we will get an error indicating that this image is being used by a container, and to delete it, we first need to delete the container.
Let’s run the command `docker ps` and see the container named `test5`, which not only exists but is also running. This is the last container we started in the previous release. Therefore, to delete the image, we need to stop its container, delete that container, and only then can we delete the image we need.
So, let’s go through the commands: stopping the container is `docker stop`, then we delete the stopped container using `docker rm`, and finally, we repeat the command `docker rmi server-app-5`. This time, the image deletion will be successful.
By the way, if the containers are not running, you can delete them in bulk. For this, you can use the command `docker rm`, and just list either the names of the containers or their IDs.
Now that we have figured out how to manage containers and images and cleaned up our Docker by removing everything unnecessary, let’s try to understand the topic of tagging our images.
As you remember, we took our parent image, Node, from the Docker Hub resource. At the time of installation, we could get various versions of this image. A complete list of supported tags can be seen directly in the image repository. There are quite a few of them, and each has its own features, functionalities, and characteristics.
Essentially, these are just different versions or variations of one image, in our case, the Node.js image. In Docker, this is called tagging. We create a tag by adding a colon after the image name and then specifying some version or tag.
In this case, the various tags are versions of Node, such as 20, 21, and so on, as well as names of Linux distributions. Letters or numbers, or both together, can be used to create a tag name.
We can use them to create multiple versions of our images that have some differences. Let’s do the same with our image. I would like to start this process from scratch, meaning clearing all containers and images currently in Docker.
This can be done as we have done so far, deleting everything individually, or we can use the special command `docker system prune -a`, where `-a` stands for "all." This is a kind of hard reset for Docker; it stops all containers, deletes all images and containers created based on them, and clears all existing cache.
Essentially, we get a clean slate. If we open Docker Desktop, we will see that it is completely empty. Even the parent image Node has been deleted.
Let’s create a new image based on the Dockerfile we have. We use the familiar command `docker build`, then specify the name of the image `server-app`, and through the colon, we set the version or tag we want to assign to the image. In my case, it will be `v1.0`.
We press Enter, and as soon as everything is finished, we can execute the command `docker images` to see the current images. As expected, we have one image named `server-app` with the tag `v1.0`.
To check that everything works, let’s run a container based on this image. As usual, we use the command `docker run`, then the flag to run the container in the background, give it the name `test1`, and define the ports as `3001:3000`. Finally, we specify the name of our image, including the tag.
We press Enter and see information about the successful launch and the ID of the container. To check, let’s go to Postman and make a request to port `3001`. We received the expected data in response, which means everything works correctly.
Now our image is tagged and has a version. Since we decided to do everything properly, there is one more nuance to address. As you may have noticed, there is no `node_modules` folder in our project folder.
It is absent because we did not conduct local development, and the server was running inside the container. However, the project in the container is an ordinary project whose code changes. In other words, if we conduct local development, the dependencies will also be installed, meaning a `node_modules` folder will be created to store these dependencies.
In our Dockerfile, there is an instruction to copy the entire project into the container, which will also happen with the `node_modules` folder if it appears. Ideally, when we create an image, we do not want Docker to copy the `node_modules` folder into the image.
To do this, in the project directory, we create a file named `.dockerignore`. Inside this file, we specify any files or folders that we want Docker to ignore when copying them into the image. By adding `node_modules` to this file, we will exclude that folder.
Overall, this works exactly like `.gitignore` in Git.
In this video, we will discuss what volumes are in the context of Docker and also review the basic work with volumes.
At this point, we know how to create images, how to start containers based on images, and how to add tags to the images we create. I hope you remember that after creating a container, the image becomes read-only.
This means that if you make any changes to the application, whether it’s changing the source code or adding new dependencies, you will then need to rebuild a new image based on those changes.
At the end of the previous lesson, we created an image with the tag `v1.0` and launched the container `test1` based on it. We can see this if we execute the command `docker ps -a`.
I remind you that the `-a` flag is needed to see all containers, including stopped ones. To restart a container that has already been created, we can use the command `docker start`, followed by the name or ID of the container.
We will call it with the `-i` flag to start the container in interactive mode. However, before doing this, let’s make some changes to the application. For example, inside the `server.js` file, we can execute the container launch.
Despite all the added changes, we still see the old message in the terminal. To see the changes we made, we need to create a completely new image based on our code changes, and then we need to launch a container based on the new image for those changes to be reflected.
Fortunately, Docker has a solution to this problem called volumes. Volumes are a Docker feature that allows us to specify folders on our host computer that can be accessible to running containers.
We can map these folders on our host computer to specific folders inside the container. So if something changes in the folders on our computer, that change will also reflect in the folders inside the container.
What’s more important is that it allows us to retain necessary data when deleting a container. In simpler terms, it’s a way for us to make changes to the project and see those changes without constantly creating new images.
However, it’s important to note that the image itself does not change; volumes simply give us a way to map a directory between containers and the host computer, and the image that launches the container does not change at all.
Therefore, if we want to update the image to share it with others or create new containers, we will first need to rebuild the image using `docker build`. But while we are testing or developing our applications, volumes can be invaluable so we can see the changes we make without constant rebuilding.
This is done easily; we just add an additional flag when starting. However, before we do that, let’s make some important additions to our project.
First, let’s update the Dockerfile to define the command for globally installing the `nodemon` package when creating the image. I remind you that `nodemon` is a package for Node.js applications that watches for changes and automatically restarts the server when such a change occurs.
We will add a `RUN` instruction for which we will specify the command `npm install -g nodemon`. Next, we will go to `package.json` and change the `start` script to `nodemon server.js`.
This is how we will run our UI application. The final step will be editing the main Docker Compose file to create the image for the React application and also to launch a container based on it.
Here, we can copy an already existing piece and slightly modify it. The `build` property will point to the folder where the Dockerfile for the current service is stored.
We will also create the container name using the `container_name` property and define it as `react-ui`. Next, we need to perform port mapping. In the case of the interface, we will expose port `3000` and connect it to port `3000` of our application.
In the volumes section, we will adjust the path from the root folder, changing it to `ui`. Here, I would like to make a small note: if you are working on Windows, the current approach with volumes for the React application may not work because Docker on Windows using WSL behaves slightly differently.
In some versions of Windows, this can be circumvented by using a flag called `chokidar-use-polling`. A more complex solution is to work with Docker inside the Linux subsystem on Windows. On Mac and Linux, this seems to work without issues.
So if you are working on Windows and your volumes are not working, I would appreciate it if you could share how to solve the problem in the comments.
Additionally, we will need to add two flags to the configuration: `stdin_open` and `tty`, both set to `true`. `stdin_open` (shortened to `-i` or `interactive`) allows connecting terminal input to the container’s execution, which is useful for interactive applications requiring user input.
In other words, our React container will not automatically close. `tty` is needed for text formatting; it makes the output more readable and understandable by providing proper formatting and color support.
Everything is ready! The last thing left is to launch both containers and check the application. We enter `docker-compose up` and wait for it to finish. As a result, in the terminal, you will see messages from `nodemon` and the React application about successful launches.
Now we can go to the browser and check. As before, we see the list of movies. If we try to make changes in the React application, they should dynamically reflect in the UI. Similarly, if we adjust any returned values on the server, they will be updated after reloading.
As a result, we have created images and launched containers for both the API and React applications.
Welcome to the final lesson dedicated to Docker technology. In this video, we will upload the results of our work to Docker Hub. Throughout the course, we have done many interesting things with Docker, created images for different services, learned how to launch containers for them, how to work with volumes, and how Docker Compose works.
Now, in conclusion, I want to show you how to share the images we created on Docker Hub so that others can download and run them on their computers. In our case, during the course, we created an API image or a server image for working with the API.
We have all the necessary data and settings, and technically, we can allow other developers to use our image without additional development; they just need to download it, launch a container based on it, and use it as we did in previous videos.
To do this, the first thing we need to do is go to the Docker Hub website. I remind you that I will add the link to it in the course repository. If you are not registered on Docker Hub, please do so; it can be done in a couple of clicks using Google or GitHub.
Next, we select the option to create a repository. This is exactly what we need. We will specify the name of the repository, which I will enter as `server-app`. If you want, you can add a description; I will leave this field empty.
Finally, we will define the repository's privacy: public will be visible to everyone, while private will only be visible to you. We click "Create." Now we see a repository named `server-app`, which has a prefix, meaning the full name of the image is `yourusername/server-app`.
This is important because we will need to name our image exactly with the full name for uploading. To upload the prepared image, we must execute the command `docker push`, followed by the full name.
Having established this, we return to our project and create a new image. Now we are talking about the image with the API, so make sure the terminal is running from the corresponding folder. We run the command `docker build`, then specify the full name, which can be copied from Docker Hub, and don’t forget the dot at the end.
We press Enter to execute it. Authentication will occur using the existing data; in your case, you may need the login and password you used during registration. As a result, a message "Login succeeded" should appear.
After that, we will send the built image to Docker Hub. Here, we also need to specify the full name. When the push is complete, you can return to Docker Hub, where in the main section, you will see information that you just sent the image.
This column indicates that the image is the latest because we did not specify any tags during the push, and this image is built on a Linux operating system. Now you will see all the current images of `server-app`.
Now, if you or someone else wants to use our API image, they just need to copy and execute the suggested command `docker pull`. This is the advantage of Docker: we can build images and share them with others.
These images will contain all dependencies, the development environment, the operating system, and so on. When a user who downloaded the image runs it on their computer, they create a container or isolated environment and run the application exactly as it would run on our computer, regardless of what versions of the operating system or development environment are installed on their local machine.
This supports collaboration among different people working on the application. In these isolated containers, everything will work as expected and predictably.
That’s all! On the channel, you will find many more interesting courses and lessons on web technologies, starting from the basics of HTML, CSS, and Flexbox, and ending with backend technologies such as Node.js, GraphQL, MongoDB, and so on.
If you have any questions, feel free to ask them in the comments below the video. Don’t forget to like and subscribe to the channel if you enjoyed this course. See you in the next new course of lectures! Bye!