В прошлый раз мы написали аж три примера клиент-сервер Node.js приложений, в которых компоненты общаются между собой через ZeroMQ. Примеры простые, показывают основную идею, но работают всё-таки на localhost, и поэтому слабо пересекаются с реальностью, в которой ZeroMQ обычно работает. И тогда я подумал, а почему бы не раскидать клиента и сервера по Docker-контейнерам? В задачу добавятся новые вопросы, она станет больше похожа на правду, и главное, это отличный шанс откатать сразу несколько инструментов, которые обычно используются вместе.
Итак, задача на сегодня: взять fire-and-forget паттерн из прошлого поста и доработать его до контейнерного приложения.
План
Во-первых, стоит вспомнить код, над которым мы будем глумиться. Чуууть-чуть видоизмененный сервер:
1 2 3 4 5 6 7 8 9 |
const socket = require(`zmq`).socket(`push`); // Create PUSH socket socket.bindSync(`tcp://127.0.0.1:3000`); // Bind to localhost:3000 setInterval(function () { const message = `Ping!`; console.log(`Sending '${message}'`); socket.send(message); // Send message once per 2s }, 2000); |
И клиент:
1 2 3 4 5 6 7 |
const socket = require(`zmq`).socket(`pull`); // Create PULL socket socket.connect(`tcp://127.0.0.1:3000`); // Connect to same address socket.on(`message`, function (msg) { // On message, log it console.log(`Message received: ${msg}`); }); |
Сервер отправляет сообщение «Ping» раз в две секунды, а клиент с благодарностью получает. Не совсем шедевр, но для примера подойдёт. С ходу, вот, что придётся доработать:
- Первая очевидная проблема — статически заданный IP адрес. 127.0.0.1 внутри контейнера — это прямой билет в одиночество. Даже если я соглашусь оставить 3000 порт и скажу серверу «слушать» на всех сетевых интерфейсах ( tcp://*:3000 ), всё равно остаётся клиент, которого нужно куда-то отправить. Другими словами, адрес нужно параметризировать.
- Чтобы положить что-то в контейнер, нужно сначала сделать из него образ. А чтобы сделать образ, нужен Dockerfile. В нашем случае — два. Сами они себя не сделают, так что придётся заняться.
- Чтобы передать клиенту адрес сервера, этот адрес нужно сначала как-то узнать. А он — динамический. С другой стороны, если дать контейнерам внятные имена и подключить их к пользовательской сети, то про IP можно забыть и общаться просто по имени. Волею богов контейнеризации, есть docker-compose, который и имя задаст, и к сети подключит. Конечно, всё это можно было бы сделать и руками, но зачем?
Итак, приступим.
Параметризируем клиентский адрес для подключения
Контейнера, Docker и docker-compose очень легко передают друг другу переменные среды (environmental variables). С другой стороны, прочитать эти переменные из Node.js — вообще не проблема. Всё это выглядит как отличный способ передать адрес сервера клиенту. Добавим переменную, какой-нибудь console.log для отладки, и готово:
1 2 3 4 5 6 7 8 9 |
const socket = require(`zmq`).socket(`pull`); const address = process.env.ZMQ_PUB_ADDRESS || `tcp://127.0.0.1:3000`; console.log(`Connecting to ${address}`); socket.connect(address); socket.on(`message`, function (msg) { console.log(`Message received: ${msg}`); }); |
На всякий случай сделаем то же самое для сервера. Вдруг пригодится:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const socket = require(`zmq`).socket(`push`); const address = process.env.ZMQ_BIND_ADDRESS || `tcp://*:3000`; console.log(`Listening at ${address}`); socket.bindSync(address); const sendMessage = function () { const message = `Ping!`; console.log(`Sending '${message}'`); socket.send(message); }; setInterval(sendMessage, 2000); |
И… всё. Приложение готово.
Создаём Dockerfile
Если кто забыл, Dockerfile описывает шаги, как создать новый образ. Прежде чем мы начнём колдовать, стоит раскидать клиента и сервера по отдельным папкам, чтобы они не мешались друг у друга под ногами. Например, так:
Вообще Dockerfile будет вполне себе тривиальным: берём образ с node, кладём в него файлы приложения, устанавливаем зависимости, открываем порт и запускаем клиента/сервера на старте. Для сервера у меня вышло вот что:
1 2 3 4 5 6 7 8 9 |
FROM node COPY ./app /app WORKDIR /app RUN rm -rf node_modules && \ apt-get update -qq && \ apt-get install -y -qq libzmq-dev && \ npm install --silent EXPOSE 3000 CMD ["node", "/app/server.js"] |
Установка зависимостей ( RUN ... ) — единственный относительно сложный шаг. Во-первых, я хочу уже в самом контейнере переустановить все NPM модули. Для этого я удаляю существующие (первая строка RUN) и устанавливаю их заново (четвертая). Затем, под линуксом у ZeroMQ есть зависимость — libzmq-dev , но так просто её не поставить, потому что node образ настолько пустой внутри, что apt-get не в курсе, откуда устанавливать пакеты, так что его самого надо сначала обновить. Для этого — вторая и третья строка. Попробуем всё собрать и запустить:
1 2 3 4 5 6 7 8 9 10 |
$ docker build -t zmqserver:latest . # Sending build context to Docker daemon 2.276 MB # Step 1 : FROM node # ---> 9873603dc506 # ......... # Successfully built f1da9ff9cf85 $ docker run zmqserver # Listening at tcp://*:3000 # Sending 'Ping' # Sending 'Ping' |
Вроде работает. Чтобы сделать клиентский Dockerfile, нужно взять серверный и заменить server.js на client.js. Пусть это будет домашним заданием.
Собираем docker-compose.yml файл
docker-compose работает со стайкой контейнеров как с одним большим приложением и хранит его конфигурацию в docker-compose.yml файле. Всё, что нам нужно, это создать файл, задать имена контейнеров, значения адресов для подключения и ссылки на Dockerfile. С подключением контейнеров к пользовательской сети ничего делать не надо — compose делает это автоматически:
1 2 3 4 5 6 7 8 9 10 |
version: '2' services: client: build: ./client/ environment: - ZMQ_PUB_ADDRESS=tcp://server:3000 server: build: ./server/ environment: - ZMQ_BIND_ADDRESS=tcp://*:3000 |
Вообще ничего сложного. Кладём конфигурацию в корень проекта и запускаем:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ docker-compose up # Building client # ......... # WARNING: Image for service client was built because it did not already exist... # Building server # ......... # WARNING: Image for service server was built because it did not already exist... # Creating fireandforget_client_1 # Creating fireandforget_server_1 # Attaching to fireandforget_client_1, fireandforget_server_1 # client_1 | Connecting to tcp://server:3000 # server_1 | Listening at tcp://*:3000 # server_1 | Sending 'Ping' # client_1 | Message received: Ping |
Ха! Древняя чёрная магия всё еще действует. Что действительно круто: если мне захочется добавить еще клиентов и серверов, чтобы посмотреть, как они будут взаимодействовать, я просто добавлю пару строк в docker-compose.yml и перезапущу его.
Мораль Итог Капитан Очевидность
Мы быстро прошлись по тому, как сконвертировать «обычное» ZeroMQ приложение в контейнерное. Всего-то нужно было параметризировать адреса подключения, создать Dockerfile для клиента и сервера и конфигурацию для docker-compose. Можно было бы обойтись и без последнего, но тогда бы пришлось руками создавать и удалять сеть, подключать к ней контейнеры, а это выливается к длиннющие shell команды и вообще скучно. Посмотреть код примера целиком можно тут.