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

Архитектура, которую поощряет Docker — это изолированные контейнеры, каждый из которых выполняет одну команду. Эти контейнеры должны знать только как друг друга найти — другими словами, о контейнере нужно знать его fqdn и порт, или ip и порт, то есть, не более, чем о любой внешней службе.

Рекомендованный способ сообщить такие координаты внутрь процесса, выполняемого в Docker — переменные окружения. Типичный пример этого подхода, не применительно к докеру — DATABASE_URL, принятый во фреймворке Rails или NODE_ENV принятый в фрейворке Nodejs.

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

Docker, переменные окружения и ссылки


Docker приходит на помощь нам, когда мы хотим связать два контейнера и даёт нам механизм ссылкок (Docker links). Подробнее о них можно прочитать в руководстве на сайте самого Docker'а, но если вкратце, выглядит это так:

  1. Даём имя контейнеру при запуске: docker run -d --name db training/postgres. Теперь мы можем ссылаться на этот контейнер по имени db.
  2. Запускаем второй контейнер, связывая его с первым: docker run -d -P --name web --link db:db training/webapp python app.py. Самое интересное в этой строчке: --link name:alias. name — имя контейнера, alias — имя, под которым этот контейнер будет известен запускаемому.
  3. Это приведёт к двум последствиям: во-первых, в контейнере 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 делает в принципе то же самое, и она это уже делает. И этот читатель будет абсолютно прав. Пользователь, которому просто необходимо заставить заработать свою систему, наверняка предпочтет использовать уже готовое и проверенное решение, но ведь хабр не отличается массовостью таких пользователей. Именно поэтому и возник этот опус, который, как хороший анекдот, не только расказывает очевидные вещи, но еще и обучает.

Спасибо за внимание.

