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

от автора

Всеобщая контейнеризация захватывает мир. Не обошла эта эпидемия и меня стороной, и теперь, последние шесть месяцев, я занимаюсь тем, что сегодня принято называть модным словом 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-а поставляется без исходных файлов, что на мой взгляд несколько странно. Второй по популярности и единственный, к слову, нашедшийся на 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. Проект не используется в продакшене, и с моей критичной точки зрения требует допила, но каждый прочитавший статью может его попробовать и, при желании, улучшить.

ссылка на оригинал статьи http://habrahabr.ru/post/257913/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *