С появлением docker у нас, как у сервиса мониторинга немного усложнилась жизнь. Как я писал ранее, одна из фишек нашего сервиса — автодетект сервисов, то есть агент сам находит запущенные на сервере сервисы, читает их конфиги и начинает сбор метрик.


Но в какой-то момент в production у наших клиентов начал появляться докер, и наш автодетект перестал работать. Процессу, который запускается через докер, проставляются различные namespace (mnt, net, user, pid), это достаточно сильно усложняет работу извне контейнера с файлами и сетью внутри контейнера.


Под катом я расскажу, как мы решали эту проблему, какие варианты пробовали, и что в итоге заработало.


Нашу задачу можно условно разделить на 2 части:


  • научиться читать файлы в контейнерах
  • научиться соединяться по сети с сервисами, запущенными в контейнерах

Файлы в контейнерах


Первая гипотеза была очень простой: мы просто будем определять куда смотрит fs контейнера на диске хоста, менять пути и ходить туда. К сожалению это работает только в случае AUFS, но он в production практически не встречается.


Дальше мы наивно пробовали делать setns на MNT namespace прямо из кода агента, но это тоже не получилось. Дело в том, что setns на mnt (и user тоже) неймспейс может делать только однотредовое приложение:


A process may not be reassociated with a new mount namespace if it is multithreaded

Наш агент написан на golang и к моменту, когда мы хотим вызывать setns, гошный runtime уже наплодил нам несколько тредов. Чтобы агент мог запускать какие-то специальные процессы типа nsenter, нужно предварительно притащить их на машину клиенту, чего нам сильно не хотелось.


Был вариант запускать что-то через docker exec -ti, но, во-первых, эта команда доступна только с версии 1.3, во-вторых, существует не только докер, но еще и другие сервисы контейнеризации, а в-третьих, внутри контейнера может не быть даже cat.


Потом нашелся интересный хак для go, который позволяет сделать setns в сишном конструкторе до запуска go runtime. В итоге мы пришли к тому, что агент запускает сам себя с определенными аргументами и может прочитать файл в нужном ns, раскрыть glob по файловой системе контейнера и тому подобное. Но так как setns должен выполняться в C коде, пришлось писать на C и обработку аргументов запуска. Причем в момент вызова


__attribute__((constructor))

argv/argc еще не проинициализированы, так что пришлось читать аргументы запуска себя из /proc/self/cmdline.


При запуске агента в этом режиме он вываливает результат своей работы в stdout/stderr, а агент-родитель это читает. Отдельно пришлось сделать ограничение на размер читаемого файла: чтобы не нагружать диск мы даже не пытаемся читать файлы более 200KB (часто встречаются увесистые конфиги nginx с мапингом geoip), так как это может заметно прогрузить диск на клиентском сервере.


Такой подход хорошо работает только когда нужно один раз прочитать файл, но не годится, если нужно например tail'ить лог. С другой стороны логи на слоеные fs контейнеров обычно не пишут. Их обычно либо заворачивают в докеровские stdout/stderr и прогоняют через dockerd, либо пишут на примонтированные разделы на хостовую fs.


Вариант с dockerd мы пока никак не обрабатываем, но стоит отметить, что среди наших клиентов он встречается редко. Видимо из-за того, что на большом потоке логов, dockerd начинает нехило нагружать процессор.


Для случая с примонтированными директориями для логов, мы через информацию из docker inspect пытаемся найти нужный файл на fs хоста, а плагин, который хочет парсить такой лог, получает путь до файла уже вне контейнера.


Сеть в контейнерах


Первая идея относительно того, как работать по сети с сервисом в контейнере была тоже наивной: мы будем брать из docker inspect IP контейнера и будем работать с ним. Потом выяснилось, что доступа с хоста в сеть контейнера может и не быть вовсе (macvlan). К тому же есть lxc итд.


Мы решили двигаться в сторону setns. Cетевой namespace в отличии от mnt и user можно переопределить для одного конкретного треда приложения. В golang с этим с первого взгляда все достаточно просто:


  • запускаем горутину
  • блокируем для нее текущий тред runtime.LockOSThread, таким образом другие горутины в этом треде исполняться не будут
  • делаем setns
  • если нужно, можно сделать setns на наш предыдущий namespace и снять лок на тред runtime.UnlockOSThread

Но все оказалось сложнее. На самом деле при блокировке треда runtime нам не гарантирует, что исполнение данной горутины останется в этом треде. Есть хорошее описание как раз такого случае в посте "Linux Namespaces And Go Don't Mix".


Изначально мы собирались запускать плагин, мониторящий сервис в контейнере как раз в залоченном треде с setns, но это сломалось на первом же http клиенте.


Так как влиять на планировщик go у нас нет возможности, мы стали искать способ оставить в треде только код, не приводящий к порождению новых тредов.


Мы заметили, что если сразу после setns делать tcp коннект, то он проходит в 100% случаев, и если потом выйти из namespace и отпустить лок на тред, открытое соединение продолжает работать (я затрудняюсь объяснить, почему это работает).


