ShinyProxyを使ったEnterprise向けShiny Appの構築

## 更新履歴 
* 2019-12-25 : "Kubernetes モードを試してみる" を追加
* 2019-12-10 : "Containerized モードを試してみる" を追加


## はじめに

アクセスする人数が多くなってきたときに、どうやってShiny Appをスケールさせるかなーと考えていたときに、たまたまこのOSSを見つけたので検証してみました

## Shiny Server の運用上の課題
* OSSのShiny Serverでは、1つのShinyappが 1 processで動くため、複数人のユーザでアクセスすると、急に動きがもっさりしがちです。例えば、ユーザA、Bの順でアクセスして、データをロードしはじめると、ユーザAの処理が終わるまで、ユーザBは待たないといけないので、待っている間ストレスがかかります
* OSSのShiny Server自体では、認証の機能を持っていないので（Shiny Server Proが必要）、別途Reverse Proxyとして、認証機能のWebサーバ（nginxなど）を構築する必要がありました

## ShinyProxyとは？

自分の言葉でいうなら、

* 事前にビルドしたShiny Appのdocker imageを用意しておき、ShinyProxyにひもづけておけば、そのShiny Appにアクセスしたときに、**ユーザごとにそのimageのdocker containerが立ち上がる** デプロイ環境を提供してくれる素敵なプロダクトです
    * 結果、1 userあたり、1 Shinyapp = 1 container の構成になり、ユーザが互いにボトルネックにならない環境を用意できる
* ユーザの認証機能として、複数のProtocol（LDAP、Kerberosなど）も利用できます。すごい
* ShinyProxy自体はJavaのSpring bootで実装されている
* Githubのリリースをみると、2016年くらいから開発しているみたいで、今年になってもアップデートされている（2019-06-22にver. 2.3.0がリリースされている）
*  Apache License 2.0 

