SpringBootアプリをGithubActionsでEC2にデプロイしてみた

# はじめに
[前回の記事](https://qiita.com/kilvis/items/a59351c4f27083b3956f) でGithubActionsを使ってBeanstalkへのデプロイが簡単に行えることがわかりました。
今回は実際に社内で運用しているEC2にデプロイできるworkflowを書いてみます。

せっかくなので、インスタンス作成直後のまっさらな状態のEC2に対しても、自動でセットアップしてデプロイできるようにしたいと思います。

# 前提
- パブリックサブネットに配置したEC2があること
- EC2にSSHできる秘密鍵があること
- Githubのアカウントがあること

# 開発環境
macOS Catalina
OpenJDK 1.8
SpringBoot 2.2.6
Gradle 6.3

#大まかな作業の流れ
1. デプロイ用のSpringBootアプリを用意する
2. RepositoryのSecretsに秘密鍵を登録する
3. workflowを書く
4. pushして自動デプロイを確認

# 1. デプロイ用のSpringBootアプリを用意する
まずはデプロイして動作確認するためのSpringBootのアプリを作成します。
例によってサクっと [spring initializr で GradleProject に lombok, Spring Web をdependenciesに追加して作成](https://start.spring.io/#!type=gradle-project&language=java&platformVersion=2.2.7.RELEASE&packaging=jar&jvmVersion=1.8&groupId=com.example&artifactId=actions&name=actions&description=Actions%20demo%20project%20for%20Spring%20Boot&packageName=com.example.actions&dependencies=lombok,web) します。

```gradle:build.gradle
plugins {
    id 'org.springframework.boot' version '2.2.6.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

test {
    useJUnitPlatform()
}
```

このプロジェクトに対し、次の変更を加えていきます。

#### 1-1. 完全な実行可能Jarのビルド設定を `build.gradle` に追加
デプロイするアプリをサービスとして実行するため、[完全に実行可能なJarを生成](https://spring.pleiades.io/spring-boot/docs/2.2.3.BUILD-SNAPSHOT/gradle-plugin/reference/html/#packaging-executable-configuring-launch-script) するようにします。
以下の `bootJar` タスクを `build.gradle` に追加します。

```gradle:build.gradle
bootJar {
    launchScript()
}
```

### 1-2. Controllerを作成
動作確認用の簡単なControllerを作成しておきます。

```java:DemoController.java

@RestController
public class DemoController {
    @GetMapping("/demo")
    public String demo() {
        return "Hello Actions demo!!";
    }
}
```

### 1-3. ポート番号80で待ち受け
EC2でHTTP通信を待ち受けるため `application.properties` を編集してポート80を設定します。

```properties:application.properties
server.port=80
```

以上でアプリの準備は完了です。

### 1-4. アプリの動作確認
EC2にデプロイする前に、アプリが正しく動作することを確認しておきます。
以下のコマンドで `jar` をビルドします。

```terminal
$ ./gradlew build
```

すると、 `build/libs` 内にjarが生成されるので、ローカルでアプリを起動してみます。
[完全に実行可能なJar](https://spring.pleiades.io/spring-boot/docs/2.2.3.BUILD-SNAPSHOT/gradle-plugin/reference/html/#packaging-executable-configuring-launch-script) で作成しているため、jarを直接叩いて実行できることを確認しておきます。

```terminal
$ ./build/libs/actions-0.0.1-SNAPSHOT.jar
```

起動後、ブラウザで `http://localhost/demo` にアクセスしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/8a23943e-7d79-7d20-da9d-a0636c936116.png)

上のイメージのように、先ほど作成したControllerのレスポンスが表示されていれば、アプリの準備は完了です。

#2. RepositoryのSecretsに秘密鍵を登録する
作成したプロジェクトをGithubにプッシュしてリポジトリを作成し、いよいよ `workflow` の作成を行います。
その前に、この `workflow` ではEC2へSSH接続を行うため、秘密鍵情報を `Secrets` に登録しておきます。

今回は `AWS_EC2_PRIVATE_KEY ` という名前で秘密鍵情報を登録します。
EC2インスタンス作成時に作った `〜.pem` ファイル、または `ssh-keygen` などで生成したキーペアの秘密鍵ファイルの中身のテキストをまるっとコピーして貼り付けます。
![スクリーンショット 2020-06-01 2.16.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/6bec424a-6389-75af-35ee-fc3f833a9b84.png)

# 3. workflowを書く
EC2へのデプロイのため、普段手作業でSCPとSSHでjarファイルのコピー＆サービスの(再)起動を行なっていたものを `workflow` 化しました。

やってることは、EC２に

- `java` をインストール
- `/var/apps/(アプリ名)/(アプリ名).jar` と `(アプリ名).conf` ファイルを配置
- `/etc/systemd/system/(アプリ名).service` ファイルを配置
- `sudo systemctl start(or restart) (アプリ名)` でサービス起動

です。

出来上がった `workflow` ファイルは以下になります。

```yaml:.github/workflows/ec2-deploy.yml
name: Gradle build and Deploy to ec2

on:
  push:
    branches: [ master ]

env:
  EC2_USER: 'ec2-user'
  EC2_HOST: (your ec2 public ip address.)
  SRC_PATH: 'build/libs/*.jar'
  DEST_DIR: '/var/apps'

  APP_NAME: 'actions'    # your app name
  JAVA_VERSION: '1.8'    # '1.8' or '11'
  JAVA_OPTS: '-Xms1024M -Xmx1024M'
  RUN_ARGS: '--spring.profiles.active=prod'

jobs:
  deploy:
    name: Gradle build and Deploy to ec2
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Set up JDK.
      uses: actions/setup-java@v1
      with:
        java-version: ${{ env.JAVA_VERSION }}

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    - name: Build with Gradle
      run: ./gradlew build

    - name: SCP EC2 Copy app file
      env:
        PRIVATE_KEY: ${{ secrets.AWS_EC2_PRIVATE_KEY }}
      run: |
        echo "$PRIVATE_KEY" > private_key && chmod 600 private_key
        ssh -t -o StrictHostKeyChecking=no -i private_key ${EC2_USER}@${EC2_HOST} "sudo mkdir -p $DEST_DIR/$APP_NAME && sudo chmod -R 777 $DEST_DIR/$APP_NAME"
        scp -i private_key ${SRC_PATH} ${EC2_USER}@${EC2_HOST}:${DEST_DIR}/${APP_NAME}/${APP_NAME}.jar

    - name: SSH EC2 Setup and Deploy
      uses: appleboy/ssh-action@v0.0.9
      with:
        key: ${{ secrets.AWS_EC2_PRIVATE_KEY }}
        username: ${{ env.EC2_USER }}
        host: ${{ env.EC2_HOST }}
        envs: DEST_DIR,JAVA_VERSION,APP_NAME,JAVA_OPTS,RUN_ARGS
        script: |
          echo "===== yum update ====="
          sudo yum update -y
          echo "===== check java install ====="
          if java -version 2>&1 >/dev/null | grep "java version\|openjdk version" ; then
            echo "already installed java."
          else
            echo "install java."
            TARGET=$(yum search java | grep "$JAVA_VERSION.*devel\.\|$JAVA_VERSION.*amazon-corretto\." | awk '{print $1}')
            echo "install target name -> $TARGET"
            sudo yum install -y ${TARGET}
            echo "JAVA_HOME=$(readlink -f /usr/bin/java | sed "s:bin/java::")" | sudo tee -a   /etc/profile
            source /etc/profile
          fi
          echo "===== check conf file ====="
          if [ -f ${DEST_DIR}/${APP_NAME}/${APP_NAME}.conf ]; then
          echo "already exist conf file for $APP_NAME"
          else
          echo "create conf file for $APP_NAME"
          cat <<EOL | sudo tee -a ${DEST_DIR}/${APP_NAME}/${APP_NAME}.conf
          export LANG="ja_JP.UTF8"
          JAVA_OPTS="$JAVA_OPTS"
          RUN_ARGS="$RUN_ARGS"
          EOL
          fi
          echo "===== check exist service file ====="
          if [ -f /etc/systemd/system/${APP_NAME}.service ]; then
          echo "already exist service file for $APP_NAME"
          else
          echo "create service file for $APP_NAME"
          cat <<EOL | sudo tee -a  /etc/systemd/system/${APP_NAME}.service
          [Unit]
          Description = ${APP_NAME} app

          [Service]
          ExecStart =  ${DEST_DIR}/${APP_NAME}/${APP_NAME}.jar
          Restart = always
          Type = simple
          User = root
          Group = root
          SuccessExitStatus = 143

          [Install]
          WantedBy = multi-user.target
          EOL
          fi
          echo "===== application (re)start ====="
          sudo systemctl daemon-reload
          if sudo systemctl status ${APP_NAME} 2>&1 | grep "Active: active (running)" ; then
            echo "${APP_NAME} app restart!!"
            sudo systemctl restart ${APP_NAME}
          else
            echo "${APP_NAME} app start!!"
            sudo systemctl start ${APP_NAME}
          fi
```

まっさらなEC2にもデプロイできるようにしたため、セットアップ系のSSHのコマンドラインが多いです。
以下に簡単に内容を説明します。

### workflowの内容
`master` ブランチへPush、マージされた場合にこのworkflowが動く様に設定しています。

```yaml
on:
  push:
    branches: [ master ]
```

以下は `workflow` 内で参照する環境変数です。
デプロイするアプリやサーバによって可変な部分を定義しています。

```yaml

env:
  EC2_USER: 'ec2-user'
  EC2_HOST: (your ec2 public ip address.)
  SRC_PATH: 'build/libs/*.jar'
  DEST_DIR: '/var/apps'

  APP_NAME: 'actions'    # your app name
  JAVA_VERSION: '1.8'    # '1.8' or '11'
  JAVA_OPTS: '-Xms1024M -Xmx1024M'
  RUN_ARGS: '--spring.profiles.active=prod'

```

`EC2_HOST` と `APP_NAME` はサーバ、アプリ毎に設定を変えて利用します。

| 定数名 | 説明 |
|:------|:----|
| EC2_USER | ec2へssh接続する時のユーザ名(ec2-userのままでOK) |
| EC2_HOST | **【要変更】** ec2のパブリックIPアドレス |
| SRC_PATH | ビルドされたjarファイルのパス |
| DEST_DIR | ec2側のアプリを配置するディレクトリのパス |
| APP_NAME | **【要変更】** アプリ名。gradleでビルドされた.jarを `${APP_NAME}.jar` にリネームして、ec2の `${DEST_DIR}` にコピーする |
| JAVA_VERSION | ビルドや実行する時のJavaのバージョン（'1.8'か'11'）。EC2にもこのバージョンのjavaがインストールされる。 |
| JAVA_OPTS | アプリ起動時のオプション |
| RUN_ARGS | アプリの起動引数。上記例ではSpringのプロファイルを `prod` に切り替えている |

以下は `./gradlew build` を実行するまでのStepです。
（ほぼ、公式の雛形 `Java with Gradle` のままです）

```yaml

jobs:
  deploy:
    name: Gradle build and Deploy to ec2
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Set up JDK.
      uses: actions/setup-java@v1
      with:
        java-version: ${{ env.JAVA_VERSION }}

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    - name: Build with Gradle
      run: ./gradlew build

```

以下で、ビルドした `.jar` ファイルを `ssh` と `scp` でEC2サーバーにコピーします。
ここで先ほど登録した秘密鍵情報 `AWS_EC2_PRIVATE_KEY` からファイルを復元して `ssh` と `scp` の秘密鍵ファイルとして指定します。

```yaml

    - name: SCP EC2 Copy app file
      env:
        PRIVATE_KEY: ${{ secrets.AWS_EC2_PRIVATE_KEY }}
      run: |
        echo "$PRIVATE_KEY" > private_key && chmod 600 private_key
        ssh -t -o StrictHostKeyChecking=no -i private_key ${EC2_USER}@${EC2_HOST} "sudo mkdir -p $DEST_DIR/$APP_NAME && sudo chmod -R 777 $DEST_DIR/$APP_NAME"
        scp -i private_key ${SRC_PATH} ${EC2_USER}@${EC2_HOST}:${DEST_DIR}/${APP_NAME}/${APP_NAME}.jar

```

ここまででEC2の `/var/apps/(アプリ名)/(アプリ名).jar` の配置までが完了します。
以降でEC2の初期セットアップとアプリの起動を行います。

### EC2の初期セットアップとアプリのサービス起動まで

`workflow` で複数行の `ssh` コマンドを記述可能にする便利なサードバーティ製のActionsがあったので、それを使って `ssh` コマンドを書いていきます。

https://github.com/marketplace/actions/ssh-remote-commands
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/3d9f6ac8-f4ed-2f6a-be20-60ad0b1f260b.png)

このActionsの `script: |` 以降にマルチラインのコマンドを記述できるようになります。
注意点として、この `script:` 内で参照する環境変数は、 `envs:` に指定してあげる必要があります。

以下は `echo` ログと `sudo yum update` を実行しています。

```yaml

    - name: SSH EC2 Setup and Deploy
      uses: appleboy/ssh-action@v0.0.9
      with:
        key: ${{ secrets.AWS_EC2_PRIVATE_KEY }}
        username: ${{ env.EC2_USER }}
        host: ${{ env.EC2_HOST }}
        envs: DEST_DIR,JAVA_VERSION,APP_NAME,JAVA_OPTS,RUN_ARGS
        script: |
          echo "===== yum update ====="
          sudo yum update -y
```

以降、セットアップ用のコマンドが続きます。
初回のみ実行するように `IF` で条件判定を行いつつ、順に・・・

- Javaのインストール（未インストールの時のみ）

```bash
          echo "===== check java install ====="
          if java -version 2>&1 >/dev/null | grep "java version\|openjdk version" ; then
            echo "already installed java."
          else
            echo "install java."
            TARGET=$(yum search java | grep "$JAVA_VERSION.*devel\.\|$JAVA_VERSION.*amazon-corretto\." | awk '{print $1}')
            echo "install target name -> $TARGET"
            sudo yum install -y ${TARGET}
            echo "JAVA_HOME=$(readlink -f /usr/bin/java | sed "s:bin/java::")" | sudo tee -a   /etc/profile
            source /etc/profile
          fi
```

- アプリ起動オプション用の `(アプリ名).conf` ファイルの作成（未作成の時のみ）

```bash
          echo "===== check conf file ====="
          if [ -f ${DEST_DIR}/${APP_NAME}/${APP_NAME}.conf ]; then
          echo "already exist conf file for $APP_NAME"
          else
          echo "create conf file for $APP_NAME"
          cat <<EOL | sudo tee -a ${DEST_DIR}/${APP_NAME}/${APP_NAME}.conf
          export LANG="ja_JP.UTF8"
          JAVA_OPTS="$JAVA_OPTS"
          RUN_ARGS="$RUN_ARGS"
          EOL
          fi
```
↑IF文のインデントがなく読み辛いですが、 `cat << EOL` ~ `EOL` の間を `.conf` ファイルに出力しているためです。

- アプリのサービス登録用に `/etc/systemd/system/(アプリ名).service` ファイルの作成（未作成の時のみ）

```bash
          echo "===== check exist service file ====="
          if [ -f /etc/systemd/system/${APP_NAME}.service ]; then
          echo "already exist service file for $APP_NAME"
          else
          echo "create service file for $APP_NAME"
          cat <<EOL | sudo tee -a  /etc/systemd/system/${APP_NAME}.service
          [Unit]
          Description = ${APP_NAME} app

          [Service]
          ExecStart =  ${DEST_DIR}/${APP_NAME}/${APP_NAME}.jar
          Restart = always
          Type = simple
          User = root
          Group = root
          SuccessExitStatus = 143

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

以上で、EC2の初期セットアップが完了し、アプリをサービスとして実行できるようになりました。（`.conf` や `.service` は、予め雛形ファイルを用意しておいて、jarと一緒にアップロードしても良いかもしれません）

最後に、アプリのサービスの(再)起動を行います。

```bash
          echo "===== application (re)start ====="
          sudo systemctl daemon-reload
          if sudo systemctl status ${APP_NAME} 2>&1 | grep "Active: active (running)" ; then
            echo "${APP_NAME} app restart!!"
            sudo systemctl restart ${APP_NAME}
          else
            echo "${APP_NAME} app start!!"
            sudo systemctl start ${APP_NAME}
          fi
```

# 4. pushして自動デプロイを確認
いよいよアプリの自動デプロイを試してみます。
`workflow` がプロジェクトの `.github/workflows/ec2-deploy.yml` にあることを確認して、 `master` ブランチに対してCommit＆Pushしてみましょう。

GithubのプロジェクトのRepositoryのActionsタブから実行中のタスクが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/45bda799-f4dd-c697-b4b4-407ed832859a.png)

このように全て✅がついていれば正常に完了です。
念の為、SSHのセットアップ状況のログもみてみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/b7e82204-d2a9-001d-97f1-290460931473.png)

`SSH EC2 Setup and Deploy` のStepの詳細を見ると、Javaのインストール、 `conf` `service` ファイルの作成、アプリの起動まで成功していました。

### EC2にブラウザからアクセスして動作確認
無事にデプロイが完了したので、ブラウザから
`http://(ec2のパブリックIP)/demo`
にアクセスしてみます。
<img width="492" alt="スクリーンショット 2020-06-01 14.17.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/145813b2-a32e-d152-3e89-7409a5f73862.png">

まっさらなEC2に、無事に初回セットアップ＆デプロイが行えました！

### アプリの再リリースも行えるか動作確認
初回デプロイだけでなく、再デプロイが行えることも確認するために、Controllerの一部の文言を変更して再度Commit&Pushしてみます。

```java:DemoController.java

@RestController
public class DemoController {
    @GetMapping("/demo")
    public String demo() {
        return "Hello CI/CD demo!!";
    }
}
```

再デプロイ時のSSHのセットアップ状況を見ると、期待通りに `already ...` でSkipされ、アプリの `restart` が行われています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/eae95dce-dac8-8bba-6d94-becc57e72907.png)

最後にブラウザをリロードしてみます。
<img width="518" alt="スクリーンショット 2020-06-01 14.36.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/116b10a2-379f-35bd-0dc8-723c416e72ca.png">

無事に再デプロイされ、アプリの変更が反映されました！

# まとめ
`workflow` でSSHを使うことで、EC2への自動デプロイも簡単に行えるようになりました。
特に、サードパーティの [ssh-remote-commands](https://github.com/marketplace/actions/ssh-remote-commands) は今後も重宝しそうです。

# 参考
今回の `workflow` 作成に辺り、以下の記事を参考にさせていただきました。

- https://kcf-developers.hatenablog.jp/entry/2018/04/20/182217
- https://m0rter.anyfrog.net/2020/205/