Автор материала, перевод которого мы публикуем сегодня, работает DevOps-инженером. Он говорит, что ему приходится пользоваться Docker. В частности, эта платформа для управления контейнерами применяется на разных этапах жизненного цикла Node.js-приложений. Использование Docker, технологии, которая, в последнее время, является чрезвычайно популярной, позволяет оптимизировать процесс разработки и вывода в продакшн Node.js-проектов.
Сейчас мы публикуем цикл статей о 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 Dockern');
});
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", "run"]
Вот как будет выглядеть готовый файл 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", "run"]
Сборка образа
Мы подготовили файл 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 ./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-проектами — просим поделиться рекомендациями по эффективному использованию этой системы с новичками.
Автор: ru_vds