ManualにもShiny AppをEnterprise化するためのOSSであると強調しており、企業でShiny Appを使うときの問題を解決しようとOpen Analyticsという団体？が取り組んでいるようです。[Open Analytics](https://www.openanalytics.eu/)の活動は、[Twitter](https://twitter.com/openanalytics)とかで眺めてみると良いと思います（自分も今日フォローしました） 

先にマニュアルを以下に載せておきます

### 公式マニュアル
* https://www.shinyproxy.io
* https://github.com/openanalytics/shinyproxy


### Open Analytics Community Support 
* バグや質問はここでもよく議論されている : https://support.openanalytics.eu/c/shinyproxy/l/latest?no_subcategories=false&page=5

### Github
* ConfigurationのExamples集 : https://github.com/openanalytics/shinyproxy-config-examples
    * htmlファイルのテンプレート集 : https://github.com/openanalytics/shinyproxy-config-examples/tree/master/04-custom-html-template 
* Demoで使用するshiny Appのdocker image : https://github.com/openanalytics/shinyproxy-demo
* ShinyProxyで動かすShiny Appのdocker imageのtemplate : https://github.com/openanalytics/shinyproxy-template

## ShinyProxyの構築

### Standalone vs containerized

ShinyProxyは2つのデプロイ方法があります

* Standalone モード : javaが動くHost自体にshinyproxyをインストールして使う
* containerized モード : shinyproxy自体もcontainerとして動かす

### single node vs swarm vs kubernetes 

また、Shiny AppをDocker containerとしてデプロイするときに、一番基本となるdocker engineがインストールされているHostにsingle nodeとしてデプロイする他、swarmやkubernetesを使ったdeployもできるみたいです。kubernetes対応やCloudサービス対応もすでに例があるようなので、完成度が高い印象を受けました

## Standaloneモードを試してみる

### Install and Launch

2019/08/06時点で最新のversion 2.3.0をインストールしていきます。Cent OS 7系を使用しています。Dockerのインストール方法は省略します。

注意点としては、docker hostにShinyProxyからアクセスしてコンテナを立ち上げる操作が発生するので、[Docker startup options](https://www.shinyproxy.io/getting-started/#docker-startup-options)の設定を事前に実施しておいてください

```console
$ wget https://www.shinyproxy.io/downloads/shinyproxy_2.3.0_x86_64.rpm
$ sudo rpm -U shinyproxy_2.3.0_x86_64.rpm

$ sudo systemctl enable shinyproxy
$ sudo systemctl start shinyproxy
$ sudo systemctl status shinyproxy
● shinyproxy.service - ShinyProxy
   Loaded: loaded (/etc/systemd/system/shinyproxy.service; enabled; vendor preset: disabled)
   Active: active (running) since 火 2019-08-06 10:25:52 JST; 2min 16s ago
 Main PID: 82468 (java)
    Tasks: 18
   Memory: 517.8M
   CGroup: /system.slice/shinyproxy.service
           └─82468 /usr/bin/java -jar /opt/shinyproxy/shinyproxy.jar
<snip>
```

インストールしたHostにアクセス（例えば、http://10.1.1.1:8080 とか）してログイン画面が出ればOK。（User nameなんだろとなるが、一旦ログインはスキップ）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/69266/d7055837-d13c-c071-16fe-d3465a56d8aa.png" width=70%>

### demo image を pull

デモ用に動かすdocker imageをpullしておきます

```console
$ sudo docker pull openanalytics/shinyproxy-demo
```

### Configuration

`application.yml` というシングルファイルで管理します。設定ファイルはrpmでインストールした場合は、`/etc/shinyproxy`配下に置けばOK

https://www.shinyproxy.io/configuration/ の default configからauthenticationを`simple`に変更して配置します。各項目の詳細は、リンク先を参照してください

```console
$ sudo vim /etc/shinyproxy/application.yml
proxy:
  title: OKIYUKI Shiny Proxy
  logo-url: http://www.openanalytics.eu/sites/www.openanalytics.eu/themes/oa/logo.png
  landing-page: /
  heartbeat-rate: 10000
  heartbeat-timeout: 60000
  port: 8080
  authentication: simple
  admin-groups: scientists

  # Example: 'simple' authentication configuration
  users:
  - name: jack
    password: password
    groups: scientists
  - name: jeff
    password: password
    groups: mathematicians

  # Docker configuration
  docker:
    cert-path: /home/none
    url: http://localhost:2375
    port-range-start: 20000
  specs:
  - id: 01_hello
    display-name: Hello Application
    description: Application which demonstrates the basics of a Shiny app
    container-cmd: ["R", "-e", "shinyproxy::run_01_hello()"]
    container-image: openanalytics/shinyproxy-demo
    access-groups: [scientists, mathematicians]
  - id: 06_tabsets
    container-cmd: ["R", "-e", "shinyproxy::run_06_tabsets()"]
    container-image: openanalytics/shinyproxy-demo
    access-groups: scientists

logging:
  file:
    /var/log/shinyproxy/shinyproxy.log

# For Logging
$ sudo mkdir -p /var/log/shinyproxy
$ sudo chown shinyproxy:shinyproxy /var/log/shinyproxy
```

### restart

```console
$ sudo systemctl restart shinyproxy

# stop 
# sudo systemctl stop shinyproxy
```

再度アクセスして、jackでログインしてみます。

アクセスできるShiny Appの一覧が以下のように見れます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/69266/6ba3ca00-f3ee-877b-b7af-cecfb5b5f134.png)

例えば、ここで Hello Applicationにアクセスしてみます。

そうすると、以下のような Launching ... という画面が数秒見えたあとで、

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/69266/522bc2fa-6ce4-2dda-fb50-d194589651e5.png" width=30%>

Hello ApplicationのShiny Appが立ち上がります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/69266/6addaab8-45f9-b498-9aa5-a6df8654944e.png)

ここで、Docker Hostで`docker ps`をしてみると、以下のように、Shiny Appのコンテナが立っていることが確認できました