Комментарии (18)


  1. Mutineer
    11.06.2015 09:09

    А зачем докер записывает номер порта в имя переменной окружения? Ведь тогда чтобы узнать номер порта нужно уже знать номер порта, что как-то странно


    1. masterclass
      11.06.2015 09:27
      +2

      В докере встроенный механизм NAT, запись вида 5432:5432 подразумевает, что внешний и внутренний порт (на хосте и внутри контейнера) — 5432. Но возможны варианты что они будут не совпадать. Например, если на одном хосте запускаются два контейнера с базами postgresql — у обоих внутренние порты будут 5432, но внешние порты с хоста будут разные. Подробнее тут: docs.docker.com/reference/builder/#expose


      1. mickvav
        12.06.2015 00:55

        Эмм, а если в докере встроенный NAT, зачем тогда топикстартеру socat?


        1. aratak Автор
          12.06.2015 10:28

          Для того, чтобы те службы, которые запущены хрен знает где, были доступны на локалхосте. Другими словами описанным механизмом можно запустить приложение в докере даже если оно и не думало запускаться в докере и не читает настройки подключения к различным сервисам из переменных окружения, а тупо пытается подключиться к локахосту. Хотя, быть может, я не настолько знаком с технологией NAT, чтобы понять ваш вопрос до конца :)


          1. dvapelnik
            12.06.2015 17:41
            +1

            я с такими приложениями не сталкивался — для всех приложений есть конфиг, в котором указывается что куда должно подключаться (IP:port). актуальным использованием вижу такую ситуацию: вы ведете разработку веб-приложения, которое коннетктится к редису, мемкешу, какой-нибудь БД и деплоите это приложение на сервер, где у эти все сервисы запущены на локалхосте, и для того, чтобы было легко деплоить можно сокатом перекидывать всё со слинкованных контейнеров на локалхост главного контейнера — тогда приложение нормально будет деплоиться обычной выгрузкой файлов

            я о сокате не знал, а тепер знаю — за это спасибо


          1. mickvav
            13.06.2015 14:59

            Рискну предположить, что в просто линуксе можно попробовать сказать что-то вроде iptables -t nat -I PREROUTING -i lo -p tcp --dport ПОРТ -j DNAT --to-destination ХОСТ --to-port ПОРТНАХОСТЕ. Может, понадобится что-то ещё, например поменять SNAT- ом адрес отправителя в цепочке POSTROUTING, но тогда все манипуляции будут в ядре, что даст плюс в производительность решения. socat, на мой вкус — скорее быстрый в применении костыль для прототипирования, чем рабочая лошадка.


  1. Cheater
    11.06.2015 12:43

    Тезис «конфигурировать ПО надо через переменные окружения» — ОЧЕНЬ спорный.

    Зависимость приложения от переменных окружения гораздо менее прозрачная, чем от переменных текстового конфига или от входных параметров. В конфиге аккумулированы все переменные (как правило, там указывают абсолютно все параметры и ненужное закомментаривают), он более-менее самодокументирован; в случае же переменных окружения задача аккуратно изолировать их в одном месте ложится целиком на совесть разработчика, и мало кто решает её так, как надо. Гораздо чаще разработчик где-то в глубинах кода втыкает что-то вроде «if $MYENV=xxx», в особо тяжёлых случаях ещё и не документировав MYENV. Особенно этим страдают приложения на интерпретируемых языках.


    1. aratak Автор
      11.06.2015 13:00

      Кто вам мешает взять лучшее от двух миров?

      Создайте конфигурационный файл, который будет читать переменные окружения и создавать «более-менее самодокументированый» файл. С блекджеком.

      Вот вам достаточно говорящий пример `settings.yml` с erb-шаблонизатором:

      github:
        key: <%= ENV.fetch('GITHUB_ID', nil) %>
        secret: <%= ENV.fetch('GITHUB_SECRET', nil) %>
      
      facebook:
        key: <%= ENV.fetch('FACEBOOK_ID', nil) %>
        secret: <%= ENV.fetch('FACEBOOK_SECRET', nil) %>
      


      С таким конфигурационным файлом вы знаете все конфигурационные переменные, и все еще в состоянии настраивать приложение не вмешиваясь в исходный код оного. Так сказать, и волки и овцы.


      1. Cheater
        11.06.2015 13:20

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


  1. sllh
    11.06.2015 13:38

    Вообще это костыли, без нормального конфигурейшн-менеждера ни одно приложение не взлетит. Специально под докеры заточены www.ansible.com/home
    Все остальное — это «я написал сайт и запихну его в докер, потому что это модно».


    1. aratak Автор
      11.06.2015 17:00
      +3

      Не понимаю сути вашего комментария. Вы хотите сказать, что все, что не управляется шефом-ансиблем-паппетом, то не стоит внимания? Или у вас есть какое-нибудь конструктивное замечание?


  1. Nail
    11.06.2015 14:33
    +1

    Еще хуже дела обстоят, когда несколько серверов с докерами, там контейнеры просто так не залинкуешь.


    1. neo_13
      11.06.2015 15:05
      +2

      Отчего же?
      CoreOS использует
      Docker Weave
      Я думаю, такого «добра» достаточно.


      1. neo_13
        11.06.2015 15:58

        Неправильно линки оформил
        Исправлю положение
        CoreOS использует Flannel
        Weave отдельный проект
        Как по мне, Weave реальный кандидат для применения, для меня, по крайней мере.


    1. aratak Автор
      11.06.2015 17:08

      Дела с несколькими серверами обстоят точно так же как с докером, так и без докера. Вы же не жалуетесь на мультисерверность архитектуры в обычном случае? У вас всегда есть возможность запустить контейнер с выставленным портом в наружу и задача станет похожей на задачу управления несколькими серверами.

      Другое дело, что есть инструменты, которые позволяют создавать свою абстракцию в виде облака серверов, но это уже совсем другая история, которая выходит за рамки этой статьи.


    1. sandricmora
      11.06.2015 21:54
      +1

      Kubernetes скоро из беты выйдет, там нетворкинг вообще из коробки в виде сервисов, который полностью поддерживает спецификацию docker links. А вообще docker пишет свой libnetwork, coreos использует flanneld, существует отдельный weave, да и consul у hashicorp есть (это я про links.sh). Ambassador паттерн был актуален где то год назад. Статья немного запоздала imho (точнее не запоздала, а показала реализацию того, что все написали еще год назад).


  1. shuron
    11.06.2015 15:08

    Это типа вариант Ambassador pattern


  1. Eternalko
    12.06.2015 00:49
    +1

    Первая статья на хабре про докер которая о чем-то новом и интересном.