Навеяно публикацией «Понимая Docker», небольшой пример костылей вокруг докера для запуска веб-приложений.
Я пробовал разные технологии обвязок, но некоторые (fig) выглядят несколько корявыми для применения, а некоторые (kubernetis, mesos) — слишком абстрактными и сложными.
В моей конфигурации есть несколько машин, на машинах выполняются разнообразные веб-приложения, некоторые из них требуют наличия локального хранилища. В качестве базовой схемы примем конфигурацию из двух фронтендов и одного бекенда, ceph (ФС) обеспечивает роуминг данных для бекенда там, где это необходимо.
У машин есть приватный сетевой интерфейс. У фронтендов есть еще и публичный.
Дня конфигурации я использую связку из etcd+skydns (обнаружение сервисов), runit (мониторинг состояния контейнеров) и ansible (конфигурация). Вот код модуля ansible, который я буду обсуждать:
#!/usr/bin/env python
import os, sys
from string import Template
def on_error(msg):
def wrap(f):
def wrapped(self, module):
try:
return f(self, module)
except Exception, e:
module.fail_json(msg="%s %s: %s" % (msg, self.name, str(e)))
return wrapped
return wrap
class Service:
SERVICE_PREFIX = 'docker-'
SERVICES_DIR = '/etc/sv'
RUNNING_SERVICES_DIR = '/etc/service'
def __init__(self, name, image, args, announce, announce_as, port):
self.name = name
self.image = image
if args is not None:
self.args = args
else:
self.args = ''
self.announce = announce
self.announce_as = announce_as
self.port = port
def _needs_etcd(self):
return self.announce is not None
def _service_name(self):
return self.SERVICE_PREFIX + self.name
def _root_service_dir(self):
return os.path.join(self.SERVICES_DIR, self._service_name())
def _announced_service_dir(self):
return os.path.join(self._root_service_dir(), 'services', 'service')
def _etcd_service_dir(self):
return os.path.join(self._root_service_dir(), 'services', 'announce')
def _run_service_link(self):
return os.path.join(self.RUNNING_SERVICES_DIR, self._service_name())
def _root_run_file(self):
return os.path.join(self._root_service_dir(), 'run')
def _announced_service_run_file(self):
return os.path.join(self._announced_service_dir(), 'run')
def _etcd_run_file(self):
return os.path.join(self._etcd_service_dir(), 'run')
def exists(self):
return os.path.isdir(self._root_service_dir())
def scheduled_to_run(self):
return os.path.exists(self._run_service_link())
@on_error("Error starting service")
def start(self, module):
if self._needs_update(module):
self.install(module)
if self.scheduled_to_run():
return False
os.symlink(self._root_service_dir(), self._run_service_link())
return True
@on_error("Error stopping service")
def stop(self, module):
if not self.scheduled_to_run():
return False
os.unlink(self._run_service_link())
return True
@on_error("Error installing service")
def install(self, module):
if self._needs_update(module):
self.stop(module)
self.remove(module)
self._create_service(module)
return True
else:
return False
@on_error("Error creating service")
def _create_service(self, module):
self._create_service_dirs(module)
self._write_run_file(self._root_run_file(), self._render_root_run())
if self._needs_etcd():
self._write_run_file(self._announced_service_run_file(), self._render_service_run())
self._write_run_file(self._etcd_run_file(), self._render_etcd_run())
def _write_run_file(self, name, content):
f = open(name, 'w')
f.write(content)
os.fchmod(f.fileno(), 0755)
f.close()
@on_error("Error verifying service existence")
def _needs_update(self, module):
if self.exists():
if os.path.exists(self._root_run_file()):
root_run = self._render_root_run()
curr_run = open(self._root_run_file()).read()
if root_run != curr_run:
return True
if self._needs_etcd():
if os.path.exists(self._announced_service_run_file()):
service_run = self._render_service_run()
curr_run = open(self._announced_service_run_file()).read()
if service_run != curr_run:
return True
if os.path.exists(self._etcd_run_file()):
etcd_run = self._render_etcd_run()
curr_run = open(self._etcd_run_file()).read()
if etcd_run != curr_run:
return True
else:
return True
else:
return True
else:
return True
else:
return True
return False
@on_error("Error creating service directory")
def _create_service_dirs(self, module):
os.mkdir(self._root_service_dir(), 0755)
if self._needs_etcd():
os.mkdir(os.path.join(self._root_service_dir(), 'services'), 0755)
os.mkdir(self._announced_service_dir(), 0755)
os.mkdir(self._etcd_service_dir(), 0755)
@on_error("Error removing service")
def remove(self, module):
if not self.exists():
return False
if self.scheduled_to_run():
self.stop(module)
from shutil import rmtree
rmtree(self._root_service_dir())
return True
def _render_root_run(self):
if self._needs_etcd():
return self._render_runsv_run()
else:
return self._render_service_run()
def _render_service_run(self):
args = self.args
if self.announce:
if self.port is not None:
port = self.port
else:
port = self.announce
if self.announce_as != 'container':
args += " -p $ANNOUNCE_IP:" + self.announce + ":" + port
return Template("""#!/bin/bash
CONTAINER_NAME=$name
ifconfig eth1 >/dev/null 2>&1
if [[ $$? -eq 0 ]]; then
PUBILC_IF=eth0
PRIVATE_IF=eth1
else
PUBILC_IF=eth0
PRIVATE_IF=eth0
fi
case "$announce_as" in
public) ANNOUNCE_IP="`ifconfig $$PUBILC_IF | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*.){3}[0-9]*).*/\2/p'`"
;;
private) ANNOUNCE_IP="`ifconfig $$PRIVATE_IF | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*.){3}[0-9]*).*/\2/p'`"
;;
*) ANNOUNCE_IP=""
;;
esac
docker inspect $$CONTAINER_NAME|grep State >/dev/null 2>&1
if [ $$? -eq 0 ]; then
docker rm $$CONTAINER_NAME || { echo "cannot remove container $$CONTAINER_NAME"; exit 1; }
fi
docker pull $image
exec docker run
-i --rm
--name $$CONTAINER_NAME
--hostname "`hostname`-$name"
$args
$image
""").substitute(name=self.name, image=self.image, args=args, announce_as=self.announce_as)
def _render_runsv_run(self):
return """#!/bin/bash
runsvdir -P services &
RUNSVPID=$!
trap "{ sv stop `pwd`/services/*; sv wait `pwd`/services/*; kill -HUP $RUNSVPID ; exit 0; }" SIGINT SIGTERM
wait
"""
def _render_etcd_run(self):
return Template("""#!/bin/bash
ETCD="http://192.0.2.1:4001"
DOMAIN="com/example/prod/s/$name/`hostname`"
ifconfig eth1 >/dev/null 2>&1
if [[ $$? -eq 0 ]]; then
PUBILC_IF=eth0
PRIVATE_IF=eth1
else
PUBILC_IF=eth0
PRIVATE_IF=eth0
fi
case "$announce_as" in
public) ANNOUNCE_IP="`ifconfig $$PUBILC_IF | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*.){3}[0-9]*).*/\2/p'`"
;;
private) ANNOUNCE_IP="`ifconfig $$PRIVATE_IF | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*.){3}[0-9]*).*/\2/p'`"
;;
*) ANNOUNCE_IP=""
;;
esac
enable -f /usr/lib/sleep.bash sleep
trap "{ curl -L "$$ETCD/v2/keys/skydns/$$DOMAIN" -XDELETE ; exit 0; }" SIGINT SIGTERM
while true; do
if [[ "$announce_as" == "container" ]]; then
ANNOUNCE_IP="`docker inspect --format '{{ .NetworkSettings.IPAddress }}' $name`"
fi
curl -L "$$ETCD/v2/keys/skydns/$$DOMAIN" -XPUT -d value="{\"host\": \"$$ANNOUNCE_IP\", \"port\": $port}" -d ttl=60 >/dev/null 2>&1
sleep 45
done""").substitute(name=self.name, port=self.announce, announce_as=self.announce_as)
def main():
module = AnsibleModule(
argument_spec = dict(
state = dict(required=True, choices=['present', 'absent', 'enabled', 'disabled']),
name = dict(required=True),
image = dict(required=True),
args = dict(default=None),
announce = dict(default=None),
announce_as = dict(default='private', choices=['public', 'private', 'container']),
port = dict(default=None)
)
)
state = module.params['state']
name = module.params['name']
image = module.params['image']
args = module.params['args']
announce = module.params['announce']
announce_as = module.params['announce_as']
port = module.params['port']
svc = Service(name, image, args, announce, announce_as, port)
if state == 'present':
module.exit_json(changed=svc.install(module))
if state == 'absent':
module.exit_json(changed=svc.remove(module))
if state == 'enabled':
module.exit_json(changed=svc.start(module))
if state == 'disabled':
module.exit_json(changed=svc.stop(module))
module.fail_json(msg='Unexpected position reached')
sys.exit(0)
from ansible.module_utils.basic import *
main()
Давайте посмотрим, что происходит, когда мы запускаем новый сервис; например, запустим influxdb:
ansible -i hosts node-back-1 -s -m rundock -a 'state=enabled name=influxdb image="registry.s.prod.example.com:5000/influxdb:latest" args="--volumes-from data.influxdb -p $PRIVATE_IP:8083:8083" announce=8086 port=8086'
Ansible добавляет на машину новую задачу для runit, которая содержит две подзадачи, контейнер и анонс:
$ cat /etc/sv/docker-influxdb/services/service/run
#!/bin/bash
CONTAINER_NAME=influxdb
INTERFACE=eth0
PRIVATE_IP="`ifconfig $INTERFACE | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*.){3}[0-9]*).*/2/p'`"
docker inspect $CONTAINER_NAME|grep State >/dev/null 2>&1
if [ $? -eq 0 ]; then
docker rm $CONTAINER_NAME || { echo "cannot remove container $CONTAINER_NAME"; exit 1; }
fi
docker pull registry.s.prod.example.com:5000/influxdb:latest
exec docker run -i --rm --name $CONTAINER_NAME --hostname "`hostname`-influxdb" --volumes-from data.influxdb -p $PRIVATE_IP:8083:8083 -p $PRIVATE_IP:8086:8086 registry.s.prod.example.com:5000/influxdb:latest
runit убьет старый контейнер, если он был, скачает новый образ и запустит докер в интерактивном режиме. Если контейнер умрет — runit его перезапустит. В контейнере data.influxdb
сделан маппинг на пути в ФС, где influx будет хранить свои данные.
Второй сервис:
$ cat /etc/sv/docker-influxdb/services/announce/run
#!/bin/bash
ETCD="http://192.0.2.1:4001"
DOMAIN="com/example/prod/s/influxdb/`hostname`"
INTERFACE=eth0
enable -f /usr/lib/sleep.bash sleep
trap "{ curl -L "$ETCD/v2/keys/skydns/$DOMAIN" -XDELETE ; exit 0; }" SIGINT SIGTERM
while true; do
PRIVATE_IP="`ifconfig $INTERFACE | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*.){3}[0-9]*).*/2/p'`"
curl -L "$ETCD/v2/keys/skydns/$DOMAIN" -XPUT -d value="{"host": "$PRIVATE_IP", "port": 8086}" -d ttl=60 >/dev/null 2>&1
sleep 45
Модуль для bash добавляет sleep как built-in команду, теперь bash будет обновлять запись для домена, и influxdb будет доступен по node-back-1.influxdb.s.prod.example.com.
костыль: по-хорошему, анонс надо делать изнутри контейнера, так как анонс будет жив даже если контейнер ушел в crash-loop.
Теперь прикрутим grafana для фронтенда:
ansible -i hosts node-back-1 -s -m rundock -a 'state=enabled name=grafana image="tutum/grafana:latest" args="-e INFLUXDB_HOST=influxdb.s.prod.example.com -e INFLUXDB_PORT=8086 -e INFLUXDB_NAME=metrics -e INFLUXDB_USER=metrics -e INFLUXDB_PASS=metrics -e HTTP_PASS=metrics -e INFLUXDB_IS_GRAFANADB=true" announce=8087 port=80'
Тут port и announce разные, так как стандартный контейнер отдает grafana на порту 80, а мы отдаем его наружу на 8087.
Ну и наконец апстрим в nginx:
upstream docker_grafana {
server grafana.s.prod.example.com:8087;
keepalive 512;
}
костыль: порты прибиты руками. По-хорошему, что-то вроде этого может научить nginx использовать SRV записи.
Поговорим о стабильности решения?
Фронтенд. Если умрет фронтенд, надо обновлять DNS записи. Некоторое время лежим и грустим.
Обнаружение. etcd/skydns вообще сложно убить, если они адекватно собраны в консенсус.
Бекенд-сервис. Мы резолвим сервис без имени машины, так что можно запустить несколько бекендов; skydns будет балансировать нагрузку или оперативно подменять умершие сервисы.
Файловая система. В идеальном мире мы имеем полностью неизменяемое состояние, но в жизни все печальнее. БД, которые понимают репликацию, могут иметь хранилище на локальном диске или в обычном --volume
. Там, где надо распределять что-то между контейнерами, работает ceph (paxos, по хорошему, тоже сложно убить).
Автор: farcaller