```console
$ sudo docker ps --no-trunc
CONTAINER ID                                                       IMAGE                           COMMAND                             CREATED             STATUS              PORTS                     NAMES
4fd3b5bcaee638093c8696ab9dadc42175eddb5fb7ef27a2b53c2ff959721d98   openanalytics/shinyproxy-demo   "R -e shinyproxy::run_01_hello()"   2 minutes ago       Up 2 minutes        0.0.0.0:20000->3838/tcp   jolly_satoshi
```

さらに、この状態のまま、06_tabsetsのShiny Appにアクセスする。再び`docker ps`をすると、2つのコンテナが立っていることが分かります

```console
$ sudo docker ps --no-trunc
CONTAINER ID                                                       IMAGE                           COMMAND                               CREATED             STATUS              PORTS                     NAMES
c925ae0f43d35cc3e2c41d58153382d6178afb54cadd919b6dac0ab1cd675419   openanalytics/shinyproxy-demo   "R -e shinyproxy::run_06_tabsets()"   13 seconds ago      Up 12 seconds       0.0.0.0:20001->3838/tcp   happy_goldwasser
4fd3b5bcaee638093c8696ab9dadc42175eddb5fb7ef27a2b53c2ff959721d98   openanalytics/shinyproxy-demo   "R -e shinyproxy::run_01_hello()"     4 minutes ago       Up 4 minutes        0.0.0.0:20000->3838/tcp   jolly_satoshi
```

複数人でのアクセスを想定して、この状態のまま別のブラウザから、今度はjeffでログインして、Hello Applicationにアクセスしてみる。再び`docker ps`をすると、3つのコンテナが立っていることが分かる。ユーザごとに1 app = 1 containerになっていることが確認できました

```console
$ sudo docker ps --no-trunc
CONTAINER ID                                                       IMAGE                           COMMAND                               CREATED             STATUS              PORTS                     NAMES
bae351ecb8f5992b301a7c7c7f0754a6e39001dcc017283b6a7bd6292c4f9b3b   openanalytics/shinyproxy-demo   "R -e shinyproxy::run_01_hello()"     17 seconds ago      Up 16 seconds       0.0.0.0:20002->3838/tcp   musing_dijkstra
c925ae0f43d35cc3e2c41d58153382d6178afb54cadd919b6dac0ab1cd675419   openanalytics/shinyproxy-demo   "R -e shinyproxy::run_06_tabsets()"   2 minutes ago       Up 2 minutes        0.0.0.0:20001->3838/tcp   happy_goldwasser
4fd3b5bcaee638093c8696ab9dadc42175eddb5fb7ef27a2b53c2ff959721d98   openanalytics/shinyproxy-demo   "R -e shinyproxy::run_01_hello()"     6 minutes ago       Up 6 minutes        0.0.0.0:20000->3838/tcp   jolly_satoshi
```

また、jackのHello Applicationをブラウザで閉じると、そのコンテナがHostから消えることを確認できます。すぐに消えるわけではないので、その辺の細かい設定はなにかでできるのかもしれません。

## Containerized モードを試してみる

ShinyProxy自体もdocker containerとして動かすContainerized モードを試してみます。以下の公式のREADMEに沿ってやってみます

参考 : https://github.com/openanalytics/shinyproxy-config-examples/tree/master/02-containerized-docker-engine

### Dockerfile for ShinyProxyの作成

公式通りのDockerfileを作成します

```dockerfile
FROM openjdk:8-jre

RUN mkdir -p /opt/shinyproxy/
RUN wget https://www.shinyproxy.io/downloads/shinyproxy-2.3.0.jar -O /opt/shinyproxy/shinyproxy.jar
COPY application.yml /opt/shinyproxy/application.yml

WORKDIR /opt/shinyproxy/
CMD ["java", "-jar", "/opt/shinyproxy/shinyproxy.jar"]
```

### application.ymlの変更

Docker間ネットワーク内でShinyProxyからDockerアクセスできるように `application.yml` にdocker networkに関する設定を追加します。具体的には、以下の3箇所を変更します

