Docker: гибкая сеть без NAT на все случаи жизни
Время на месте не стоит, и у горячо любимого всеми Docker от версии к версии появляется новый функционал. Случается так, что когда читаешь Changelog для новой версии, видишь там то, что может пригодиться и сделать какие-то вещи лучше, чем есть на данный момент.
Так дело обстояло и в моем случае. Хочу заметить, что многие задачи, которые приходится делать, я делаю по принципу keep it simple. То есть почти всегда, если для решения задачи можно использовать простые инструменты и шаги, я выберу этот путь. Я понимаю, что простой или сложный шаг или инструмент — оценка субъективная, но т.к. работаем мы в команде, то вот такие критерии могут подходить при выборе инструментов:
- используется ли инструмент в инфраструктуре?
- если требуется что-то новое, то нельзя ли использовать то, что уже есть?
- насколько сильно обслуживание (обновление, перезапуск) сервиса будет отличаться от остальных сервисов?
- <...>
В этой статье речь пойдет о сетевом аспекте Docker. Расскажу обо всем по порядку, но хочу заметить, что на этот раз я не буду говорить «мы используем сеть хоста, всячески избегая применения NAT».
О том, как Docker работает с сетью, можно ознакомиться по ссылке. Выделим основные моменты:
- default bridge network;
- host network;
- user defined network.
Как я говорил ранее в некоторых своих публичных выступлениях, нам нужна максимальная производительность сети для наших контейнеров. Если говорить о production, то NAT для контейнеров мы не используем.
Долгое время (да чего уж скрывать — и по сей день) для запуска контейнеров мы используем параметр --net=host, получая тем самым «нативный» eth внутри контейнера. Да, в этом случае одного бенефита — изоляции — мы, конечно, лишаемся… Но посмотрев на плюсы и минусы в нашем конкретном случае, мы намеренно пришли к такому решению, т.к. задачи изолировать сеть между запущенными приложениями в рамках одного хоста не стояло. Хочу напомнить, что пишу я о конкретном месте применения Docker — в Badoo.
Что мы знаем про наши сервисы:
- у нас есть карта размещения сервисов на серверах;
- у нас есть карта портов для каждого сервиса и его типа (или типов, если их много);
- у нас есть договоренность о том, что порты должны быть уникальными.
Исходя из вышесказанного, мы гарантируем следующее:
- если мы запускаем несколько сервисов на одной машине с --net=host, то пересечения портов мы не получим: все запустится и будет работать;
- если нам станет мало одного eth-интерфейса, то мы физически подключим еще один и посредством, например, DNS, раскидаем нагрузку между ними.
Все хорошо, тогда зачем мне пришлось что-то менять?
Дело было вечером, делать было нечего… Ранее говорилось о том, что мы продолжаем переносить наши сервисы в контейнеры. При следовании такому сценарию, как это обычно бывает, самые сложные остаются на потом. Причин для этого может быть масса:
- сервис критичен;
- отсутствие достаточного опыта, чтобы предложить максимально прозрачный и быстрый переезд сервиса в контейнер;
- можно «добавить что-то еще по вкусу».
Ну так вот. Был (и есть) у нас один такой сервис, который давно написан. Он и по сей день отлично работает, но есть у него несколько минусов:
- работает он на одном ядре (да, такое бывает);
- восполняя первый пробел, стоит отметить, что можно запустить несколько инстансов сервиса и использовать taskset/--cpuset-cpus;
- сервис «сильно» использует сеть, а также ему требуется большое количество портов для исходящих соединений.
Вот так сервис запускался ДО:
- на машине, где планировалось поднятие сервиса, нужно было добавить дополнительный IP-адрес (или даже несколько) — ip a add (тут можно сразу указать на много минусов данного подхода, о которых мы знаем);
- про вышесказанное стоит помнить, чтобы не получить, к примеру, 2 одинаковых адреса на разных машинах;
- в конфигурации демона стоило указать, на каком адресе он работает, как раз чтобы не «съесть» все порты соседа или хост-системы.
Как можно было решить задачу, если бы было лень изобретать новые методы:
- оставить все как есть, но обернуть в контейнер;
- поднимать на dockerhost все те же дополнительные IP-адреса;
- «биндить» приложение на конкретный адрес.
Как к задаче решил подойти я? Изначально, конечно, это все выглядело как эксперимент, да чего скрывать — это и было экспериментом. Мне показалось, что для этого сервиса как нельзя кстати подойдет MACVLAN-технология, которая на тот момент была отмечена в Docker как Experimental (версия 1.11.2), а вот в версии 1.12 всё уже доступно в основном функционале.
MACVLAN — это, по сути, Linux switch, который основан на статичном соответствии MAC и VLAN. Здесь используется unicast-фильтрация, не promiscuous-режим. MACVLAN может работать в режиме private, VEPA, bridge, passthru. MACVLAN — это reverse VLAN в Linux. Данная технология позволяет взять один реальный интерфейс и сделать на его основе несколько виртуальных с разными MAC-адресами.
Также сравнительно недавно появилась технология IPVLAN (https://www.kernel.org/doc/Documentation/networking/ipvlan.txt). Основное отличие от MACVLAN заключается в том, что IPVLAN может работать в L3 mode. В данной статье я буду рассматривать вариант использования MACVLAN (в режиме bridge), потому что:
- не стоит ограничение в 1 MAC-адрес с одного линка на активном сетевом оборудовании;
- количество контейнеров на хосте не будет таким большим, что может привести к превышению mac capacity. С течением времени этот момент у нас может, конечно, измениться;
- на данном этапе L3 не нужен.
Чуть подробнее про MACVLAN vs IPVLAN можно ознакомиться по ссылке http://hicu.be/macvlan-vs-ipvlan.
Вот здесь можно почитать теорию и как это работает в Docker: https://github.com/docker/docker/blob/master/experimental/vlan-networks.md.
Теория — это отлично, но даже там мы видим, что overhead имеет место быть. Можно и нужно посмотреть на сравнительные тесты пропускной способности MACVLAN в интернете (например, http://comp.photo777.org/docker-network-performance/ и http://delaat.net/rp/2014–2015/p92/report.pdf), но также неотъемлемой частью эксперимента является проведение теста в своих лабораторных условиях. Поверить на слово — хорошо, но «потрогать руками» и сделать выводы самому — интересно и необходимо.
Итак, поехали!Для того чтобы проверить, работает ли MACVLAN в Docker, нам нужно включить в последнем поддержку экспериментальных функций.
Если данный функционал при сборке не включен, то в логах можно будет видеть вот такие сообщения об ошибках:
# docker network create -d macvlan --subnet=1.1.1.0/24 --gateway=1.1.1.1 -o parent=eth0 cppbig_vlan
Error response from daemon: plugin not found
А в логах процесса будет следующее:
docker[2012]: time="2016-08-04T11:44:44.095241242Z" level=warning msg="Unable to locate plugin: macvlan, retrying in 1s"
docker[2012]: time="2016-08-04T11:44:45.095489283Z" level=warning msg="Unable to locate plugin: macvlan, retrying in 2s"
docker[2012]: time="2016-08-04T11:44:47.095750785Z" level=warning msg="Unable to locate plugin: macvlan, retrying in 4s"
docker[2012]: time="2016-08-04T11:44:51.095970433Z" level=warning msg="Unable to locate plugin: macvlan, retrying in 8s"
docker[2012]: time="2016-08-04T11:44:59.096197565Z" level=error msg="Handler for POST /v1.23/networks/create returned error: plugin not found"
Если вы видите такие сообщения — значит, поддержка MACVLAN в Docker не включена.
Тест был символическим, с использованием iperf. Для каждого варианта я запускал сначала 1 клиента, потом 8 в параллель. Вариантов было 2:
- --net=host;
- --net=macvlan.
# docker run -it --net=host --name=iperf_w_host_net --entrypoint=/bin/bash dockerio.badoo.com/itops/sle_12_base:latest
# iperf3 -s -p 12345
-----------------------------------------------------------
Server listening on 12345
-----------------------------------------------------------
Запускаем клиента:
# iperf3 -c 1.1.1.2 -p 12345 -t 30
На сервере получаем результат:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth
[ 5] 0.00-30.04 sec 0.00 Bytes 0.00 bits/sec sender
[ 5] 0.00-30.04 sec 2.45 GBytes 702 Mbits/sec receiver
На клиенте:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth Retr
[ 4] 0.00-30.00 sec 2.46 GBytes 703 Mbits/sec 0 sender
[ 4] 0.00-30.00 sec 2.45 GBytes 703 Mbits/sec receiver
Запускаем в параллель 8 клиентов:
# iperf3 -c 1.1.1.2 -p 12345 -t 30 -P 8
На сервере получаем результат:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth
[ 5] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 5] 0.00-30.03 sec 314 MBytes 87.7 Mbits/sec receiver
[ 7] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 7] 0.00-30.03 sec 328 MBytes 91.5 Mbits/sec receiver
[ 9] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 9] 0.00-30.03 sec 305 MBytes 85.2 Mbits/sec receiver
[ 11] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 11] 0.00-30.03 sec 312 MBytes 87.3 Mbits/sec receiver
[ 13] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 13] 0.00-30.03 sec 316 MBytes 88.3 Mbits/sec receiver
[ 15] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 15] 0.00-30.03 sec 310 MBytes 86.7 Mbits/sec receiver
[ 17] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 17] 0.00-30.03 sec 313 MBytes 87.5 Mbits/sec receiver
[ 19] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 19] 0.00-30.03 sec 321 MBytes 89.7 Mbits/sec receiver
[SUM] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[SUM] 0.00-30.03 sec 2.46 GBytes 704 Mbits/sec receiver
На клиенте:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth Retr
[ 4] 0.00-30.00 sec 315 MBytes 88.1 Mbits/sec 0 sender
[ 4] 0.00-30.00 sec 314 MBytes 87.8 Mbits/sec receiver
[ 6] 0.00-30.00 sec 330 MBytes 92.3 Mbits/sec 0 sender
[ 6] 0.00-30.00 sec 328 MBytes 91.6 Mbits/sec receiver
[ 8] 0.00-30.00 sec 306 MBytes 85.6 Mbits/sec 0 sender
[ 8] 0.00-30.00 sec 305 MBytes 85.3 Mbits/sec receiver
[ 10] 0.00-30.00 sec 313 MBytes 87.5 Mbits/sec 0 sender
[ 10] 0.00-30.00 sec 312 MBytes 87.4 Mbits/sec receiver
[ 12] 0.00-30.00 sec 317 MBytes 88.8 Mbits/sec 0 sender
[ 12] 0.00-30.00 sec 316 MBytes 88.4 Mbits/sec receiver
[ 14] 0.00-30.00 sec 312 MBytes 87.1 Mbits/sec 0 sender
[ 14] 0.00-30.00 sec 310 MBytes 86.8 Mbits/sec receiver
[ 16] 0.00-30.00 sec 314 MBytes 87.9 Mbits/sec 0 sender
[ 16] 0.00-30.00 sec 313 MBytes 87.6 Mbits/sec receiver
[ 18] 0.00-30.00 sec 322 MBytes 90.2 Mbits/sec 0 sender
[ 18] 0.00-30.00 sec 321 MBytes 89.8 Mbits/sec receiver
[SUM] 0.00-30.00 sec 2.47 GBytes 707 Mbits/sec 0 sender
[SUM] 0.00-30.00 sec 2.46 GBytes 705 Mbits/sec receiver
2. Запускам сервер, используя MACVLAN:
# docker run -it --net=cppbig_vlan --name=iperf_w_macvlan_net --ip=1.1.1.202 --entrypoint=/bin/bash dockerio.badoo.com/itops/sle_12_base:latest
# iperf3 -s -p 12345
-----------------------------------------------------------
Server listening on 12345
-----------------------------------------------------------
Запускаем клиента:
# iperf3 -c 1.1.1.202 -p 12345 -t 30
На сервере получаем результат:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth
[ 5] 0.00-30.04 sec 0.00 Bytes 0.00 bits/sec sender
[ 5] 0.00-30.04 sec 2.45 GBytes 701 Mbits/sec receiver
На клиенте:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth Retr
[ 4] 0.00-30.00 sec 2.46 GBytes 703 Mbits/sec 0 sender
[ 4] 0.00-30.00 sec 2.45 GBytes 702 Mbits/sec receiver
Запускаем в параллель 8 клиентов:
# iperf3 -c 1.1.1.202 -p 12345 -t 30 -P 8
На сервере получаем результат:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth
[ 5] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 5] 0.00-30.03 sec 306 MBytes 85.4 Mbits/sec receiver
[ 7] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 7] 0.00-30.03 sec 319 MBytes 89.1 Mbits/sec receiver
[ 9] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 9] 0.00-30.03 sec 307 MBytes 85.8 Mbits/sec receiver
[ 11] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 11] 0.00-30.03 sec 311 MBytes 87.0 Mbits/sec receiver
[ 13] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 13] 0.00-30.03 sec 317 MBytes 88.6 Mbits/sec receiver
[ 15] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 15] 0.00-30.03 sec 322 MBytes 90.1 Mbits/sec receiver
[ 17] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 17] 0.00-30.03 sec 313 MBytes 87.5 Mbits/sec receiver
[ 19] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[ 19] 0.00-30.03 sec 310 MBytes 86.7 Mbits/sec receiver
[SUM] 0.00-30.03 sec 0.00 Bytes 0.00 bits/sec sender
[SUM] 0.00-30.03 sec 2.45 GBytes 700 Mbits/sec receiver
На клиенте:
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth Retr
[ 4] 0.00-30.00 sec 307 MBytes 85.8 Mbits/sec 0 sender
[ 4] 0.00-30.00 sec 306 MBytes 85.5 Mbits/sec receiver
[ 6] 0.00-30.00 sec 320 MBytes 89.6 Mbits/sec 0 sender
[ 6] 0.00-30.00 sec 319 MBytes 89.2 Mbits/sec receiver
[ 8] 0.00-30.00 sec 308 MBytes 86.2 Mbits/sec 0 sender
[ 8] 0.00-30.00 sec 307 MBytes 85.9 Mbits/sec receiver
[ 10] 0.00-30.00 sec 313 MBytes 87.5 Mbits/sec 0 sender
[ 10] 0.00-30.00 sec 311 MBytes 87.1 Mbits/sec receiver
[ 12] 0.00-30.00 sec 318 MBytes 89.0 Mbits/sec 0 sender
[ 12] 0.00-30.00 sec 317 MBytes 88.6 Mbits/sec receiver
[ 14] 0.00-30.00 sec 324 MBytes 90.5 Mbits/sec 0 sender
[ 14] 0.00-30.00 sec 322 MBytes 90.2 Mbits/sec receiver
[ 16] 0.00-30.00 sec 314 MBytes 87.9 Mbits/sec 0 sender
[ 16] 0.00-30.00 sec 313 MBytes 87.6 Mbits/sec receiver
[ 18] 0.00-30.00 sec 311 MBytes 87.1 Mbits/sec 0 sender
[ 18] 0.00-30.00 sec 310 MBytes 86.8 Mbits/sec receiver
[SUM] 0.00-30.00 sec 2.46 GBytes 704 Mbits/sec 0 sender
[SUM] 0.00-30.00 sec 2.45 GBytes 701 Mbits/sec receiver
Как видно из результатов, overhead есть, но в данном случае можно считать его не критичным.
Ограничения технологии by design: доступность контейнера с хоста и доступность хоста из контейнера отсутствует. Нам такой функционал необходим, потому что:
- часть проверок доступности сервиса проверяется «хелперами» Zabbix, которые выполняются на том хосте, где работает сервис;
- есть необходимость использовать кеширующий DNS, который расположен на хост-системе. В нашем случае это Unbound;
- бывает необходимость использовать доступ к еще каким-то сервисам, запущенным на хост-системе.
- Это лишь часть причин, по которым доступ «хост <==> контейнер» нам необходим. Взять и переделать архитектуру подобных узлов в одночасье невозможно.
Варианты преодоления данного ограничения:
- Использовать два и более физических линка на машине. Это дает возможность взаимодействия через соседний интерфейс. Например, взять eth1 и отдать его специально для MACVLAN, а на хост-системе продолжать использовать eth0. Вариант, безусловно, неплохой, но это влечет за собой необходимость держать одинаковое количество линков на всех машинах, где мы планируем запускать подобные сервисы. Реализовать это дорого, не быстро и не всегда возможно.
- Использовать еще один дополнительный IP-адрес на хост-системе, повесить его на виртуальный MACVLAN-интерфейс, который нужно поднять на хост-системе. Это приблизительно так же сложно с точки зрения поддержки («не забыть/не забывать»), как предыдущее предложение — это раз. А, так как ранее я говорил о том, что наш сервис сам по себе требует дополнительного адреса, то в итоге для запуска такого сервиса нам потребуется:
- адрес для основного интерфейса хост-системы (1);
- адрес для сервиса (2);
- адрес для виртуального интерфейса, через который мы будем взаимодействовать с сервисом (3).
В данном случае получается, что нам нужно слишком много IP-адресов, которые, по большому счету, использоваться будут мало. Помимо излишнего расходования IP-адресов стоит также помнить, что нам нужно будет поддерживать статичные маршруты через этот самый виртуальный интерфейс до контейнера. Это не непреодолимая сложность, но усложнение системы в целом — факт.Внимательный читатель задаст вопрос: «А зачем адрес на основной интерфейс и на MACVLAN-интерфейс, если можно адрес основного интерфейса отдать виртуальному?» В таком случае мы оставим нашу систему без адресов на реальных интерфейсах, а на такой шаг я пойти пока не готов.
В предыдущих двух вариантах предполагалось, что адреса всех интерфейсов принадлежат одной сети. Как несложно представить, даже при 100 серверах в такой подсети, если завести по три адреса, то в /24 мы уже не попадаем.
- Сервисный IP. Суть идеи заключается в том, что мы делаем отдельную подсеть для сервисов. Как это выглядит:
- на сервер начинаем подавать «тегированный» траффик;
- native VLAN оставляем в виде основной сети для dockerhost (eth0);
- поднимаем виртуальный интерфейс с 802q, без IP-адреса на хост системе;
- используем для сервиса IP-адрес из сервисной сети.
Рассматривать, как уже стало понятно, будем пункт три. Чтобы все у нас заработало, нужно проделать несколько действий:
- для того чтобы подать «тегированный» трафик на интерфейс, кто нам нужен? Правильно, сетевики! Просим их выполнить переключение access-порта в порт, на который подаем 2 VLAN;
- поднять дополнительный интерфейс на хосте:
# cat /etc/sysconfig/network/ifcfg-vlan8 BOOTPROTO='static' STARTMODE='auto' VLAN='yes' ETHERDEVICE='eth0' # ip -d link show vlan8 31: vlan8@eth0:
mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000 link/ether e4:11:5b:ea:b6:30 brd ff:ff:ff:ff:ff:ff promiscuity 1 vlan protocol 802.1Q id 8 - завести MACVLAN-сеть в Docker
# docker network create -d macvlan --subnet=1.1.2.0/24 --gateway=1.1.2.1 -o parent=vlan8 c_services
- убедиться, что сеть в Docker появилась:
# docker network ls | grep c_services a791089219e0 c_services macvlan
Все сделал, все хорошо. Тут я решил посмотреть на общие графики по хосту (а если быть точнее, то на это обратил мое внимание коллега). Вот такую картину мы там увидели:
Да, здесь видно использование conntrack на хосте.
Как так? Ну не нужен же conntrack для МACVLAN?! Так как дело было уже вечером, я решил проверить даже самые невероятные теории. В подтверждение моих теоретических знаний, connection tracking был на самом деле не нужен. Без него все продолжало работать. Выгрузка модулей, так или иначе завязанных на conntrack, была невозможной только в момент запуска моего контейнера. Идеи меня покинули, и пошел я домой, решив, что утро вечера мудренее.
На следующий день я в очередной раз убедился в точности данного высказывания. Итак, я решил «топорным» методом сделать так, чтобы Docker не мог подгружать nf_conntrack. Сначала я его просто переименовал (т.к. blacklist игнорируется при загрузке модуля через modprobe), после чего запустил свой контейнер снова. Контейнер, как и ожидалось, поднимался и отлично себя чувствовал, но в логе я увидел сообщения о том, что четыре правила не могут быть добавлены в iptables. Получается, что conntrack нужен? Вот правила, которые не хотели добавляться:
-t nat -A OUTPUT -d 127.0.0.11 -p udp --dport 53 -j DNAT --to-destination 127.0.0.11:35373
-t nat -A POSTROUTING -s 127.0.0.11 -p udp --sport 35373 -j SNAT --to-source :53
-t nat -A OUTPUT -d 127.0.0.11 -p tcp --dport 53 -j DNAT --to-destination 127.0.0.11:41214
-t nat -A POSTROUTING -s 127.0.0.11 -p tcp --sport 41214 -j SNAT --to-source :53
53 порт? Налицо работа, связанная с «резолвером». И тут я, к своему удивлению, узнаю про embedded DNS server. Ну хорошо, пусть и встроенный, но можно же как-то опциями выключить его? Нет, нельзя :)
Далее я попробовал вернуть модуль, запустить сервис, поправить правила из iptables и выгрузить модули… Но не тут то было. Путем ковыряния modinfo я выяснил, какой там модуль от какого зависит, и какой из них кого тянет за собой. При создании сети Docker принудительно делает modprobe xt_nat, который, в свою очередь, зависит от nf_conntrack, вот тому подтверждение:
# modinfo xt_nat
filename: /lib/modules/4.4.0-3.1-default/kernel/net/netfilter/xt_nat.ko
alias: ip6t_DNAT
alias: ip6t_SNAT
alias: ipt_DNAT
alias: ipt_SNAT
author: Patrick McHardy
license: GPL
srcversion: 9982FF46CE7467C8F2361B5
depends: x_tables,nf_nat
intree: Y
vermagic: 4.4.0-3.1-default SMP preempt mod_unload modversions
Как я уже говорил, все работает и без этих модулей. Соответственно, мы можем сделать вывод, что в нашем случае они не нужны. Остается вопрос: зачем, все же, они нужны? Я не поленился и заглянул в 2 места:
- на Docker issues;
- в исходный код.
И что я там нашел? Верно: для любого user defined network Docker делает modprobe. Смотрим код и видим 2 интересных для нас пункта:
if out, err := exec.Command("modprobe", "-va", "nf_nat").CombinedOutput(); err != nil {
logrus.Warnf("Running modprobe nf_nat failed with message: `%s`, error: %v", strings.TrimSpace(string(out)), err)
}
if out, err := exec.Command("modprobe", "-va", "xt_conntrack").CombinedOutput(); err != nil {
logrus.Warnf("Running modprobe xt_conntrack failed with message: `%s`, error: %v", strings.TrimSpace(string(out)), err)
}
И вот такое еще:
if err := r.setupIPTable(); err != nil {
return fmt.Errorf("setting up IP table rules failed: %v", err)
}
Делаем патч, а точнее — выкидываем все ненужное:) Делаем новую сборку Docker.
Смотрим. Все ок, все работает.
На этом этапе можно считать, что вся наша схема в лабораторных условиях работает, осталось сделать самую малость — прицепить ее к нашему сервису. Хорошо, возвращаемся к сервису и смотрим на его общую архитектуру:
Пояснения о том, как оно работает:
- (1 и 6) мобильный клиент устанавливает соединение с неким урлом, за которым стоит балансировщик;
- (2) балансировщик выбирает нужный инстанс нашего сервиса и позволяет установить соединение клиент-сервис;
- (3 и 4) далее наш сервис проксирует запросы от клиента на кластер с кодом, но тоже через балансировщик в виде nginx. Вот тут мы и вернулись к нашему требованию о том, что nginx должен быть на той же машине, что и сервис. На данный момент также есть ограничение в том, что он должен быть именно на хосте, а не в контейнере (это, кстати, решило бы проблему сразу). Мы не будем в данной статье рассуждать о причинах этого требования, а примем это как условие.
- (5) у каждого инстанса нашего сервиса есть определенный id, который нужен коду, чтобы понимать, через какой именно инстанс отвечать клиенту.
В первом приближении нам ничто не мешает собрать образ с нашим сервисом и запустить его уже в контейнере, но есть одно НО. Так уж сложилось, что для тех сервисов, которым нужно взаимодействие с внешним балансировщиком, у нас присутствует определенный набор статических маршрутов, например, вот такой:
# ip r
default via 1.1.2.254 dev eth0
10.0.0.0/8 via 1.1.2.1 dev eth0
1.1.2.0/24 dev eth0 proto kernel scope link src 1.1.2.14
192.168.0.0/16 via 1.1.2.1 dev eth0
Т.е. все, что должно идти в или из наших внутренних сетей, идет через .1, а остальное — через .254.
Почему это проблема в нашем случае? Потому что при запуске контейнера в его маршрутах мы видим следующее:
# ip r
default via 1.1.2.1 dev eth0
1.1.2.0/24 dev eth0 proto kernel scope link src 1.1.2.14
Попытка поменять маршруты внутри контейнера ни к чему не приведет, т.к. он у нас не привилегированный (--priveleged). Остается менять маршруты руками после старта контейнера с хоста (тут кроется большое заблуждение, но про это — дальше). Здесь варианта два:
- делать это руками, используя namespace контейнера;
- взять pipework https://github.com/jpetazzo/pipework и сделать все то же самое, но с его помощью.
Скажу сразу, что с этим можно жить, но существуют опасности, как у студента: «можно забыть, забить или запить» :)
Стремясь к идеалу, мы сделали для этой сервисной сети все маршруты через default gw, а всю сложность маршрутизации переложили на сетевой отдел. Всё. Точнее, я думал, что всё…
Как мне показалось на тот момент — решение отличное. Если бы все работало именно так, как я рассчитывал, так бы оно и было, но на этом ничего не закончилось. Чуть позже стало понятно, что при такой схеме мы получаем асимметричный роутинг для тех сетей, в которых есть маршрут через наш LTM. Чтобы стало чуть более понятно, я попробую показать, какие подсети у нас могут быть.
- Сеть, у которой есть только 1 default gw и нет внешнего балансировщика.
- Сеть, у которой более одного GW: например, балансировщик внешних запросов. Сложность в том, что внутренний трафик мы через него не гоняем.
Поговорив с сетевиками, мы сделали следующие выводы:
- они не готовы брать на себя ответственность и следить за всеми сетями, в которых роутинг будет именно таким;
- мы, со своей стороны, не готовы поддерживать статичные маршруты для всех таких сетей на серверах
Получилось так, что при желании сделать что-то простое, мы получили проблему на ровном месте, и если бы не подумали сразу о возможных сложностях, это привело бы к довольно грустным последствиям.
Я всегда говорю, что не стоит забывать про идеи, которые приходили в голову раньше, но были отвергнуты. Мы вернулись к мысли использовать статические маршруты внутри контейнера.
Итак, вот те условия, которые гарантируют нам работу нашего сервиса в контейнере:
- сам сервис;
- выделенный IP для сервиса;
- доступность сервиса и адреса со всех наших подсетей;
- возможность использовать и менять маршруты при запуске контейнера (самое важное, потому что именно это можно забыть).
Запускать контейнеры в привилегированном режиме (--privileged) не хотелось и не хочется. Я изначально не подумал про Linux capabilities, которые можно добавлять и убирать при запуске контейнера. Подробнее про них можно почитать вот тут. Для нашей задачи было достаточно добавить NET_ADMIN.
Вот теперь картинка сложилась, и мы можем добавить все нужные нам штуки, связанные с маршрутизацией, в автозапуск.
Давайте посмотрим, как выглядит наш Dockerfile в близком к финальному результату.
Dockerfile:
FROM dockerio.badoo.com/itops/sle_12_base:latest
MAINTAINER #MAINTEINER#
RUN /usr/bin/zypper -q -n in iproute2
RUN groupadd -g 1001 wwwaccess
RUN mkdir -p /local/SERVICE/{var,conf}
COPY get_configs.sh /local/SERVICE/
COPY config.cfg /local/SERVICE/
ADD SERVICE-CERTS/ /local/SERVICE-CERTS/
ADD SERVICE/bin/SERVICE-BINARY-${DVERSION} /local/SERVICE/bin/
ADD SERVICE/conf/ /local/SERVICE/conf/
COPY routes.sh /etc/cont-init.d/00-routes.sh
COPY env.sh /etc/cont-init.d/01-env.sh
COPY finish.sh /etc/cont-finish.d/00-finish.sh
COPY run /etc/services.d/SERVICE/
COPY finish /etc/services.d/SERVICE/
RUN touch /tmp/fresh_container
ENTRYPOINT ["/init"]
На что тут стоит обратить внимание:
- мы используем s6 overlay как supervisor внутри контейнера;
- мы добавляем пакет iproute, чтобы можно было править маршруты;
- мы добавляем запуск нескольких скриптов, которые выполняются ДО старта нашего сервиса (директория /etc/cont-init.d/), а также добавляем скрипты, которые будут выполнены ПОСЛЕ завершения работы сервиса, но ДО того, как опустится контейнер (/etc/cont-finish.d/);
- мы добавляем файл /tmp/fresh_container для того, чтобы понимать, первый ли раз стартует наш контейнер или нет. Чуть яснее про это будет, когда я покажу содержимое остальных скриптов;
Используемые скрипты:
- get_configs.sh — это скрипт, который смотрит, есть ли конфиг для сервиса в нашей системе хранения и генерации конфигов, доставляет их в контейнер, проверяет на валидность, и если все в порядке, то с ним и запускает. Подробнее про это мы рассказывали на Docker Meetup;
- routes.sh — скрипт, который подготавливает маршруты внутри контейнера:
#!/usr/bin/with-contenv sh if [ ! -x /usr/sbin/ip ];then echo -e "\e[31mCan't execute /usr/sbin/ip\e[0m"; [ $(pgrep s6-svscan) ] && s6-svscanctl -t /var/run/s6/services exit 1; else LTMGW=$(/usr/sbin/ip r show | /usr/bin/grep default | /usr/bin/awk {'print $3'} | /usr/bin/awk -F \. {'print $1"."$2"."$3".254"'}) DEFGW=$(/usr/sbin/ip r show | /usr/bin/grep default | /usr/bin/awk {'print $3'} | /usr/bin/awk -F \. {'print $1"."$2"."$3".1"'}) /usr/sbin/ip r replace default via ${LTMGW} /usr/sbin/ip r add 192.168.0.0/16 via 10.10.8.1 dev eth0 /usr/sbin/ip r add 10.0.0.0/8 via 10.10.8.1 dev eth0 echo -e "\e[32mAll job with routes done:\e[0m\n$(/usr/sbin/ip r show)" fi
- env.sh — скрипт, который готовит окружение для нашего сервиса; зачастую именно он выполняется только 1 раз при первом старте контейнера:
#!/usr/bin/with-contenv sh if [ ! -z "${ISTEST}" ];then exit 0;fi if [ ! -n "${SERVICETYPE}" ];then echo -e "\e[31mPlease set SERVICE type\e[0m"; [ $(pgrep s6-svscan) ] && s6-svscanctl -t /var/run/s6/services exit 1; fi bash /local/SERVICE/get_configs.sh || exit 1 echo -e "\e[32mSERVICE ${SERVICETYPE} is running\e[0m"
- finish.sh — скрипт, который просто чистит pid-файлы от нашего сервиса. Конкретный сервис настолько крут (как Чак Норрис), что сам этого не делает, но он не запустится, если обнаружит старые pid-файлы :)
- run — это скрипт, который запускает наше приложение:
#!/usr/bin/with-contenv bash exec /local/SERVICE/bin/SERVICE-${DVERSION} -l /local/SERVICE/var/mobile-${SERVICETYPE}.log -P /local/SERVICE/var/mobile-${SERVICETYPE}.pid -c /local/SERVICE/conf/SERVICE.conf -v ${VERBOSITY}
- finish — скрипт, который тушит контейнер в случае, если сервис завершил свою работу:
#!/bin/sh [ $(pgrep s6-svscan) ] && s6-svscanctl -t /var/run/s6/services
Строка для запуска нашего сервиса будет выглядеть так:
docker run -d --net=c_services --ip=1.1.2.17 --name=SERVICE-INSTANCE16 -h SERVICE-INSTANCE16.local --cap-add=NET_ADMIN --add-host='nginx.localhost:1.1.1.17' -e SERVICETYPE=INSTANCE16_eu1 -e HOST_IP=1.1.1.17 --volumes-from=badoo_loop dockerio.badoo.com/cteam/SERVICE:2.30.0_994
На этом перенос нашего сервиса в контейнер можно считать успешным. Забегая вперед, хочу заметить, что MACVLAN/IPVLAN мы используем в других наших сервисах, но примером послужил именно этот эксперимент.
Антон banuchka Турецкий
Site Reliability Engineer, Badoo
Комментарии (2)
24 августа 2016 в 16:21
0↑
↓
А почему вы не рассматривали вариант с macvlan интерфейсом в качестве основного на хост-системе? А на eth0 даже адрес не навешивать. Заодно и пакеты перестанут ходить через свитч.24 августа 2016 в 16:27
0↑
↓
В статье я сделал оговорку по этому поводу, также в ней содержится и ответ:Внимательный читатель задаст вопрос: «А зачем адрес на основной интерфейс и на MACVLAN-интерфейс, если можно адрес основного интерфейса отдать виртуальному?» В таком случае мы оставим нашу систему без адресов на реальных интерфейсах, а на такой шаг я пойти пока не готов.
Для того, чтобы пакеты перестали ходить через свитч мы также высадили nginx в контейнер, который использует нужную нам сеть.