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