Как связать Docker-контейнеры, не заставляя приложение читать переменные окружения
Docker, если кто умудрился об этом ещё не слышать — фреймворк с открытым исходным кодом для управления контейнерной виртуализацией. Он быстрый, удобный, продуманный и модный. По сути он меняет правила игры в благородном деле управления конфигурацией серверов, сборки приложений, выполнения серверного кода, управления зависимостями и много ещё где.Архитектура, которую поощряет Docker — это изолированные контейнеры, каждый из которых выполняет одну команду. Эти контейнеры должны знать только как друг друга найти — другими словами, о контейнере нужно знать его fqdn и порт, или ip и порт, то есть, не более, чем о любой внешней службе.
Рекомендованный способ сообщить такие координаты внутрь процесса, выполняемого в Docker — переменные окружения. Типичный пример этого подхода, не применительно к докеру — DATABASE_URL, принятый во фреймворке Rails или NODE_ENV принятый в фрейворке Nodejs.
И вот переменные окружения позволяют приложению внутри контейнера удобно и непринуждённо найти базу данных. Но для этого, человек, который пишет приложение, должен об этом знать. И хотя конфигурация приложения с помощью переменных окружения — это хорошо и правильно, иногда приложения написаны плохо, а запускать их как-то надо.
Docker, переменные окружения и ссылкиDocker приходит на помощь нам, когда мы хотим связать два контейнера и даёт нам механизм ссылкок (Docker links). Подробнее о них можно прочитать в руководстве на сайте самого Docker’а, но если вкратце, выглядит это так: Даём имя контейнеру при запуске: docker run -d --name db training/postgres. Теперь мы можем ссылаться на этот контейнер по имени db. Запускаем второй контейнер, связывая его с первым: docker run -d -P --name web --link db: db training/webapp python app.py. Самое интересное в этой строчке: --link name: alias. name — имя контейнера, alias — имя, под которым этот контейнер будет известен запускаемому. Это приведёт к двум последствиям: во-первых, в контейнере web появится набор переменных окружения, указывающих на контейнер db, во-вторых в /etc/hosts контейнера web появится алиас db указывающий на ip, на котором мы запустили контейнер с базой данных. Набор переменных окружения, которые будут доступны в контейнере web вот такой: DB_NAME=/web/db DB_PORT=tcp://172.17.0.5:5432 DB_PORT_5432_TCP=tcp://172.17.0.5:5432 DB_PORT_5432_TCP_PROTO=tcp DB_PORT_5432_TCP_PORT=5432 DB_PORT_5432_TCP_ADDR=172.17.0.5 И если приложение отчаянно не готово читать такие вот переменные, то на помощь нам придёт консольная утилита socat.
socat socat — это утилита Unix для перенаправления портов. Идея состоит в том, что с её помощью мы создадим у приложения внутри контейнера впечатление, что, например, база данных, запущена в том же контейнере на том же хосте и на своём стандартном порту, как это происходит на компьютере у разработчика. socat, как всё низкоуровневое юниксовое очень легковесный и ничем не отяготит основной процесс контейнера.Давайте внимательнее посмотрим на переменные окружения, которые пробрасывает внутрь контейнера механизм ссылок. Нас особенно интересует одна из них: DB_PORT_5432_TCP=tcp://172.17.0.5:5432. В этой переменной есть все данные, которые нам нужны: порт, который надо бы слушать на localhost (5432 в DB_5432_TCP) и координаты самой базы данных (172.17.0.5:5432).
Такая переменная будет проброшена в контейнер для каждой переданной ссылки: базы данных, Redisа, вспомогательного сервиса.
Мы напишем скрипт, который будет оборачивать любую команду следующим образом: просканировать список переменных окружения в поисках интересующих нас, для каждой запустить socat, потом запустить переданную команду и отдать управление. Когда скрипт закончится, он должен завершить все socat процессы.
Скрипт Стандартный заголовок. set -e инструктирует shell при первой же ошибке завершать скрипт, то есть, требует привычного программисту поведения. #!/bin/bash
set -e Поскольку мы будем порождать дополнительные процессы socat, нам нужно будет за ними следить, чтобы можно было потом их завершить и подождать их завершения. store_pid () { pids=(»${pids[@]}» »$1») } Теперь мы можем написать функцию, которая будет порождать нам дочерние процессы, которые мы можем запоминать. start_command () { echo «Running $1» bash -c »$1» & pid=»$!» store_pid »$pid» }
start_commands () { while read cmd; do start_command »$cmd» done } Основа идеи в том, чтобы из набора переменных окружения, заканчивающихся на _TCP вытянуть кортежи (целевой_порт, адрес_источника, порт_источника) и превратить их в набор команд запуска socat. to_link_tuple () { sed 's/.*_PORT_\([0–9]*\)_TCP=tcp:\/\/\(.*\):\(.*\)/\1,\2,\3/' }
to_socat_call () { sed 's/\(.*\),\(.*\),\(.*\)/socat -ls TCP4-LISTEN:\1, fork, reuseaddr TCP4:\2:\3/' }
env | grep '_TCP=' | to_link_tuple | sort | uniq | to_socat_call | start_commands env выведет список переменных окружения, grep оставит только нужные, to_link_tuple вытянет нужные нам тройки, sort | uniq предотвратит запуск двух socatов для одной службы, to_socat_call уже создаст нужную нам команду.Мы ещё хотели завершать дочерние процессы socat, когда завершится основной процесс. Мы сделаем это посылкой сигнала SIGTERM.
onexit () { echo Exiting echo sending SIGTERM to all processes kill ${pids[*]} &>/dev/null } trap onexit EXIT Запускаем основной процесс командой exec. Тогда управление будет передано ему, мы будем видеть его STDOUT и он станет получать сигналы STDINа. exec »$*» Весь скрипт можно посмотреть одним куском.И что? Подкладываем этот скрипт в контейнер, например, в /run/links.sh и запускаем контейнер теперь вот так: $ docker run -d -P --name web --link db: db training/webapp /run/links.sh python app.py Вуаля! В контейнере на 127.0.0.1 на порту 5432 будет доступен наш постгрес.Entrypoint Чтобы не нужно было помнить про наш скрипт, образу можно задать точку входа директивой ENTRYPOINT в Dockerfile’е. Это приведёт к тому, что любая команда, запущенная в таком образе, будет сначала дополнена префиксом в виде этой точки входа.Добавьте в ваш Dockerfile:
ADD ./links.sh /run/links.sh ENTRYPOINT [»/run/links.sh»] и снова контейнер можно будет запускать просто передавая ему команды и быть уверенным в том, что службы из связанных контейнеров будут видны приложению, как будто они запущены на локалхосте.А если доступа в образ нет? В связи с вышеизложенным есть интересная задачка: как сделать такое же удобное проксирование служб, если нет доступа внутрь образа? Ну то есть, нам дают образ и клянутся, что внутри есть socat, но нашего скрипта там нет и подложить его мы не можем. Зато запускающую команду можем сделать сколь угодно сложной. Как нам пробросить внутрь свой wrapper? На помощь приходит возможность пробросить частичку файловой системы хоста внутрь контейнера. Другими словами, мы можем на файловой системе хоста сделать, например, папку /usr/local/docker_bin, положить туда links.sh и запускать контейнер вот так:
$ docker run -d -P \ --name web \ --link db: db training/webapp \ -v /usr/local/docker_bin:/run: ro \ /run/links.sh python app.py В результате любые скрипты, которые мы положим в /usr/local/docker_bin будут доступны внутри контейнера для запуска.Обратите внимание, что мы использовали флаг ro, не дающий контейнеру возможность писать в папку /run.
Альтернативным вариантом было бы отнаследоваться от образа и просто добавить туда файлы.
Итого С помощью socatа и доброго слова можно добиться намного более удобного способа связи между контейнерами, чем с помощью одного только доброго слова.Вместо послесловия Внимательный и искушенный читатель наверняка подметит, что библиотека ambassadord делает в принципе то же самое, и она это уже делает. И этот читатель будет абсолютно прав. Пользователь, которому просто необходимо заставить заработать свою систему, наверняка предпочтет использовать уже готовое и проверенное решение, но ведь хабр не отличается массовостью таких пользователей. Именно поэтому и возник этот опус, который, как хороший анекдот, не только расказывает очевидные вещи, но еще и обучает.Спасибо за внимание.