Pull to refresh

Как я писал аудит запуска Docker-контейнеров на Go

Reading time10 min
Views9.7K
Всеобщая контейнеризация захватывает мир. Не обошла эта эпидемия и меня стороной, и теперь, последние шесть месяцев, я занимаюсь тем, что сегодня принято называть модным словом DevOps. В проектах, которыми я занимаюсь, мы решили использовать Docker, ведь он делает процесс развёртывания приложений до неприличия простым, и буквально заставляет вас следовать другому не менее модному сегодня течению — микросервисной архитектуре, которая способствует бурному размножению этих самых контейнеров на его основе. В какой-то момент понимаешь, что было бы неплохо собрать статистику их жизни и смерти в отнюдь небезопасной среде обитания. А в качестве бонуса изучить инструменты, которые используешь в работе, понаписать что-то не на основном языке программирования, да и просто сделать что-то необязательное, но полезное.

В статье я расскажу как за три вечера и кусочек ночи был разработан проект для аудита и сбора статистики жизненного цикла контейнеров.

Первая половина дела


Беглый поиск в гугле не привёл к нахождению уже готового решения, поэтому будем делать сами.
Что нужно:
  • мониторинг запуска и остановки отдельного взятого контейнера
  • отправка сообщений о событии в некое хранилище
  • удобный инструмент для просмотра событий и их последующего анализа

Первую задачу решает registrator. Это решение от ребят из GliderLabs, которое позволяет автоматически регистрировать контейнеры в системах хранения конфигурации, такие как Сonsul или Netflix Eurika. К сожалению, последние заточены под совсем другую задачу: сказать какие сервисы сейчас доступны, и где расположены контейнеры, которые их реализуют.

Если рассмотреть каждое событие (запуск или смерть контейнера) как запись некоего лога, с которым мы можем делать всё что нам нужно, то для хранения этих записей можно взять ElasticSearch, а для просмотра и анализа в реальном времени — Kibana.

Нам остаётся решить второй пункт, а именно сделать связку между регистратором и эластиком.

Как устроен регистратор


