Streamlitで作ったアプリのEC2ホスティング&デプロイ自動化

# はじめに
Streamlitで作った簡易アプリをメンバーが気軽に開発できる形で共有しようと思い、自前でデプロイパイプラインを組むことなく自動デプロイが可能な[AWS App Runner](https://aws.amazon.com/jp/apprunner/)の利用を検討したのですが、どうやら現状だとApp RunnerがWebSocketに対応しておらずStreamlitが動かなそうということだったので、ひとまず勉強がてらEC2上にStreamlit実行環境を整備し、CodePipeline + CodeDeployでデプロイパイプラインを構築しました。その際の備忘録となります。

## やりたいこと
- セキュアでなくてよいのでStreamlitアプリをWebに公開したい
- gitプッシュだけでデプロイできる環境を構築したい
- AWSを使いたい

# 準備

### GitHubリポジトリ
以下のようなディレクトリ構成のStreamlitアプリが`my-app`というリポジトリ名でGitHubで管理されているとします。
```
my-app
├ scripts
│ ├ start_server.sh
│ └ stop_server.sh
├ main.py
├ run.sh
└ appspec.yml
```

`main.py`は以下のようにStreamlitでHello Worldを出力するだけのコードです。
```main.py
import streamlit as st
st.title('Hello, World!')
```

また`run.sh`はアプリの自動復旧に利用するスクリプトで、そのほかはデプロイパイプラインの実装時に作成するファイルとなります（後述）。

### GitHubアクセストークン
デプロイパイプラインでGitHubからソースコードを取得する際にアクセストークンが必要となるため、GitHubのDeveloper settingsのページから取得しておきます。

### AWS CLI
あとで利用するので、Homebrewなどで予めインストールしておきます。
```
$ brew install awscli
```


# EC2でホスティングする
まずはEC2インスタンス上でアプリ起動してブラウザから閲覧できるようにするところまでを実装します。

## インスタンスの立ち上げ
AWSコンソールからEC2インスタンスを手動で立ち上げます。今回はおおまかに以下のような設定で立ち上げました。
|項目 |内容 |
|---|---|
|名前とタグ |`my-app`とした。CodeDeployでのデプロイ対象を指定する際に利用。　|
|AMI |Amazon Linux 2 Kernel 5.10 |
|インスタンスタイプ |t2.nano |
|セキュリティグループ |`セキュリティグループを作成する`を選択し、新規作成。 |
|ボリューム |1 ボリューム - 8 GiB|
|IAMロール |`AmazonS3ReadOnlyAccess`ポリシーを持つロールを新規作成し付与。あとでCodeDeployと連携する際に利用。|


立ち上げ作業中にキーペア設定が要求され、キーペアファイルがローカルに保存されるはずですので、権限を付与（`chmod 400`）した上で適切な場所に格納しておきます。あとでSSH接続する際に使います。

## インバウンドルールの設定
EC2インスタンスの立ち上げ時に作成した新しいセキュリティグループに対してインバウンドルールの設定を行います。
Streamlitはデフォルトで8501番ポートを利用するため、カスタムTCPとして開けておきます。また「ソース」として送信元IPアドレスを制限することが可能なので、必要に応じて設定します。
![スクリーンショット 2023-02-16 14.53.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283041/a5f3b44f-6e55-0975-8b73-d68aeb6d77cb.png)



## アプリを立ち上げてみる

ローカルにある`my-app`ディレクトリをEC2インスタンス上にコピーします。
ユーザー名はAmazon Linuxであればデフォルトで`ec2-user`ですので、これでログインします。`xxx.xxx.xxx.xxx`にはEC2インスタンスに割り当てられたパブリックIPアドレスを指定します。
```bash
# my-appディレクトリをEC2インスタンス上にコピー
$ scp -r -i <キーペアファイルのパス> ./my-app ec2-user@xxx.xxx.xxx.xxx:/home/ec2-user/
```

次にEC2インスタンスにSSH接続（`ssh -i <キーペアファイルのパス> ec2-user@xxx.xxx.xxx.xxx:/home/ec2-user/my-app`）した上で、以下を実行します。
```bash
# pipでStreamlitをインストール
$ pip3 install streamlit
```

```bash
# アプリを実行
$ cd /home/ec2-user/my-app
$ streamlit run main.py
```

上記実行後、パブリックIPアドレスの8501番ポートにアクセスして以下のように表示されればOKです。
<img width="75%" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283041/7b8382eb-5e7a-b2a7-950c-ea6cb4364e6d.png">



## 自動復旧させる

万が一アプリケーションのプロセスが落ちても自動復旧できるよう、`systemd`を利用した自動復旧の仕組みを構築します。
まずアプリケーション実行用のスクリプト`run.sh`をmy-appディレクトリ配下に作成し、`chmod 755 run.sh`しておきます。
    
```run.sh
#!/bin/sh
~/.local/bin/streamlit run main.py  # systemdから実行するため、streamlitの実行ファイルはフルパスで指定
```

次に、`run.sh`をシステムのデーモンから実行できるようにするため、`/etc/systemd/system/`配下に`my-app.service`ファイルを作成します。

```my-app.service
[Unit]
Description=Run my-app
After=network.target

[Service]
WorkingDirectory=/home/ec2-user/my-app
ExecStart=/home/ec2-user/my-app/run.sh
Restart=always
User=ec2-user

[Install]
WantedBy=multi-user.target
```

最後に以下を実行してアプリ起動 & 自動実行の設定をします。
```bash
$ sudo systemctl start my-app.service  # サービスを起動
$ sudo systemctl enable my-app.service  # サービスの自動実行を有効化
```

これによりデーモンが立ち上がり、アプリが落ちていても自動復旧するようになります。


# CodePipeline + CodeDeployで自動デプロイ
このままだとデプロイ作業が面倒なため、ここからはGitHubリポジトリへのプッシュをトリガーにして自動デプロイが実行されるような環境を構築していきます。今回はCodePipelineとCodeDeployを利用します。

## CodeDeploy Agentのインストール
まずはCodeDeployからEC2インスタンスへのデプロイを行えるようにするため、EC2インスタンスにSSH接続してCodeDeploy Agentをインストールしておきます。
[こちら](https://qiita.com/yukofeb/items/e077fc8755416c904032)を参考に、EC2インスタンス上で以下を実行します。

```bash
$ sudo yum update
$ sudo yum install ruby
$ sudo yum install aws-cli
$ cd /home/ec2-user
$ aws s3 cp s3://aws-codedeploy-ap-northeast-1/latest/install . --region ap-northeast-1  # 東京リージョンの場合
$ chmod +x ./install
$ sudo ./install auto
```

さらに以下を実行し、CodeDeploy Agentが起動しているかを確認します。
```
$ sudo service codedeploy-agent status
```
`The AWS CodeDeploy agent is running as PID xxxx`のような応答があればOKです。


## フックスクリプトとappspec.ymlを作成
CodeDeployではデプロイのタイミングで発生するイベントをトリガーとして任意のスクリプトを実行することが出来ます。これを利用して、systemdからアプリを一時停止・再開するための仕組みを作ります。
以下のように`stop_server.sh`と`start_server.sh`を作成し、`my-app/scripts`ディレクトリ配下に置きます。
```stop_server.sh
#!/bin/sh

sudo systemctl disable my-app.service
sudo systemctl stop my-app.service
```

```start_server.sh
#!/bin/sh

sudo systemctl enable my-app.service
sudo systemctl start my-app.service
```

さらに、`appspec.yml`というCodeDeployでのデプロイ処理を制御するための設定ファイルを`my-app`ディレクトリ配下に作成します。先ほど作成したスクリプトはhooksという定義から参照されていますが、これによりデプロイ時のイベント（この例ではBeforeInstallおよびAfterInstall）をトリガーとして実行されるようになります。
デプロイ時に発生する各種イベントの詳細については[こちら](https://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html)をご参照ください。

```appspec.yml
version: 0.0
os: linux
files:
  - source: /
    destination: /home/ec2-user/my-app
hooks:
  BeforeInstall:
    - location: scripts/stop_server.sh
  AfterInstall:
    - location: scripts/start_server.sh
file_exists_behavior: OVERWRITE
```


## CloudFormationでパイプラインをデプロイ
ここまででCodeDeployを利用するための準備が終わったので、ここからはCodePipelineリソースとCodeDeployリソースをそれぞれ作成していきます。ただしAWSコンソールから手作業で設定するのは結構大変なため、ここではCloudFormationを利用し、スタックとしてまとめて立ち上げます。CloudFormationテンプレートは以下のようになります。


```pipeline_template.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: deploy pipeline for my-app

Parameters:
  AppName:
    Type: String  # アプリケーション名
  Branch:
    Type: String  # デプロイのトリガーとするブランチ名
  Owner:
    Type: String  # リポジトリのオーナー名
  Repo:
    Type: String  # リポジトリ名
  OAuthToken:
    Type: String  # GitHubのDeveloper settingsから発行したアクセストークン
    NoEcho: true

Resources:
  # S3::Bucketの定義。CodePipelineのSourceフェーズでGitHubからダウンロードしたソースコードを保持するために利用。
  ArtifactStoreBucket:
    Type: AWS::S3::Bucket
    Properties:
      VersioningConfiguration:
        Status: Enabled

  # CodeDeploy::Applicationの定義
  CodeDeployApplication:
    Type: AWS::CodeDeploy::Application
    Properties:
      ApplicationName: !Ref AppName
      ComputePlatform: Server

  # CodeDeploy::DeploymentGroupの定義
  CodeDeployDeploymentGroup:
    Type: AWS::CodeDeploy::DeploymentGroup
    DependsOn: CodeDeployRole
    Properties:
      ApplicationName: !Ref CodeDeployApplication
      DeploymentGroupName: !Sub ${CodeDeployApplication}-DeployGroup
      DeploymentConfigName: CodeDeployDefault.AllAtOnce
      ServiceRoleArn: !Sub arn:aws:iam::${AWS::AccountId}:role/${CodeDeployApplication}-CodeDeployRole
      DeploymentStyle:
        DeploymentType: IN_PLACE
        DeploymentOption: WITHOUT_TRAFFIC_CONTROL
      Ec2TagFilters:
        - Type: VALUE_ONLY
          Value: !Ref AppName  # デプロイ対象となるEC2インスタンスのタグ

  # CodePipeline::Pipelineの定義。今回は最小構成のため、SourceフェーズとDeployフェーズの２段階のみ。
  CodePipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: !Sub ${CodeDeployApplication}-Pipeline
      RoleArn: !GetAtt CodePipelineRole.Arn
      ArtifactStore:
        Location: !Ref ArtifactStoreBucket
        Type: S3
      Stages:
        - Name: Source
          Actions:
            - Name: Source
              ActionTypeId:
                Category: Source
                Owner: ThirdParty
                Provider: GitHub
                Version: 1
              Configuration:
                Branch: !Ref Branch
                OAuthToken: !Ref OAuthToken
                Owner: !Ref Owner
                PollForSourceChanges: true
                Repo: !Ref Repo
              OutputArtifacts:
                - Name: SourceArtifact
              Region: !Ref AWS::Region
              Namespace: SourceVariables
              RunOrder: 1
        - Name: Deploy
          Actions:
            - Name: Deploy
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Provider: CodeDeploy
                Version: 1
              Configuration:
                ApplicationName: !Ref CodeDeployApplication
                DeploymentGroupName: !Sub ${CodeDeployApplication}-DeployGroup
              InputArtifacts:
                - Name: SourceArtifact
              Region: !Ref AWS::Region
              Namespace: DeployVariables
              RunOrder: 1

  # CodePipeline用のロール
  CodePipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: codepipeline.amazonaws.com
      Path: /
      Policies:
        - PolicyName: CodePipelineAccess
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - s3:*
                  - iam:PassRole
                  - codedeploy:CreateDeployment
                  - codedeploy:GetApplication
                  - codedeploy:GetApplicationRevision
                  - codedeploy:GetDeployment
                  - codedeploy:GetDeploymentConfig
                  - codedeploy:RegisterApplicationRevision
                Resource: "*"

  # CodeDeploy用のロール
  CodeDeployRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      RoleName: !Sub ${CodeDeployApplication}-CodeDeployRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: codedeploy.amazonaws.com
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonS3FullAccess
        - arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole
```


上記テンプレートをデプロイするため、以下のAWS CLIコマンドを実行します。
（今回は`APPNAME`と`REPO`はともに`my-app`とし、`BRANCH`はmainを指定しました）

```bash
$ aws cloudformation create-stack \
    --stack-name ${APPNAME}-pipeline-stack \
    --template-body file://pipeline_template.yml \
    --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \
    --parameters \
    ParameterKey=OAuthToken,ParameterValue="${TOKEN}" \  # GitHubのDeveloper settingsから発行したアクセストークン
    ParameterKey=Branch,ParameterValue="${BRANCH}" \  # デプロイのトリガーとなるブランチ名
    ParameterKey=Owner,ParameterValue="${OWNER}" \  # リポジトリのオーナー名
    ParameterKey=Repo,ParameterValue="${REPO}" \  # リポジトリ名
    ParameterKey=AppName,ParameterValue="${APPNAME}"  # アプリケーション名
```

上記実行後、CloudFormationのコンソール画面にて、作成したスタック（`my-app-stack`）のステータスが`CREATE_COMPLETE`になっていればOKです。
![スクリーンショット 2023-02-16 11.58.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283041/152317da-9a2b-721c-a82b-545d8fa563ea.png)

またCodePipelineのコンソール画面から、新たに`my-app-Pipeline`というパイプラインが作成されていることも確認できます。
![スクリーンショット 2023-02-16 12.19.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283041/e76d73ae-d5f0-4f21-31f5-af31bb9610fc.png)


## 動作確認する

パイプラインが正しく動作するか確認してみます。
`main.py`を以下のように書き換えてmainブランチにpushします。
```diff_python
import streamlit as st
- st.title('Hello, World!')
+ st.title('Hello, World!!!!!!!!')
```

作成した`my-app-Pipeline`のコンソール画面からデプロイ進捗を見守り、、
<img width="75%" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283041/4ad8cf49-8f45-2c0e-d6b1-f0bf045ebb83.png">

Deployフェーズが成功したことを確認後にブラウザからアクセスしたところ、ちゃんと動いてそうです。
<img width="75%" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283041/6ae62ae6-6899-773a-3365-f80922883ce5.png">


# おわりに
今回は勉強を兼ねての実装でしたが、実際デプロイパイプラインの構築は多少面倒なため、可能であればStreamlitのComunity CloudやHerokuなどのサービスを使ったほうが楽なのかもしれません。またSystemdでの自動復旧、デプロイ時の実行スクリプトからサービス一時停止・再開などはかなり手探りで組んだのですが、このあたりはもっと良いやり方がありそうです。

# 参考
https://docs.aws.amazon.com/ja_jp/codepipeline/latest/userguide/tutorials-simple-codecommit.html

https://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html

https://qiita.com/yukofeb/items/e077fc8755416c904032

https://qiita.com/ytaka95/items/5899c44c85e71fdc5273