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, по хорошему, тоже сложно убить).