Всякое развлечение начинается с форка, поэтому смело жмём кнопочку на GitHub-е для репозитория (https://github.com/gliderlabs/registrator). Клонируем себе на локальную машину и смотрим содержимое:

registrator.go     // основной файл запуска приложения  
modules.go         // подключение реализованных модулей (consul, etcd и т.д.)  
Dockerfile         // файл сборки docker-контйнера  
Dockerfile.dev     // файл для сборки dev-версии контейнера  
/bridge            // отсылаем данные во вне
/consul            // реализация отправки сообщения в consul

Схема простая. В registrator.go создаётся Docker-клиент, который слушает сокет, и, при возникновении какого-либо события (запуска, остановки или смерти контейнера), передаёт в bridge идентификатор контейнера и событие с ним связанное. Внутри bridge-а создаётся адаптер (модуль), который был указан при запуске приложения, в который уже передаётся детальная информация о контейнере для её последующей обработки. Таким образом достаточно добавить новый модуль, который будет пересылать данные в ElasticSearch.

make dev


Прежде чем писать код, попробуем собрать и запустить проект. В Makefile-е есть таск, в котором создаётся и запускается новый Docker-образ:

dev:  
    docker build -f Dockerfile.dev -t $(NAME):dev .
    docker run --rm --net host \
        -v /var/run/docker.sock:/tmp/docker.sock \
        $(NAME):dev /bin/registrator consul:

consul намекает нам на то, что это мастер-система по-умолчанию, без которой приложение не будет работать. Поставим его в Docker-контейнере в режиме standalone:

$ docker run -p 8400:8400 -p 8500:8500 -p 53:53/udp \ 
    -h node1 progrium/consul -server -bootstrap

Затем запустим сборку регистратора:

make dev  

Если всё прошло удачно (к сожалению удача она такая штука), то мы увидим что-то вроде этого:

2015/04/04 19:55:48 Starting registrator dev ...  
2015/04/04 19:55:48 Using elastic adapter: consul://  
2015/04/04 19:55:48 Listening for Docker events ...  
2015/04/04 19:55:48 Syncing services on 4 containers  
2015/04/04 19:55:48 ignored: cedfd1ae9f68 no published ports  
2015/04/04 19:55:48 added: b4455d0f7d50 ubuntu:kibana:80  
2015/04/04 19:55:48 added: 3d598d184eb6 ubuntu:nginx:80  
2015/04/04 19:55:48 ignored: 3d598d184eb6 port 443 not published on host  
2015/04/04 19:55:48 added: bcad15ac5759 ubuntu:determined_goldstine:9200  
2015/04/04 19:55:48 added: bcad15ac5759 ubuntu:determined_goldstine:9300  

Как видно у нас было 4 контейнера. У одного из них не было портов, у другого — порт 443 не был опубликован и т.д. Чтобы проверить, что сервисы действительно добавились, можно воспользоваться утилитой dig

dig @localhost nginx-80.service.consul  

Добавить -80 к имени контейнера необходимо, поскольку nginx выставляет наружу несколько портов, и с точки зрения Consul-а это разные сервисы.

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

Go Go Go


Адаптеры в проекте для различных бэкендов реализуются в виде отдельных модулей. Вообще в Go модуль очень занятная штука. Это может быть как локальная папка, так и проект на GitHub-е, разницы в подключении практически нет.

Добавим новую папку в корень проекта: /elastic и разместим в ней файл с нашей будущей реализации: elastic.go.

Дадим имя по-умолчанию для нашего модуля

package elastic  

Заимпортируем необходимые нам сторонние пакеты:

import (  
    "net/url"
    "errors"
    "encoding/json"
    "time"

    "github.com/gliderlabs/registrator/bridge"
    elasticapi "github.com/olivere/elastic"
)

Чтобы обрабатывать события, нужно реализовать интерфейс

type RegistryAdapter interface {  
    Ping() error //проверяем жив ли наш бэкенд
    Register(service *Service) error
    Deregister(service *Service) error
    Refresh(service *Service) error // можно не реализовывать :)
}

Адаптер регистрируется через метод init(), который исполняется при загрузке модуля:

func init() {  
    bridge.Register(new(Factory), "elastic")
}

При создании адаптера необходимо создать экземпляр клиента к ElasticSearch:

func (f *Factory) New(uri *url.URL) bridge.RegistryAdapter {  
  urls := "http://127.0.0.1:9200"

  if uri.Host != "" {
      urls = "http://"+uri.Host
  }

  client, err := elasticapi.NewClient(elasticapi.SetURL(urls))
  if err != nil {
      log.Fatal("elastic: ", uri.Scheme)
  }

  return &ElasticAdapter{client: client}
}

type ElasticAdapter struct {  
    client   *elasticapi.Client
}

С помощью метода isRunning() нужно проверить, что экземпляр всё ещё жив

func (r *ElasticAdapter) Ping() error {  
    status := r.client.IsRunning()

    if !status {
        return errors.New("client is not Running")
    }

    return nil
}

Пусть запись о контейнере будет иметь следующую структуру:

type Container struct {  
  Name string `json:"container_name"`
  Action string `json:"action"` //start and stop
  Message string `json:"message"`
  Timestamp string `json:"@timestamp"`
}

Реализуем метод регистрации контейнера:

func (r *ElasticAdapter) Register(service *bridge.Service) error  

Дампим полностью информацию о сервисе в json.

serviceAsJson, err := json.Marshal(service)  
if err != nil {  
    return err
}

Получаем текущее время. В Go используется забавная нотация для определения формата даты

timestamp := time.Now().Local().Format("2006-01-02T15:04:05.000Z07:00")  

Создаём новую запись для лога:

container := Container {  
    Name: service.Name, 
    Action: "start", 
    Message: string(serviceAsJson), 
    Timestamp: timestamp 
}

И отправляем её в специально созданный индекс

_, err = r.client.Index().  
    Index("containers").
    Type("audit").
    BodyJson(container).
    Timestamp(timestamp).
    Do()
if err != nil {  
    return err
}

Функция Deregister полностью повторяет предыдущую, только с другим action-ом.

Остаётся поменять в Makefile-е consul на elastic, и прописать модуль в modules.go.

All together now


Запускаем ElasticSearch

docker run -d --name elastic -p 9200:9200 \  
    -p 9300:9300 dockerfile/elasticsearch

Чтобы Kibana корректно работала с индексом, нужно добавить чуть переработанный шаблон от logstash-а:

{
  "template" : "containers*",
  "settings" : {
    "index.refresh_interval" : "5s"
  },
  "mappings" : {
    "_default_" : {
       "_all" : {"enabled" : true},
       "dynamic_templates" : [ {
         "string_fields" : {
           "match" : "*",
           "match_mapping_type" : "string",
           "mapping" : {
             "type" : "string", "index" : "analyzed", "omit_norms" : true,
               "fields" : {
                 "raw" : {"type": "string", "index" : "not_analyzed", "ignore_above" : 256}
               }
           }
         }
       } ],
        "_ttl": {
         "enabled": true,
         "default": "1d"
       },
       "properties" : {
         "@version": { "type": "string", "index": "not_analyzed" },
         "geoip"  : {
           "type" : "object",
             "dynamic": true,
             "path": "full",
             "properties" : {
               "location" : { "type" : "geo_point" }
             }
         }
       }
    }
  }
}

Запускаем Kibana

docker run -d -p 8080:80 -e KIBANA_SECURE=false \  
    --name kibana --link elastic:es \
    balsamiq/docker-kibana

Запускаем регистратор:

make dev  

Запускаем контейнер с nginx-ом для тестирования решения

docker run -d --name nginx -p 80:80 nginx

В Kibana нужно настроить новый индекс containers, после чего можно будет увидеть запись о запущенном nginx-е.

Файл с конечной реализацией лежит тут.

В бар врывается logstash


Всем хорошо наше решение, но для его работы нам нужно держать отдельный самописный индекс, и ещё не забыть накатить правильный шаблон с mapping-ами. Чтобы люди не заморачивались подобными вопросами существуют агрегаторы логов, которые не только умеют собирать информацию из огромного количества источников, но и сделают за нас всю грязную работу в части приведения логов к единому формату. Мы возьмём для наших экспериментов logstash.

По традиции запускать logstash мы хотим в контейнере. Официальный Docker-образ для logstash-а поставляется без исходных файлов, что на мой взгляд несколько странно (как заметил внимательный читатель grossws, ссылка на Dockerfile всё же присутствует). Второй по популярности и единственный, к слову, нашедшийся на github-e образ зачем-то запускает внутри себя и ElasticSearch и Kibana, что противоречит идее «один контейнер — один процесс». Там конечно есть возможность напередавать волшебную комбинацию флагов, но у меня он всё равно при старте лез брать какие-то ключи с сайта автора. На DockerHub-е было ещё с десяток контейнеров от неизвестных мне лиц, поэтому лучше соберём контейнер сами под наши нужды. Всё что нам понадобится — вот такой вот Dockerfile:

FROM dockerfile/java:oracle-java8  
MAINTAINER aatarasoff@gmail.com

RUN echo 'deb http://packages.elasticsearch.org/logstash/1.5/debian stable main' | sudo tee /etc/apt/sources.list.d/logstash.list && \  
        apt-get -y update && \
        apt-get -y --force-yes install logstash

EXPOSE 5959

VOLUME ["/opt/conf", "/opt/certs", "/opt/logs"]

ENTRYPOINT exec /opt/logstash/bin/logstash agent -f /opt/conf/logstash.conf  

Образ будет очень простым и запустится только при наличии внешнего конфигурационного файла, что для наших развлекательных задач вполне себе норма. Соберём образ и зальём его на Docker Hub:

docker build -t aatarasoff/logstash .  
docker push aatarasoff/logstash  


Создадим конфигурационный файл /mnt/logstash/conf/logstash.conf со следующим содержимым:

input {  
  tcp {
    type => "audit"
    port => 5959
    codec => json
  }
}

output {  
  elasticsearch {
    embedded => false
    host => "10.211.55.8"
    port => "9200"
    protocol => "http"
  }
}

type => «audit» сделает так, что все наши логи будут иметь общее значение в поле type, что позволит нам их отличать от других логов по этому дискриминатору. Остальные настройки довольно очевидны. Запустим свежеиспечённый контейнер:

docker run -d -p 5959:5959 -v /mnt/logstash/conf:/opt/conf \  
    --name logstash aatarasoff/logstash

и проверим, что логи будут писаться, если мы по tcp передадим json.

Реализация №2


Мы делаем уже второй модуль, поэтому стоит вынести реализацию в отдельный проект, который назовём auditor. Первым делом нам надо накрутить уже имеющееся «мясо» из регистратора. Поэтому берём наш форк и нагло копируем код себе в проект.

Проверяем, что всё у нас по-прежнему собирается, выполнив команду: make dev.

Замечаем, что в файле regitrator.go модуль bridge подключается как внешняя зависимость, поэтому можно смело удалять эту папку. Снова проверяем, что всё работает.

Изменяем Dockerfile.dev:

FROM gliderlabs/alpine:3.1  
CMD ["/bin/auditor"]

ENV GOPATH /go  
RUN apk-install go git mercurial  
COPY . /go/src/github.com/aatarasoff/auditor  
RUN cd /go/src/github.com/aatarasoff/auditor \  
    && go get -v && go build -ldflags "-X main.Version dev" -o /bin/auditor

Аналогично меняем релизный Dockefile. Убираем лишние таски и меняем имя контейнера в Makefile:

NAME=auditor  
VERSION=$(shell cat VERSION)

dev:  
    docker build -f Dockerfile.dev -t $(NAME):dev .
    docker run --rm --net host \
        -v /var/run/docker.sock:/tmp/docker.sock \
        $(NAME):dev /bin/auditor elastic:

build:  
    mkdir -p build
    docker build -t $(NAME):$(VERSION) .
    docker save $(NAME):$(VERSION) | gzip -9 > build/$(NAME)_$(VERSION).tgz


Добавим новый модуль /logstash и файл logstash.go к нашему проекту. Возьмём готового клиента для logstash-а, который туп как пробка, и фактически является просто обёрткой над стандартной библиотекой net: github.com/heatxsink/go-logstash.

В этот раз структура контейнера будет несколько отличаться от предыдущего варианта:

type Container struct {  
  Name string `json:"container_name"`
  Action string `json:"action"`
  Service *bridge.Service `json:"info"`
}

Связано это с тем, что теперь нам нужно просто сериализовать объект в json и отправить его как строку в logstash, который сам разберётся со всеми полями в сообщении.

Также как и в прошлый раз регистрируем нашу фабрику:

func init() {  
  bridge.Register(new(Factory), "logstash")
}

И создаём новый экземпляр адаптера:

func (f *Factory) New(uri *url.URL) bridge.RegistryAdapter {  
  urls := "127.0.0.1:5959"

  if uri.Host != "" {
    urls = uri.Host
  }

  host, port, err := net.SplitHostPort(urls)
  if err != nil {
    log.Fatal("logstash: ", "split error")
  }

  intPort, _ := strconv.Atoi(port)
  client := logstashapi.New(host, intPort, 5000)

  return &LogstashAdapter{client: client}
}

type LogstashAdapter struct {  
  client   *logstashapi.Logstash
}

Здесь нам пришлось использовать утильный метод net.SplitHostPort(urls), который умеет вычленять хост и порт из строки, потому что клиент принимает их раздельно, а приходят они вместе в uri.Host.

Числовое представление порта можно получить, применив метод конвертации строки в число: intPort, _ := strconv.Atoi(port). Знак подчёркивания нужен, потому что функция возвращает два параметра, второй из которых ошибка, которую мы можем не обрабатывать.

Реализация метода Ping получилась довольно простой:

func (r *LogstashAdapter) Ping() error {  
  _, err := r.client.Connect()
  if err != nil {
    return err
  }

  return nil
}

Фактически мы проверяем, что можем подключиться по tcp к logstash-у. В функции Connect повторное подключение произойдёт только если текущее уже не может быть использовано.

Осталось реализовать метод регистрации:

func (r *LogstashAdapter) Register(service *bridge.Service) error {  
  container := Container{Name: service.Name, Action: "start", Service: service}
  asJson, err := json.Marshal(container)
  if err != nil {
    return err
  }

  _, err = r.client.Connect()
  if err != nil {
    return err
  }

  err = r.client.Writeln(string(asJson))
  if err != nil {
    return err
  }

  return nil
}

Думаю, что код достаточно понятен и не требует комментариев, кроме одного. Вызов Connect перед Writeln гарантирует, что будет получено рабочее соединение.

Метод Deregister полная копия метода выше.

Меняем в Dockerfile.dev в строке запуска elastic на logstash, запускаем и проверяем наличие записей в ElasticSearch:

curl 'http://localhost:9200/_search?pretty'


… счастьем поделись с другим


Коммитим наши изменения на GitHub и идём собирать образ для DockerHub-а. На hub.docker.com, заходим на свою страницу и жмем кнопку +Add Repository. Когда собирался образ для logstash-a, я выбрал подпункт Repository, который позволяет вручную заливать свои образы, но есть и другой путь — Automated Build. Нажав на него, Docker Hub предложит подключить к нему свой аккаунт на GitHub-е или BitBucket-е. После этого остаётся только выбрать свой репозиторий, нужную ветку, и изменить названия образа, если это очень нужно. Всё остальное, включая перенос описания из README.MD возьмёт на себя Docker Hub.

После небольшого ожидания вот он — готовый образ.

Теперь можно протестировать его выполнив простую команду:

docker run -d --net=host \  
    -v /var/run/docker.sock:/tmp/docker.sock \
    --name auditor aatarasoff/auditor logstash://


PS. Проект не используется в продакшене, и с моей критичной точки зрения требует допила, но каждый прочитавший статью может его попробовать и, при желании, улучшить.
Tags:
Hubs:
Total votes 17: ↑14 and ↓3+11
Comments7

Articles