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