
Сейчас мы публикуем цикл статей о Docker, предназначенных для тех, кто хочет освоить эту платформу для её использования в самых разных ситуациях. Этот же материал сосредоточен, в основном, на профессиональном применении Docker в Node.js-разработке.
Что такое Docker?
Docker — это программа, которая предназначена для организации виртуализации на уровне операционной системы (контейнеризации). В основе контейнеров лежат многослойные образы. Проще говоря, Docker — это инструмент, который позволяет создавать, разворачивать и запускать приложения с использованием контейнеров, независимых от операционной системы, на которой они выполняются. Контейнер включает в себя образ базовой ОС, необходимой для работы приложения, библиотеки, от которых зависит это приложение, и само это приложение. Если на одном компьютере запущено несколько контейнеров, то они пользуются ресурсами этого компьютера совместно. В контейнерах Docker могут быть упакованы проекты, созданные с использованием самых разных технологий. Нас в данном материале интересуют проекты, основанные на Node.js.
Создание Node.js-проекта
Прежде чем упаковать Node.js-проект в контейнер Docker, нам нужно создать этот проект. Сделаем это. Вот файл
package.json этого проекта:{ "name": "node-app", "version": "1.0.0", "description": "The best way to manage your Node app using Docker", "main": "index.js", "scripts": { "start": "node index.js" }, "author": "Ankit Jain <ankitjain28may77@gmail.com>", "license": "ISC", "dependencies": { "express": "^4.16.4" } }
Для установки зависимостей проекта выполним команду
npm install. В ходе работы этой команды, кроме прочего, будет создан файл package-lock.json. Теперь создадим файл index.js, в котором будет находиться код проекта:const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('The best way to manage your Node app using Docker\n'); }); app.listen(3000); console.log('Running on http://localhost:3000');
Как видите, тут мы описали простой сервер, возвращающий в ответ на запросы к нему некий текст.
Создание файла Dockerfile
Теперь, когда приложение готово, поговорим о том, как упаковать его в контейнер Docker. А именно, речь пойдёт о том, что является важнейшей частью любого проекта, основанного на Docker, о файле Dockerfile.
Dockerfile — это текстовой файл, который содержит инструкции, описывающие создание образа Docker для приложения. Инструкции, находящиеся в этом файле, если не вдаваться в детали, описывают создание слоёв многоуровневой файловой системы, в которой имеется всё то, что нужно приложению для работы. Платформа Docker умеет кэшировать слои образов, что, при повторном использовании слоёв, которые уже есть в кэше, ускоряет процесс сборки образов.
В объектно-ориентированном программировании существует такое понятие, как класс. Классы используются для создания объектов. В Docker образы можно сравнить с классами, а контейнеры можно сравнить с экземплярами образов, то есть — с объектами. Рассмотрим процесс формирования файла Dockerfile, который поможет нам во всём этом разобраться.
Создадим пустой Dockerfile:
touch Dockerfile
Так как мы собираемся собрать контейнер для Node.js-приложения, то первым, что нам нужно поместить в контейнер, будет базовый образ Node, который можно найти на Docker Hub. Мы будем пользоваться LTS-версией Node.js. В результате первой инструкцией нашего Dockerfile будет следующая инструкция:
FROM node:8
После этого создадим директорию для нашего кода. При этом, благодаря использованной здесь инструкции
ARG, мы сможем, при необходимости, указать имя директории приложения, отличное от /app, во время сборки контейнера. Подробности об этой инструкции можно почитать здесь.# Папка приложения ARG APP_DIR=app RUN mkdir -p ${APP_DIR} WORKDIR ${APP_DIR}
Так как мы используем образ Node, в нём уже будет установлена платформа Node.js и npm. Пользуясь тем, что уже есть в образе, можно организовать установку зависимостей проекта. С использованием флага
--production (или в том случае, если переменная среды NODE_ENV установлена в значение production) npm не будет устанавливать модули, перечисленные в разделе devDependencies файла package.json.# Установка зависимостей COPY package*.json ./ RUN npm install # Для использования в продакшне # RUN npm install --production
Здесь мы выполняем копирование в образ файла
package*.json вместо того, чтобы, например, скопировать все файлы проекта. Мы поступаем именно так из-за того, что инструкции Dockerfile RUN, COPY и ADD создают дополнительные слои образа, благодаря чему можно задействовать возможности по кэшированию слоёв платформы Docker. При таком подходе, когда мы, в следующий раз, будем собирать похожий образ, Docker выяснит, можно ли повторно использовать слои образов, которые уже есть в кэше, и если это так — воспользуется тем, что уже есть, вместо того, чтобы создавать новые слои. Это позволяет серьёзно экономить время при сборке слоёв в ходе работы над большими проектами, включающими в себя множество npm-модулей.Теперь скопируем файлы проекта в текущую рабочую директорию. Здесь мы будем использовать не инструкцию ADD, а инструкцию COPY. На самом деле, в большинстве случаев рекомендуется отдавать предпочтение инструкции
COPY.Инструкция
ADD, в сравнении с COPY, обладает некоторыми возможностями, которые, тем не менее, нужны не всегда. Например, речь идёт о возможностях по распаковке .tar-архивов и по загрузке файлов по URL.# Копирование файлов проекта COPY . .
Контейнеры Docker представляют собой изолированные среды. Это означает, что мы, запустив приложение в контейнере, не сможем взаимодействовать с ним напрямую, не открыв порт, который прослушивает это приложение. Для того чтобы проинформировать Docker о том, что в некоем контейнере имеется приложение, прослушивающее какой-то порт, можно воспользоваться инструкцией EXPOSE.
# Уведомление о порте, который будет прослушивать работающее приложение EXPOSE 3000
К настоящему моменту мы, с помощью Dockerfile, описали образ, который будет содержать приложение и всё, что ему нужно для успешного запуска. Добавим теперь в файл инструкцию, которая позволяет запустить приложение. Это — инструкция CMD. Она позволяет указать некую команду с параметрами, которая будет выполнена при запуске контейнера, и, при необходимости, может быть переопределена средствами командной строки.
# Запуск проекта CMD ["npm", "start"]
Вот как будет выглядеть готовый файл Dockerfile:
FROM node:8 # Папка приложения ARG APP_DIR=app RUN mkdir -p ${APP_DIR} WORKDIR ${APP_DIR} # Установка зависимостей COPY package*.json ./ RUN npm install # Для использования в продакшне # RUN npm install --production # Копирование файлов проекта COPY . . # Уведомление о порте, который будет прослушивать работающее приложение EXPOSE 3000 # Запуск проекта CMD ["npm", "start"]
Сборка образа
Мы подготовили файл Dockerfile, содержащий инструкции по сборке образа, на основе которого будет создан контейнер с работающим приложением. Соберём образ, выполнив команду следующего вида:
docker build --build-arg <build arguments> -t <user-name>/<image-name>:<tag-name> /path/to/Dockerfile
В нашем случае она будет выглядеть так:
docker build --build-arg APP_DIR=var/app -t ankitjain28may/node-app:V1 .
В Dockerfile есть инструкция
ARG, описывающая аргумент APP_DIR. Здесь мы задаём его значение. Если этого не сделать, то он примет то значение, которое присвоено ему в файле, то есть — app.После сборки образа проверим, видит ли его Docker. Для этого выполним такую команду:
docker images
В ответ на эту команду должно быть выведено примерно следующее.

Образы Docker
Запуск образа
После того, как мы собрали образ Docker, мы можем его запустить, то есть — создать его экземпляр, представленный работающим контейнером. Для этого используется команда такого вида:
docker run -p <External-port:exposed-port> -d --name <name of the container> <user-name>/<image-name>:<tag-name>
В нашем случае она будет выглядеть так:
docker run -p 8000:3000 -d --name node-app ankitjain28may/node-app:V1
Запросим у системы информацию о работающих контейнерах с помощью такой команды:
docker ps
В ответ на это система должна вывести примерно следующее:

Контейнеры Docker
Пока всё идёт так, как ожидается, хотя мы пока ещё не пробовали обратиться к приложению, работающему в контейнере. А именно, наш контейнер, имеющий имя
node-app, прослушивает порт 8000. Для того чтобы попытаться к нему обратиться, можно открыть браузер и перейти в нём по адресу localhost:8000. Кроме того, для того, чтобы проверить работоспособность контейнера, можно воспользоваться такой командой:curl -i localhost:8000
Если контейнер действительно работает, то в ответ на эту команду будет выдано нечто вроде того, что показано на следующем рисунке.

Результат проверки работоспособности контейнера
На основе одного и того же образа, например, на основе только что созданного, можно создать множество контейнеров. Кроме того, можно отправить наш образ в реестр Docker Hub, что даст возможность другим разработчикам загружать наш образ и запускать соответствующие контейнеры у себя. Такой подход упрощает работу с проектами.
Рекомендации
Здесь приведены некоторые рекомендации, к которым стоит прислушаться для того, чтобы эффективно использовать возможности Docker и создавать как можно более компактные образы.
▍1. Всегда создавайте файл .dockerignore
В папке проекта, который планируется поместить в контейнер, всегда нужно создавать файл
.dockerignore. Он позволяет игнорировать файлы и папки, в которых нет необходимости при сборке образа. При таком подходе мы сможем уменьшить так называемый контекст сборки, что позволит быстрее собрать образ и уменьшить его размер. Этот файл поддерживает шаблоны имён файлов, в этом он похож на файл .gitignore. Рекомендуется добавить в .dockerignore команду, благодаря которой Docker проигнорирует папку /.git, так как в этой папке обычно содержатся материалы большого размера (особенно в процессе разработки проекта) и её добавление в образ ведёт к увеличению его размера. Кроме того, в том, чтобы копировать эту папку в образ, нет особого смысла.▍2. Используйте многоступенчатый процесс сборки образов
Рассмотрим пример, когда мы собираем проект для некоей организации. В этом проекте используется множество npm-пакетов, при этом каждый такой пакет может устанавливать дополнительные пакеты, от которых зависит он сам. Выполнение всех этих операций приводит к дополнительным затратам времени в процессе сборки образа (хотя это, благодаря возможностям Docker по кэшированию, не такая уж и большая неприятность). Хуже то, что итоговый образ, содержащий зависимости некоего проекта, получается довольно большим. Тут, если речь идёт о фронтенд-проектах, можно вспомнить о том, что такие проекты обычно обрабатывают с помощью бандлеров наподобие webpack, которые позволяют удобно упаковывать всё, что нужно приложению в продашкне. В результате файлы npm-пакетов для работы такого проекта оказываются ненужными. А это значит, что от таких файлов мы, после сборки проекта с помощью того же webpack, можем избавиться.
Вооружившись этой идеей, попробуем поступить так:
# Установка зависимостей COPY package*.json ./ RUN npm install --production # Продакшн-сборка COPY . . RUN npm run build:production # Удаление папки с npm-модулями RUN rm -rf node_modules
Такой подход нас, однако, не устроит. Как мы уже говорили, инструкции
RUN, ADD и COPY создают слои, кэшируемые Docker, поэтому нам надо найти способ справиться с установкой зависимостей, сборкой проекта и последующим удалением ненужных файлов с помощью одной команды. Например, это может выглядеть так:# Добавляем в образ весь проект COPY . . # Устанавливаем зависимости, собираем проект и удаляем зависимости RUN npm install --production && npm run build:production && rm -rf node_module
В этом примере есть лишь одна инструкция
RUN, которая устанавливает зависимости, собирает проект и удаляет папку node_modules. Это приводит к тому, что размер образа будет не таким большим, как размер образа, включающего в себя папку node_modules. Мы пользуемся файлами из этой папки только в процессе сборки проекта, после чего удаляем её. Правда, такой подход плох тем, что установка npm-зависимостей занимает много времени. Устранить этот недостаток можно, воспользовавшись технологией многоступенчатой сборки образов.Представим, что мы работаем над фронтенд-проектом, имеющим множество зависимостей, и мы, для сборки этого проекта, используем webpack. При таком подходе мы можем, ради уменьшения размера образа, воспользоваться возможностями Docker по многоступенчатой сборке образов.
FROM node:8 As build # Папки RUN mkdir /app && mkdir /src WORKDIR /src # Установка зависимостей COPY package*.json ./ RUN npm install # Для использования в продакшне # RUN npm install --production # Копирование файлов проекта и сборка проекта COPY . . RUN npm run build:production # В результате получается образ, состоящий из одного слоя FROM node:alpine # Копируем собранные файлы из папки build в папку app COPY --from=build ./src/build/* /app/ ENTRYPOINT ["/app"] CMD ["--help"]
При таком подходе итоговый образ оказывается гораздо меньше предыдущего образа, и мы, кроме того, используем образ
node:alpine, который и сам по себе очень мал. А вот сравнение пары образов, в ходе которого видно, что образ node:alpine гораздо меньше, чем образ node:8.
Сравнение образов из репозитория Node
▍3. Используйте кэш Docker
Стремитесь к тому, чтобы в ходе сборки ваших образов использовались бы возможности Docker по кэшированию данных. Мы уже обращали внимание на эту возможность, работая с файлом, к которому обращались по имени
package*.json. Это позволяет сократить время сборки образа. Но данной возможностью не стоит пользоваться необдуманно.Предположим, мы описываем в Dockerfile установку пакетов в образ, созданный на основе базового образа
Ubuntu:16.04:FROM ubuntu:16.04 RUN apt-get update && apt-get install -y \ curl \ package-1 \ . .
Когда система будет обрабатывать этот файл, то, если устанавливаемых пакетов много, операции обновления и установки займут немало времени. Для того чтобы улучшить ситуацию, мы решили воспользоваться возможностями Docker по кэшированию слоёв и переписали Dockerfile так:
FROM ubuntu:16.04 RUN apt-get update RUN apt-get install -y \ curl \ package-1 \ . .
Теперь, при сборке образа в первый раз, всё идёт как надо, так как кэш пока не сформирован. Представим себе теперь, что нам нужно установить ещё один пакет,
package-2. Для этого мы переписываем файл:FROM ubuntu:16.04 RUN apt-get update RUN apt-get install -y \ curl \ package-1 \ package-2 \ . .
В результате выполнения такой команды
package-2 не будет установлен или обновлён. Почему? Дело в том, что при выполнении инструкции RUN apt-get update, Docker не видит никакой разницы этой инструкции и инструкции, выполнявшейся ранее, в результате он берёт данные из кэша. А эти данные уже устарели. При обработке инструкции RUN apt-get install система выполняет её, для неё она выглядит не так, как похожая инструкция в предыдущем Dockerfile, но в ходе установки могут либо возникнуть ошибки, либо установлена будет старая версия пакетов. В результате оказывается, что команды update и install нужно выполнять в рамках одной инструкции RUN, так, как сделано в первом примере. Кэширование — это замечательная возможность, но необдуманное использования этой возможности может приводить к проблемам.▍4. Минимизируйте количество слоёв образов
Рекомендуется всегда, когда это возможно, стремиться к минимизации количества слоёв образов, так как каждый слой — это файловая система образа Docker, а это значит, что чем меньше в образе слоёв — тем компактнее он будет. При использовании многоступенчатого процесса сборки образов достигается уменьшение количества слоёв в образе и уменьшение размера образа.
Итоги
В этом материале мы рассмотрели процесс упаковки Node.js-приложений в контейнеры Docker и работу с такими контейнерами. Кроме того, мы привели некоторые рекомендации, которые, кстати, могут быть использованы не только при создании контейнеров для Node.js-проектов.
Уважаемые читатели! Если вы профессионально пользуетесь Docker при работе с Node.js-проектами — просим поделиться рекомендациями по эффективному использованию этой системы с новичками.


