Всем привет! Меня зовут Сергей, я аналитик в ГК «Везёт». Исторически так сложилось, что в нашей компании было множество систем отчетности: от платных в виде Looker и Qlick – до самописных веб-сервисов. Однажды решив, что так дальше жить нельзя, мы стали выбирать единую систему, на которой будет все, и в итоге остановились на Shiny. В этой статье я расскажу про наш опыт внедрения Shiny в качестве корпоративного BI. Эта статья будет полезна всем, кто только выбирает инструмент для корпоративной отчетности.
Содержание
- Причины выбора Shiny
- Выбираем способ развертывания приложений
- Shiny Server
- Shiny Server Pro или RStudio Connect
- ShinyProxy
- Load Balancing
- Настройка ShinyProxy
- Деплой отчетов
- Сбор статистики
- Общая структура отчетов и контроль доступов
- Процесс внедрения
- Недостатки и неудобства
- Вывод
Причины выбора Shiny
Shiny – это пакет для R, который позволяет создавать интерактивные веб-приложения и отчеты.
Официальный сайт проекта.
Здесь можно посмотреть примеры веб-приложений и дашбордов.
- Динамические UI – в ряде отчетов, в зависимости от условий выбора нужно было показывать дополнительные фильтры, селекторы.
- Гибкость настройки отчетов – в разных BI использовался разный функционал работы с отчетами, конечный бизнес-пользователь хотел сохранить функционал изначального отчета.
- Контроль изменений в отчетах с помощью гита – это для удобства, нужно было понимать кто, когда и что менял в отчете.
- Быстрое прототипирование сложных отчетов – иногда конечный бизнес-пользователь может не до конца понимать, какой результат он хочет в итоге, поэтому желательно быстрее пройти процесс согласования конечного функционала отчета.
- Малое потребление серверных ресурсов.
- Удобная отладка отчетов – т.к. периодически пользователи запрашивают сложные отчеты, нужна его удобная отладка, чтобы можно было понять, в каком месте отчет не работает.
- Бесплатность – как дополнительный плюс
Выбираем способ развертывания приложений
Созданный отчет должен как-то увидеть конечный бизнес-пользователь. Для этого есть несколько решений.
Shiny Server (Бесплатный)
Запуск всех отчетов на одном процессе R
Плюсы:
- Неограниченный хостинг приложений.
- Простота в настройке.
Минусы:
- Одновременно можно запускать 20 сеансов, т.е. больше 20 человек сервер не примет.
- Нет аутентификации пользователя.
- Все приложения работают через один процесс, что будет доставлять неудобства: если кто-то запустит тяжелый отчет, то вся отчетность будет дико тупить.
Источник картинки
Shiny Server Pro или RStudio Connect (Платный)
Запуск разных отчетов в разных процессах R.
Плюсы:
- Неограниченный хостинг приложений.
- Неограниченное количество одновременных пользователей.
- Множество способов аутентификации пользователя.
- Можно установить пароль на отдельное приложение.
- Работа приложений в нескольких процессах.
- Простота в настройке.
Минусы:
- Не нашел.
Источник картинки
ShinyProxy (Бесплатный)
В данном продукте используется концепция запуска каждого приложения пользователя в отдельном докере.
Плюсы:
- Неограниченный хостинг приложений.
- Неограниченное количество одновременных пользователей.
- Множество способов аутентификации пользователя.
Минусы:
- Так как для каждого пользователя ShinyProxy инициализирует контейнер, то он может потреблять много оперативной памяти сервера.
- Пользователь должен дождаться инициализации контейнера.
Источник картинки
Load Balancing (Платный)
Развитие концепции ShinyProxy используется, если ваш контейнер с приложением очень тяжелый и запускается около минуты и более. Решение состоит в прединициализации контейнера, к которому подключаются пользователи.
Плюсы:
- Неограниченный хостинг приложений.
- Неограниченное количество одновременных пользователей.
- Множество способов аутентификации пользователя.
Минусы:
- Load Balancing инициализирует контейнер, поэтому он может потреблять много оперативной памяти сервера.
Источник картинки
После рассмотрения различных вариантов решили остановиться на ShinyProxy.
Настройка ShinyProxy
В целом настройка по инструкции прошла стандартно, были настроены два сервера и HAProxy в качестве балансировщика между ними.
Для аутентификации был выбран протокол LDAP через корпоративный Active Directory.
Итоговые настройки ShinyProxy на двух серверах выглядят так.
proxy:
title: ShinyProxy
bind-address:
- 0.0.0.0
heartbeat-timeout: 60000
container-wait-time: 60000
authentication: ldap
admin-groups: Domain Users
# LDAP configuration
ldap:
url: ldap://host:port/dc=ad,dc=corp
user-search-base:
user-search-filter: (sAMAccountName={0})
group-search-base: OU=Groups
group-search-filter: (member={0})
manager-dn: CN=shinyproxy,CN=Users,DC=ad,DC=corp
manager-password: password
# Docker configuration
docker:
cert-path: /home/none
url: http://localhost:2375
port-range-start: 20010
port-range-max: 20900
В процессе эксплуатации выяснилось следующее:
- ShinyProxy теряет некоторые контейнеры докера и не может их найти для повторного подключения, если прошло больше получаса. Решено было повесить на крон килл контейнеров, которые работают больше 45 мин.
*/5 * * * * docker ps --format='{{.Names}}' | grep -v cadvisor | xargs -n 1 -r docker inspect -f '{{.ID}} {{.State.Running}} {{.State.StartedAt}}' | awk '$2 == "true" && $3 <= "'$(date -d '45 minutes ago' -Ins --utc | sed 's/+0000/Z/')'" { print $1 }' | xargs -r docker kill
- Блокировщик рекламы запрещал доступ на отчеты, у которых был префикс webstat. Поэтому мы попросили всех пользователей отключить блокировщики рекламы на сайте с отчетностью.
Деплой отчетов
Исходники всех отчетов хранятся в GitLab. Его и решили использовать в качестве инструмента загрузки отчетов на сервер с помощью пайплайнов. Для этого было сделано следующее:
- Был создан "главный" докер, в котором установлен ShinyServer, и основной набор пакетов для R. Таким образом мы уменьшим суммарный объем занимаемого дискового пространства и ускорим сборку отчетов.
FROM rocker/r-ver:latest
RUN apt-get update -y && \
apt-get install --no-install-recommends -y sudo gdebi-core pandoc pandoc-citeproc libcurl4-gnutls-dev libcairo2-dev libxt-dev wget libpq-dev \
libjpeg-dev libssl-dev libprotobuf-dev libjq-dev protobuf-compiler libudunits2-dev gdal-bin proj-bin libgdal-dev libproj-dev #gnupg dirmngr
RUN apt-get update && \
apt-get install --no-install-recommends -y java-common
RUN echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | /usr/bin/debconf-set-selections
RUN TEMP_DEB="$(mktemp)" && \
wget -O "$TEMP_DEB" 'https://launchpad.net/~webupd8team/+archive/ubuntu/java/+build/12469417/+files/oracle-java8-installer_8u131-1~webupd8~2_all.deb' && \
dpkg -i "$TEMP_DEB" && \
rm -f "$TEMP_DEB"
RUN wget --no-verbose https://download3.rstudio.org/ubuntu-14.04/x86_64/VERSION -O "version.txt" && \
VERSION=$(cat version.txt) && \
wget --no-verbose "https://download3.rstudio.org/ubuntu-14.04/x86_64/shiny-server-$VERSION-amd64.deb" -O ss-latest.deb && \
gdebi -n ss-latest.deb && \
rm -f version.txt ss-latest.deb && \
. /etc/environment && \
rm -rf /var/lib/apt/lists/*
RUN R -e "install.packages(c('highcharter','clickhouse','data.table','DataCombine','DBI','devtools','dplyr','dqshiny','DT','esquisse','forcats','ggplot2','ggthemes','gridExtra','hexbin','htmlwidgets','lattice','lazyeval','leaflet','leaflet.extras','lubridate','openxlsx','pivottabler','plotly','raster','RClickhouse','reactable','readxl','reshape','reshape2','rgdal','rhandsontable','RPostgres','RPostgreSQL','RSQLite','scales','sf','shiny','shinycssloaders','shinydashboard','shinyjqui','shinyjs','shinyMatrix','shinyTime','shinyWidgets','sjmisc','sp','sqldf','stringr','tibble','tidyr','writexl','yaml','geosphere'), repos='https://cran.rstudio.com/')"
RUN apt-get update && \
apt-get install -y r-cran-rjava
RUN R CMD javareconf
RUN R -e "install.packages(c('RJDBC'), repos='https://cran.rstudio.com/')"
RUN java -version
- "Главный" докер импортируется в каждом отчете
FROM host:port/db/for_shiny_reports/shiny_docker:latest # Главный докер
EXPOSE 3838
COPY app /srv/shiny-server/app
RUN mkdir -p /var/lib/shiny-server/bookmarks/shiny
CMD ["R", "-e", "shiny::runApp('/srv/shiny-server/app', port = 3838, host = '0.0.0.0')"]
- В каждом проекте прописан пайпалйн сборки, таким образом каждый раз, когда мы оставляем комит в отчете, то начинается пересборка отчета.
stages:
- build
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
DOCKER_DRIVER: overlay2
build-master:
image: docker:18.09.7
services:
- name: docker:18.09.7-dind
stage: build
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
script:
- apk add --update curl
- docker pull $CI_REGISTRY_IMAGE || true
- docker build --cache-from $CI_REGISTRY_IMAGE:latest --pull -t $CI_REGISTRY_IMAGE .
- docker run -d -e SHINYPROXY_USERNAME=$SHINYPROXY_USERNAME1 -e SHINYPROXY_USERGROUPS=$SHINYPROXY_USERGROUPS1 -p 0.0.0.0:3838:3838/tcp $CI_REGISTRY_IMAGE R -e "shiny::runApp('/srv/shiny-server/app', port = 3838, host = '0.0.0.0')"
- sleep 30 && curl -Is http://docker:3838/ | grep "200 OK"
- docker push $CI_REGISTRY_IMAGE
only:
- master
- Т.к. за день может быть несколько правок по различным отчетам и добавление какого-нибудь нового отчета, то заливку докер-образов на сервера было решено вынести в отдельный проект с отдельным CI/CD и использованием плейбуков Ansible. При добавлении нового отчета будет заново пересоздаваться конфиг для ShinyProxy. Ниже шаблон генерации конфига для Ansible
proxy:
title: ShinyProxy
bind-address:
- 0.0.0.0
heartbeat-timeout: 60000
container-wait-time: {{ shinyproxy_container_wait_time }}
authentication: {{ shinyproxy_authentication }}
admin-groups: {{ shinyproxy_admin_group }}
usage-stats-url: {{shinyproxy_usage_stats_url}}
usage-stats-username: {{shinyproxy_usage_stats_username}}
usage-stats-password: {{shinyproxy_usage_stats_password}}
# LDAP configuration
ldap:
url: {{ shinyproxy_ldap_server }}
user-search-base: {{ shinyproxy_ldap_user_search_base }}
user-search-filter: {{ shinyproxy_ldap_user_search_filter }}
group-search-base: {{ shinyproxy_ldap_group_search_base }}
group-search-filter: {{ shinyproxy_ldap_group_search_filter }}
manager-dn: {{ shinyproxy_ldap_admin }}
manager-password: {{ shinyproxy_ldap_admin_pwd }}
# Docker configuration
docker:
cert-path: /home/none
url: {{ shinyproxy_docker_url }}
port-range-start: {{ shinyproxy_docker_port_range_start }}
port-range-max: {{ shinyproxy_docker_port_range_max }}
specs:
{% if shinyproxy_apps is defined %}
{% for app in shinyproxy_apps %}
- id: {{ app.id }}
display-name: {{ app.display_name }}
{% if app.description is defined %}
description: {{ app.description }}
{% endif %}
container-cmd: ["R", "-e", "shiny::runApp('/srv/shiny-server/app', port = 3838, host = '0.0.0.0')"]
container-image: {{ CI_REGISTRY }}/db/shinyproxy/{{ app.container_image }}
# docker-memory: {{ app.docker_memory | default('2g') }}
{% if app.access_groups is defined %}
access-groups: [{{ app.access_groups }}]
{% endif %}
groups: [{{ app.groups }}]
{% if app.container_volumes is defined %}
container-volumes: ["{{ app.container_volumes }}"]
{% endif %}
{% endfor %}
{% endif %}
server:
servlet.session.timeout: 900
logging:
file:
shinyproxy.log
Этот шаблон подается в задачник ansible
---
- name: Log into registry and force re-authorization
docker_login:
registry: "{{ CI_REGISTRY }}"
username: gitlab-ci-token
password: "{{ CI_JOB_TOKEN }}"
reauthorize: true
- name: Pull the docker images
command: docker pull host:port/db/shinyproxy/{{ item.container_image }}:latest
with_items: "{{ shinyproxy_apps }}"
- name: Install the shinyproxy configuration file
template: src=shinyproxy-conf.yml.j2 dest=/etc/shinyproxy/application.yml
become: true
when: not ansible_check_mode
notify: Restart shinyproxy
- name: Ensure that the shinyproxy service is enabled and running
service: name=shinyproxy state=started enabled=yes
become: true
При этом информация о приложениях (shinyproxy_apps
) хранится в json, в таком виде.
{
"shinyproxy_apps": [
{
"id": "report_one",
"display_name": "Отчет 1",
"container_image": "report_one",
"access_groups": "Группа доступа 1, Группа доступа 2",
"groups": "Domain Users"
},
{
"id": "report_two",
"display_name": "Отчет 2",
"container_image": "report_two",
"access_groups": "Группа доступа 1, Группа доступа 2",
"groups": "Domain Users"
}
]
}
Все это безобразие запускается таким CI
stages:
- deploy
- test
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
deploy:
stage: deploy
image: $CI_REGISTRY_IMAGE
script:
- ansible-playbook --check playbook.yml -i hosts.inil --extra-vars=@apps.json -e CI_PROJECT_NAME=$CI_PROJECT_NAME -e CI_REGISTRY_IMAGE=$IMAGE_TAG -e CI_PROJECT_NAMESPACE=$CI_PROJECT_NAMESPACE -e CI_REGISTRY=$CI_REGISTRY -eCI_JOB_TOKEN=$CI_JOB_TOKEN -vv
- ansible-playbook playbook.yml -i hosts.ini --extra-vars=@apps.json -e CI_PROJECT_NAME=$CI_PROJECT_NAME -e CI_REGISTRY_IMAGE=$IMAGE_TAG -e CI_PROJECT_NAMESPACE=$CI_PROJECT_NAMESPACE -e CI_REGISTRY=$CI_REGISTRY -e CI_JOB_TOKEN=$CI_JOB_TOKEN -vv
except:
- triggers
Сбор статистики
В ShinyProxy есть средство сбора статистики, на данный момент оно поддерживает только 2 СУБД; InfluxDB и MonetDB. Собирается статистика следующего вида: время события; пользователь; тип события (логин/открытие/закрытие отчета); название отчета.
Но для наших целей мы отказались от данного инструмента, заменив его самописными событиями с отправкой в Postgres, так мы теряем возможность видеть кто логинился (но нам это и не нужно), зато получаем возможность вести расширенную статистику по пользователям. Помимо времени входа, имени пользователя и отчета, еще добавили информацию о группах пользователя в Active Directory. Так мы можем точнее понять, из-за чего у пользователя не работает отчет.
con_stat <- dbConnect(RPostgres::Postgres(),host = 'host', port = 5432, dbname = "dbname", user = "user", password = "password")
vizit_df <- data.frame(user = Sys.getenv("SHINYPROXY_USERNAME"), groups = Sys.getenv("SHINYPROXY_USERGROUPS"), app = 'app_name', timie_visit = Sys.time())
dbWriteTable(con_stat, "visit_apps", vizit_df, append=TRUE)
dbDisconnect(con_stat)
rm(vizit_df)
Общая схема всей системы отчетности выглядит как-то так
Общая структура отчетов и контроль доступов
Очевидно, что в рамках компании пользователи должны иметь разный доступ к отчетам и их содержимому. Набор отчетов для топ-менеджмента будет отличаться от набора отчетов для менеджера города. Аналогично и с доступом к информации: региональный менеджер может видеть только свой город, а топ менеджер может видеть все города. Чтобы реализовать такой функционал было сделано следующее.
В самом ShinyProxy есть возможность показывать разным группам пользователей разный набор отчетов, он осуществляется с помощью указания групп Active Directory в конфиге отчета, в свойстве access-groups. Для этих целей были заведены отдельные группы LDAP для разных профессий, вида: Маркетинг, Аналитика, Топ-менеджмент и пр.
Но вот для получения доступа к отдельным городам или платформам уже пришлось использовать самописный вариант.
- У всех региональных менеджеров в группах Active Directory прописан город, а ShinyProxy при авторизации через LDAP возвращает строку с описанными группами в переменной среды
SHINYPROXY_USERGROUPS
. Прочитав переменную, мы сможем извлечь список доступных городов для пользователя. - Также все группы пользователей (Маркетинг, Аналитика, Топ-менеджмент и пр.) были разделены на тех, кому по умолчанию можно видеть все города, и тех, кто может видеть только города привязанные в LDAP. Так, при открытии отчета, мы сможем понять, нужно ли отображать пользователю один город или все (например, если это аналитик или топ-менеджер).
- Ещё была добавлена таблица «Исключений». У тех же маркетологов или менеджеров города есть руководители, эти люди должны видеть все города при том же самом наборе отчетов. Так нам не нужно будет для подобных пользователей прописывать 100+ городов в Active Directory. Соответственно, в эту таблицу заносятся логины руководителей отделов, и при открытии отчета происходит проверка того, что пользователь может видеть все города.
Общая структура отчета Shiny
username <- Sys.getenv("SHINYPROXY_USERNAME") # Получаем имя пользователя
user_groups <- Sys.getenv("SHINYPROXY_USERGROUPS") # Получаем список групп пользователя в LDAP. Так мы узнаем, можно ли смотреть пользователю все города или только один и какой именно
# блок сбора статистики, пишем кто открыл отчет с какими правами
con_stat <- dbConnect(RPostgres::Postgres(),host = 'host', port = 5432, dbname = "dbname", user = "user", password = "password")
vizit_df <- data.frame(user = username, groups = user_groups, app = 'report_name', timie_visit = Sys.time())
dbWriteTable(con_stat, "visit_apps", vizit_df, append=TRUE)
dbDisconnect(con_stat)
rm(vizit_df)
# Определяем, можно ли пользователю смотреть все города
# Возвращаем список городов
# Определяем, можно ли пользователю видеть только один город
# Проверяем, не является ли пользователь руководителем. Если руководитель, то показываем все города
# Возвращаем список городов
ui <- dashboardPage(
# Описание UI
)
server <- function(input, output) {
# Обработка событий и генератор SQL запросов
}
shinyApp(ui, server)
Процесс внедрения
Процесс перехода в данном случае можно разбить на 4 этапа:
- По началу стояла задача закрыть операционные потребности региональных директоров и менеджеров, чтобы они могли получить расширенные сведения и статистику по водителям, пассажирам (обезличенные данные) и поездкам. Нужно было реализовать раздельный доступ к городам-платформам, чтобы каждый региональщик видел только свой город. И стояла задача перенести отчетность Looker'а.
- Затем мы стали закрывать информационные потребности маркетологов. Так же на данном этапе был осуществлен перенос отчетов из Qlick Sense.
- Следующим шагом мы стали переносить отчеты из самописных веб-сервисов, там уже преимущественно была задача закрыть потребности финансистов.
- Создание общей отчетности в рамках всей группы компаний. Здесь уже стояла задача унификации показателей между различными бэкофисами, с целью оперативного понимания о том, что происходит по всей группе компаний. Создание максимально универсального отчета чем-то похожего на сводную таблицу со множеством срезов и показателей. На данном этапе заказчиками отчетности являлись продакт-менеджеры и топ-менеджмент.
Недостатки и неудобства
Теперь стоит поговорить о недостатках данной системы корпоративной отчётности:
- Спустя время слои от докеров могут занять все дисковое пространство, даже если использовать один главный докер. Поэтому приходится постоянно удалять лишние слои, которые по какой-либо причине больше не используются.
- При открытии тяжелых отчетов система может долго думать (секунд 5-15) пока запускается докер и выполняются запросы к БД. В этом плане хотелось бы большей бесшовности, но это, видимо, сугубо мои хотелки, т.к. конечных пользователей данный факт не смущает.
- У некоторых пользователей при первом открытии отчета, иногда по неведомой причине, система может выдавать ошибку о том, что адрес не отвечает, т.к. докер контейнер не успел загрузиться, что конечного пользователя вводит в заблуждение. При повторном открытии отчета данная проблема пропадает.
- Создание отчётов не получиться доверить "абы кому" т.к. нужны знания R. И если придет новый аналитик, то он должен быть либо изначально со знаниями R, либо должен будет потратить время на его изучение уже на рабочем месте.
- После внесения правок в отчет или добавление нового отчета приходится перезагружать службу ShinyProxy на сервере.
Вывод
В целом данная система отчетности показала себя хорошо. За год ее использования не было замечено каких-то критически важных нерешаемых проблем. Конечного бизнес-пользователя все устраивает, т.к. можно реализовать любой функционал отчета. В большинстве случаев можно оптимизировать отчет и запросы в нем так, чтобы он быстро выполнялся. Кроме того, мы сэкономили средства за счет отказа от Looker'a, Qlick’a и серверов, т.к перевели разную отчётность на одну систему.
На данный момент отчетностью пользуются 30 человек ежедневно. В среднем один пользователь открывает 3 отчета. Общее количество пользователей – 200 человек, общее количество отчетов – 83 штуки.
Большинство отчетов было создано 1-3 аналитиками в течение полугода. На данный момент отчетность поддерживается преимущественно одним аналитиком.