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

Original author: Ankit Jain
  • Translation
  • Tutorial
Автор материала, перевод которого мы публикуем сегодня, работает 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 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-проектами — просим поделиться рекомендациями по эффективному использованию этой системы с новичками.

RUVDS.com
1,511.42
RUVDS – хостинг VDS/VPS серверов
Share post

Comments 24

    +4

    О господи ru_vds, а что тут профессионального то в описаном? Зашел почитать откровений про опыт использования докера и возможно про оркестрацию, а тут хеллоуворлд для ноды в докерфайле… Статьи 2019 года которые мы заслужили блин… :)

      +1
      они похоже занялись художественной перепечаткой разной документации. Вот вчера, или позавчера, перепечатали документацию по питоновскому argparse))).
        +1
        Такие же ощущения.
        Еще интересно стало что же значит тогда любительская контейнеризация)
        +4
        Docker — это инструмент, который позволяет создавать, разворачивать и запускать приложения с использованием контейнеров, независимых от операционной системы, на которой они выполняются. Контейнер включает в себя образ базовой ОС, необходимой для работы приложения, библиотеки, от которых зависит это приложение, и само это приложение. Если на одном компьютере запущено несколько контейнеров, то они пользуются ресурсами этого компьютера совместно.
        Первая нормальная статья, где понятно объясняется, что такое Docker и зачем он нужен! Аллилуйя! :) Благодарю!
          0
          Docker — это инструмент, который позволяет создавать, разворачивать и запускать приложения с использованием контейнеров, независимых от операционной системы, на которой они выполняются. Контейнер включает в себя образ базовой ОС, необходимой для работы приложения, библиотеки, от которых зависит это приложение, и само это приложение.
          С созданием образа для Node.js приложения понятно, но непонятно, где и как создаются слои для первых 2-х пунктов: "образ базовой ОС и библиотеки, от которых зависит это приложение"? И нужно ли их создавать отдельно? Что значит фраза: «контейнеры, независимые от операционной системы»? Т.е. образ докера, например, с ОС Linux, можно запускать на машине с Windows или Mac? Или один дистрибутив Linux поверх другого, например, Ubuntu поверх SUSE?
            –1
            Я как-то объяснил человеку, что Docker, это такой FreeBSD Jail и он сразу всё понял.
              –1
              FreeBSD знаю, Jail — нет :)
                0
                Значит очень льстишь себе.
                  0

                  В чём?! Я просто знаю, что FreeBSD — юниксовая ОС — всё :) Про Jail первый раз вижу упоминание.

            0
            А что мешает один раз с помощью докера с нодой собрать node_modules, и потом пробрасывать волюмом в дефолтный нодовский контейнер, без строительства и кэшей?

            version: '3'
            services:
             apptestcontainer:
                image: node:10
                shm_size: '2gb'
                environment:
                  MOCHA_FILE: /test-reports/junit-report.xml
                volumes:
                  - ../app:/app
                  - ./config/config.yml:/app/config.yml
                  - ./test-reports:/test-reports
                networks:
                  backend: {}
                depends_on:
                  - another_app
                working_dir: /app
                command: ["npm", "test"]


            Вот тут можно через директивы для docker-compose указать и порты на экспозицию, и модули доставить, и даже дефолтную команду на старт указать.
              +2

              Обычно мешает то, что контейнеры выполняются в совершенно другом месте и тащить туда весь проект довольно проблематично.
              Ну и контейнеры работают не только в docker-compose

                0
                Dockerfile имеет смысл только в одном случае — при отправке на продакшен. И тогда да — я собираю контейнер, но всё ещё с использованием docker-compose. Просто потому что кучу всего можно и нужно указывать в виде кода, а не ключей для docker build. Но тогда теряется смысл в кэшировании сборки — у меня нет сильных ограничений на время сборки для продакшена — ± 5 минут на node_modules или там nuget — роли не сыграют.

                В остальном — локальная разработка, юнит-тестирование, интеграционное тестирование, selenium-тесты — всё это у меня крутится через docker-compose как локально, так и на CI.

                А ещё мне просто нравится yaml-разметка, и не нравятся длинные/сложные jenkins-пайплайны.
                  0

                  Тут ниже довольно детально уже написали
                  Общая мысль — кроме локальной разработки существует еще целый зоопарк решений для продакшена и там вольюмы никто никуда таскать не будет и зачастую какие-либо команды выполнять вообще нет возможности

                +1
                Пример с docker-compose подходит для локальной разработки и всяких CI/CD из говна и палок самописного разлива, он несет с собой ряд ограничений для продакшина:
                • Заметное снижение производительсности в определенных случаях (например, на MacOS) по причине не очень качественной реализации volumes
                • Для работоспособности необходимо рядом держать код. Т.е. его надо дополниельно туда положить и обновлять. В ряде случаев доступа к файловой системе вообще нет.
                • Непонятно, как ставить глобально пакеты, например curl


                Поэтому, для разворачивания во всяких k8s и прочих свормах необходим самодостаточный образ без всяких волюмов.

                UPD. Пока писал комментарий тут уже ответов понаписали
                0
                Из нашего опыта: использовали yarn для установки зависимостей в Dockerfile, получался очень большой размер образа.
                Оказалось что yarn даже с флагом --production устанавливает dev зависимости, в GitHub вроде даже есть соответствующий issue. Поменяли на npm, размер сократился в 3 раза.
                  0
                  RUN mkdir -p ${APP_DIR}
                  WORKDIR ${APP_DIR}

                  Здесь mkdir -p лишний, директива WORKDIR сама создает каталог

                  В теме оптимизации хотелось видеть информацию про образы на основе alpine или debian:*-slim
                    0
                    Вроде такого — nastytester.com/posts/docker-part2.html?
                      0
                      В Рамках статьи про node.js достаточно упомянуть про node:8-alpine и node:8-stretch-slim, указать разницу в размерах и описать накладываемые ограничения.
                    +1
                    Лучше бы про кеширование образов рассказали, как шарить те самые node_modules и прочее рассказали.
                      0
                      Лишняя запятая в package.json, новичкам может помешать изучить материал
                        0
                        Собрал по данной инструкции image, запустил, контейнер стартует и сразу останавливается не давая возможности обратиться через curl. Что я делаю не так?
                          0
                          Разобрался, в последней команде CMD [«npm», «run»] вместо run нужно start
                            0
                            В коде package.json опечатка: запятая после ''node index.js'' не нужна.
                            "scripts": {
                                "start": "node index.js",
                              },
                              0
                              Исправили, спасибо. На будущее, для сообщений об опечатках на Хабре есть специальная функция CTRL/CMD+Enter. Прочитать об этом можно здесь.

                            Only users with full accounts can post comments. Log in, please.