Хабрахабр

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

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

image

Этот же материал сосредоточен, в основном, на профессиональном применении Docker в Node.js-разработке.
Сейчас мы публикуем цикл статей о Docker, предназначенных для тех, кто хочет освоить эту платформу для её использования в самых разных ситуациях.

Что такое Docker?

Docker — это программа, которая предназначена для организации виртуализации на уровне операционной системы (контейнеризации). В основе контейнеров лежат многослойные образы. Проще говоря, Docker — это инструмент, который позволяет создавать, разворачивать и запускать приложения с использованием контейнеров, независимых от операционной системы, на которой они выполняются. Контейнер включает в себя образ базовой ОС, необходимой для работы приложения, библиотеки, от которых зависит это приложение, и само это приложение. Если на одном компьютере запущено несколько контейнеров, то они пользуются ресурсами этого компьютера совместно. В контейнерах Docker могут быть упакованы проекты, созданные с использованием самых разных технологий. Нас в данном материале интересуют проекты, основанные на Node.js.

Создание Node.js-проекта

Прежде чем упаковать Node.js-проект в контейнер Docker, нам нужно создать этот проект. Сделаем это. Вот файл package.json этого проекта:

, "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 умеет кэшировать слои образов, что, при повторном использовании слоёв, которые уже есть в кэше, ускоряет процесс сборки образов.

Классы используются для создания объектов. В объектно-ориентированном программировании существует такое понятие, как класс. Рассмотрим процесс формирования файла Dockerfile, который поможет нам во всём этом разобраться. В Docker образы можно сравнить с классами, а контейнеры можно сравнить с экземплярами образов, то есть — с объектами.

Создадим пустой 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.

Например, речь идёт о возможностях по распаковке .tar-архивов и по загрузке файлов по URL. Инструкция ADD, в сравнении с COPY, обладает некоторыми возможностями, которые, тем не менее, нужны не всегда.

# Копирование файлов проекта
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-зависимостей занимает много времени. Устранить этот недостаток можно, воспользовавшись технологией многоступенчатой сборки образов.

При таком подходе мы можем, ради уменьшения размера образа, воспользоваться возможностями Docker по многоступенчатой сборке образов. Представим, что мы работаем над фронтенд-проектом, имеющим множество зависимостей, и мы, для сборки этого проекта, используем webpack.

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. Это позволяет сократить время сборки образа. Но данной возможностью не стоит пользоваться необдуманно.

04: Предположим, мы описываем в Dockerfile установку пакетов в образ, созданный на основе базового образа Ubuntu:16.

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть