...

среда, 25 марта 2015 г.

Docker и костыли в продакшене


Навеяно публикацией «Понимая 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, по хорошему, тоже сложно убить).


This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий