Профессиональная контейнеризация Node.js-приложений с помощью Docker

в 9:00, , рубрики: docker, node.js, Блог компании RUVDS.com, виртуализация, разработка, Разработка веб-сайтов

Автор материала, перевод которого мы публикуем сегодня, работает DevOps-инженером. Он говорит, что ему приходится пользоваться Docker. В частности, эта платформа для управления контейнерами применяется на разных этапах жизненного цикла Node.js-приложений. Использование Docker, технологии, которая, в последнее время, является чрезвычайно популярной, позволяет оптимизировать процесс разработки и вывода в продакшн Node.js-проектов.

image

Сейчас мы публикуем цикл статей о 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

В ответ на эту команду должно быть выведено примерно следующее.

Профессиональная контейнеризация Node.js-приложений с помощью Docker - 2

Образы 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

В ответ на это система должна вывести примерно следующее:

Профессиональная контейнеризация Node.js-приложений с помощью Docker - 3

Контейнеры Docker

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

curl -i localhost:8000

Если контейнер действительно работает, то в ответ на эту команду будет выдано нечто вроде того, что показано на следующем рисунке.

Профессиональная контейнеризация Node.js-приложений с помощью Docker - 4

Результат проверки работоспособности контейнера

На основе одного и того же образа, например, на основе только что созданного, можно создать множество контейнеров. Кроме того, можно отправить наш образ в реестр 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.js-приложений с помощью Docker - 5

Сравнение образов из репозитория 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-проектами — просим поделиться рекомендациями по эффективному использованию этой системы с новичками.

Профессиональная контейнеризация Node.js-приложений с помощью Docker - 6

Автор: ru_vds

Источник

* - обязательные к заполнению поля