Дальше задача свелась к тому, чтобы всем библиотеками для работы с различными сервисами, которые мы мониторим, подсунуть наш Dialer (функцию, отвечающую за TCP connect):


  • redis:
    client := redis.NewClient(&redis.Options{
        Dialer: func() (net.Conn, error) {
            return utils.DialTimeoutNs("tcp", params.Address, params.NetNs, redisTimeout)
        },
        ReadTimeout:  redisTimeout,
        WriteTimeout: redisTimeout,
        Password:     params.Password,
    })
  • для memcached мы не используем никаких библиотек, а работаем с ним по tcp руками, следовательно тут тоже не возникло никаких проблем
  • в rabbitmq мы ходим по http, стандартный http клиент умеет принять кастомные Dial
  • mysql:
    mysql.RegisterDial("netns", func(addr string) (net.Conn, error) {
        return utils.DialTimeoutNs("tcp", addr, params.NetNs, connectTimeout)
    })
    db, err = sql.Open("mysql",
        fmt.Sprintf("netns(%s)/?timeout=%s&readTimeout=%s&writeTimeout=%s",
            params.Address, connectTimeout, readTimeout, writeTimeout))
  • c postgresql получилось достаточно костыльно, пришлось делать свой псевдо драйвер для database/sql:

func init() {
    sql.Register("postgres+netns", &drv{})
}

type drv struct{}

type nsDialer struct {
    netNs string
}

func (d nsDialer) Dial(ntw, addr string) (net.Conn, error) {
    return utils.DialTimeoutNs(ntw, addr, d.netNs, connectTimeout)
}

func (d nsDialer) DialTimeout(ntw, addr string, timeout time.Duration) (net.Conn, error) {
    return utils.DialTimeoutNs(ntw, addr, d.netNs, timeout)
}

func (d *drv) Open(name string) (driver.Conn, error) {
    parts := strings.SplitN(name, "|", 2)
    netNs := ""
    if len(parts) == 2 {
        netNs = parts[0]
        name = parts[1]
    }
    return pq.DialOpen(nsDialer{netNs}, name)
}

потом вызываем свой драйвер:


dsn := fmt.Sprintf("%s|postgres://%s:%s@%s/%s", 
    p.NetNs, p.User, p.Password, p.Address, dbName)
db, err := sql.Open("postgres+netns", dsn)

Итого


Оглядываясь назад, мы не пожалели, что выбрали вариант с setns, так этот же код недавно прекрасно сработал у клиента с lxc.


Единственный незакрытый на данный момент сервис — это jvm в контейнере, но это совсем другая история.

Комментарии (9)


  1. ctrlok
    15.09.2017 17:29
    +5

    Может я что-то не понимаю, но почему не ходить через /proc/%pid%/root — попали бы сразу в рут к контейнеру.


    1. NikolaySivko Автор
      15.09.2017 17:39
      +1

      ну ни фига же себе! :)


    1. NikolaySivko Автор
      15.09.2017 17:42
      +2

      На самом деле это решает только часть задачи, нужно еще иногда например вызвать nginx -V в контейнере, чтобы конфиг найти, тогда setns придется делать


      1. ctrlok
        15.09.2017 18:43

        ну так вы его всё равно ж форкаете. Делаете форк, в нём setns и exec nginx -V

        Я когда у себя похожую штуку делал, реализовывал что-то похожее через nsenter man7.org/linux/man-pages/man1/nsenter.1.html


        1. NikolaySivko Автор
          15.09.2017 19:23

          Да, мы именно так и делаем


  1. SchmeL
    15.09.2017 19:13

    К сожалению это работает только в случае AUFS, но он в production практически не встречается.

    Хотелось бы знать почему не встречается?


    1. Prototik
      15.09.2017 22:24
      +1

      AUFS — глючное глюкалово, которое работает чёрт пойми как. Поэтому и не встречается :)


    1. Seboreia
      16.09.2017 00:12
      +1

      Я заметил, что, как правило, докер используется в относительно молодых/активно развивающихся проектах, в которых используются свежие дистрибутивы linux (имею в виду не уровень rhel 5/6, которые все еще очень распространены в махровом энтерпрайзе). В свежих дистрибутивах свежие ядра. В самой оф. документации докера про aufs написано, что при наличии версии ядра 4+ рекомендуется использовать более быстрый и стабильный overlay2.
      У ubuntu 16.04, например, ядро версии 4.4. Не удивлюсь, если бОльшая часть клиентов okmeter использует именно ее :)


      1. NikolaySivko Автор
        16.09.2017 09:24
        -1

        Компании, использующие докер чаще всего сидят на новых дистрибутивах. К тому же как раз из-за докера им проще мигрировать сервисы на соседнюю железку и перезалить OS.
        Среди наших клиентов есть очень разные компании, есть и ubuntu 16.04 и 10.04, есть даже у кого-то сервер БД на freebsd 8.x :)