```yml:application.yml
proxy:
  <snip>
  container-backend: docker
  <snip>

  # Docker configuration
  docker:
    cert-path: /home/none
    # url: http://localhost:2375   #(0) <--- comment out
    internal-networking: true      #(1) <--- add

  <snip>
  # app
  specs:
  - id: sample
    <snip> 
    container-network: sample-net  #(2) <--- add
    <snip>
```

Docker NetworkをBridgeモードでつなぐために、(1)と(2)が必要になります。(0)はもともと書いてなければ無視してください。(2)の定義にそってdocker networkを以下のように作成します

```console
$ docker network create --driver bridge sample-net
```

### Docker ImageのBuildとRun

上で用意したDockerfileをもとにDocker Imageをビルドします。

```console
$ docker build -t okiyuki/shiny-proxy:latest .
```

作成したImageをRunします。docker compose(v2)を利用した例が以下です

```yml
version: '2'
services:
  shiny-proxy:
    hostname: shiny-proxy
    container_name: shiny-proxy
    image: okiyuki/shiny-proxy:latest
    ports:
      - 8080:8080
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /var/log/shinyproxy:/var/log/shinyproxy
    networks:
      - sample-net

networks:
  sample-net:
    external: true
```

それを起動して完！

```console
$ docker-compose -f docker-shiny-proxy.yml up -d
```

## Kubernetes モードを試してみる

ShinyProxyをKubernetes(k8s)クラスタ内で起動させることで、Shinyコンテナをよりk8sクラスタ上で動かすことが可能になります。以下の公式のREADMEに沿ってやってみます。少し長い上にいくつかハマりポイントがあるので適宜補足します

参考 : https://github.com/openanalytics/shinyproxy-config-examples/tree/master/03-containerized-kubernetes

k8sクラスタの構築完了済みで、`kubectl`でクラスタを操作できるところまでは省略します。

