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

в 15:45, , рубрики: Ansible, docker, etcd, runit, виртуализация, костыли, системное администрирование

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

Навеяно публикацией «Понимая 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js