ちなみに、k8sのことはググるか、[Kubernetes完全ガイド impress top gearシリーズ](https://www.amazon.co.jp/dp/B07HFS7TDT/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)を読むことをおすすめします

### Docker image for kube-proxy-sidecar のBuild

`kube-proxy-sidecar`はShinyProxyからk8s上のクラスタにShinyコンテナ(k8sでいうPod）を立ち上げるために、`kubectl`がインストールされたDocker imageです。

```Dockerfile
FROM alpine:3.6
ADD https://storage.googleapis.com/kubernetes-release/release/v1.17.0/bin/linux/amd64/kubectl /usr/local/bin/kubectl
RUN chmod +x /usr/local/bin/kubectl
EXPOSE 8001
ENTRYPOINT ["/usr/local/bin/kubectl", "proxy"]
```

Dockerfile作成後は、いつもどおりDocker imageをbuildしておきます。

```console
$ docker build -t okiyuki/kube-proxy-sidecar:latest .
```

### Docker image for ShinyProxy のBuild

Dockerfileは "Containerized モードを試してみる" で紹介したときと同じDockerfileなので省略します。


k8s用にapplication.ymlを変更しましょう。`container-backend`を`k8s`に変更して、k8s用のapplication.ymlを用意します。以下の4つの箇所が変更ポイントです。


```yml:application.yml
proxy:
  <snip>
  container-backend: kubernetes
  <snip>

  # kubernetes configuration
  kubernetes:
    internal-networking: true       # <--- (1) add
    url: http://localhost:8001      # <--- (2) add
    namespace: example              # <--- (3) add
    image-pull-policy: IfNotPresent # <--- (4) add
  <snip>
```

(1) ~ (3)はk8s上でPod(Shinyコンテナ)を構築するために必要なものですので、一旦追加するものと考えてください。(4)はk8sのマニフェストで変更するため、ここでは特に不要ですが、Podを構築するホストがそのShinyコンテナのDocker Imageを持ってないときはDocker pullするよっていう意味です。明示的に書いています

あとはいつもどおりDocker imageをビルドします

```console
$ docker build -t okiyuki/shiny-proxy-k8s:latest .
```

### Roleリソースに関するマニフェストの適用

ここでは、テストのため `cluster-admin`のRole（何でもできるRole）を`default`のService Accountに与えます。分析環境によってはRoleを制限する必要があると思いますので、以下を参考に適宜変更してください

参考 : [shinyproxy-config-examples/03-containerized-kubernetes at master · openanalytics/shinyproxy-config-examples](https://github.com/openanalytics/shinyproxy-config-examples/tree/master/03-containerized-kubernetes#notes-on-the-configuration)


```yml:authorization.yml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: shinyproxy-auth
subjects:
  - kind: ServiceAccount
    name: default
    namespace: default
  - kind: ServiceAccount
    name: default
    namespace: example
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io
```

マニフェストを適用します。これでServiceAccountにClusterRoleBindingのRoleが付与されました

```console
$ kubectl create -f authorization.yaml
```

### Service と Deploymentリソースに関するマニフェストの適用

ここではもっともシンプルにnodePortを使って、外からk8sクラスタ上のShinyProxyコンテナが見えるように、30080番ポートを外にさらすようにServiceリソースを定義します

次にShinyProxyコンテナと最初に用意したkube-proxyコンテナをDeploymentリソースの上に定義します。8001番はShinyProxyの`application.yml`でも記述したポート番号だったことを覚えていますでしょうか。これにより、8001番に立ったkube-proxyコンテナに命令を行い、Podを立てたり、消したりしてくれることが可能になります

```yml:shinyproxy.yml
$ cat dev/shinyproxy.yaml
apiVersion: v1
kind: Service
metadata:
  name: shinyproxy
  namespace: example
spec:
  type: NodePort
  selector:
    run: shinyproxy
  ports:
  - protocol: TCP
    port: 8080
    targetPort: 8080
    nodePort: 30080

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shinyproxy
  namespace: example
spec:
  selector:
    matchLabels:
      run: shinyproxy
  replicas: 1
  template:
    metadata:
      labels:
        run: shinyproxy
    spec:
      containers:
      - name: shinyproxy
        image: okiyuki/shiny-proxy-k8s:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
      - name: kube-proxy-sidecar
        image: okiyuki/kube-proxy-sidecar:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 8001
```

マニフェストを適用します。これでServiceとDeploymentが付与されました

```console
$ kubectl create -f shinyproxy.yaml
```

ちなみに、`replicas : 1` にしていますが、これを2以上に増やすことで、複数ノードに分散配置されますが、1点注意点があります。以下のSupportでも上がっていますが、ただ数字を増やすだけではうまく起動しません。というのも、ShinyProxy自体がステートフルなアプリであるため、Serviceリソース内でsticky sessionを有効にする必要があります。

イメージとしては、ブラウザからShinyProxyにアクセスしたときのセッションがどのノードとやり取りしてるものかを維持しないと、ShinyProxyがうまく動かないようです。必要な設定は、Serviceに`sessionAffinity: ClientIP`を追加すればとりあえずOKのようです。しかし、詳細な内容は調べてみてください（自分も理解が足りてないところがあります...）

参考 : [Setting kubernetes pod fields and using multiple replica sets - ShinyProxy - Open Analytics Community Support](https://support.openanalytics.eu/t/setting-kubernetes-pod-fields-and-using-multiple-replica-sets/783)

これで完了です！おつかれさまでした

## おまけ

実際に使用するときに、他に設定したり試したりしたことを載せておきます

### nginxの設定

指定したドメインからShinyProxyにアクセスする場合は、リバースプロキシとしてnginxを利用するのが良いかと思います。設定方法は、以下の記事がそのまま参考になるかと思います。

* 公式マニュアルの[Security](https://www.shinyproxy.io/security/#https-ssl-tls)
* [How to Use Shiny Containers with Shinyproxy](https://lukesingham.com/shiny-containers-with-shinyproxy/)

### Docker Imageの用意

ShinyProxyで動かすShiny Appのdocker imageのtemplateは https://github.com/openanalytics/shinyproxy-template のURLが参考になるかと思います。Docker Imageの中に、Shiny Appに必要なRのライブラリをインストールしておき、ui.RなどのファイルをすべてCOPYで配置し、docker buildでdocker imageを作成すればOKかと思います

### loginページのカスタマイズ（2019/08/15追記）

デフォルトのログインページのタイトル等を変えたいときは簡単に編集できます

1. <https://support.openanalytics.eu/t/login-page-customization/727> の議論を参考に、shinyproxyのjarファイルを解凍して、`login.html` をダウンロードします。タイトル等の中身を編集します
2. <https://github.com/openanalytics/shinyproxy-config-examples/tree/master/04-custom-html-template> の`templates/`配下をダウンロードし、`application.yml` と同じ階層に置きます
3. `application.yml`に`template-path: templates/2col` と記述します。そうすると、`templates/2col/`配下のテンプレートが自動で使用されます
4. <https://github.com/openanalytics/shinyproxy-config-examples/issues/6> のIssueであがってるとおり、なぜか`login.html`のテンプレートが`templates/`に入っていないので、1.で編集した`login.html`を`templates/2col/`に移動してきます
5. shinyproxyを再起動して完了です

最終的なフォルダ構成は以下になってると思います

```console
$ tree .
.
├── application.yml
└── templates
    └── 2col
        ├── assets
        │   ├── css
        │   │   └── 2-col-portfolio.css
        │   └── img
        │       ├── 01_hello.png
        │       └── 06_tabsets.png
        ├── index.html
        └── login.html
```

### よくやりそうな `application.yml` の設定
* `application.yml`で、`authentication: none`にすることで、認証を無くすこともできる。認証機能が不要な場合はこれで十分かと思われる
* topにあるnavigation bar が不要だなと思ったら、`application.yml`で`hide-navbar: true`に設定すればOK。これで見た目は普通のShiny Serverのように使えるので、ユーザは違和感を覚えないと思います
    * 参考 : https://support.openanalytics.eu/t/option-to-hide-the-navbar/171 
* faviconの設定方法
    * https://support.openanalytics.eu/t/how-to-change-the-favicon/342
    * https://github.com/openanalytics/shinyproxy/issues/163

### Assets（CSSやJS）のLoadが失敗する503エラーの原因とは?

ShinyProxyを例えばChromeの開発者ツールを通してNetworkを見ていると、CSS等のファイルが503エラーによりロードされない事象に出くわすかもしれません（それもランダムに読めたり、読めなかったりします）。

これの原因は以下の2つ目のリンクのIssueで議論されていますが、httpsでの通信で`HTTP/2`を有効にしていると起こるようです。理由はまだShinyProxy or ShinyのPerformace限界でしょうか(？)。実際自分が運用しているShinyProxyでも`HTTP/2`設定をOFFにしたらこの手の503エラーが減りました。

* [Assets don’t load (HTTP 503) with ShinyProxy behind AWS Load Balancer](https://support.openanalytics.eu/t/assets-dont-load-http-503-with-shinyproxy-behind-aws-load-balancer/858)
* [503 Response being returned from Shinyproxy to Loadbalancer](https://github.com/openanalytics/shinyproxy/issues/65)


### LDAPを使ったuser levelのauthorizationは未実装
[In the ShinyProxy Configurations how can I limit particular apps for a specific user - ShinyProxy - Open Analytics Community Support](https://support.openanalytics.eu/t/in-the-shinyproxy-configurations-how-can-i-limit-particular-apps-for-a-specific-user/139) で議論されてますが、実装は進んでなさそう

## おわりに

ユーザ数が多くなってきて、Shiny Appにスケールが必要になってくる場面の選択肢として、シンプルで使いやすいとても便利なプロダクトだと感じました。また、すでにk8sクラスタ上でよりスケールさせる仕組みもあるので、Shinyを運用することになる人はぜひそこまで挑戦してほしいと思います