🚀

DokkuをVPSをインストールし、Production, Staging, PRごとの環境・CDを構築

2023/09/21に公開

はじめに

私は普段Ruby on Railsを使用してWebアプリケーションを作成しています。
個人でもちょっとしたものをデプロイしたいと思ったときに、HerokuのようなPaaSが使えると思います。
ただ、細かく環境を分けたりなど数が増えると、それに応じて料金が発生して気軽に試せなくなりそうです。
そこで、DokkuをVPSへインストールし、Production, Staging, PRごとの環境・CD(Github Actions)を構築してみました。

Dokkuとは?

Dokkuは、セルフホスティングできるHerokuのようなPaaSのオープンソースソフトウェアです。
HerokuのBuildpack(Herokuish Buildpacks)を使用しているので、Ruby on RailsなどのWebアプリケーションが簡単にデプロイできます。

Dokku

An open source PAAS alternative to Heroku.

What is Dokku?

Dokku is an extensible, open source Platform as a Service that runs on a single server of your choice. Dokku supports building apps on the fly from a git push via either Dockerfile or by auto-detecting the language with Buildpacks, and then starts containers based on your built image. Using technologies such as nginx and cron, Web processes are automatically routed to, while background processes and automated cron tasks are also managed by Dokku.

環境

  • VPS: Ubuntu 22.04 LTS
  • Dokku 0.31.0

Production, Staging, PR Review環境について

Production

  • 本番用のWebアプリケーション・データベース
  • Gitのブランチはmain
  • 他環境の影響を受けないように、ひとつのVPSで共存しない

Staging

  • テスト用のWebアプリケーション・データベース
  • Gitのブランチはdevelop
  • Productionと同様の環境で、スペックなどは落とす
  • PR Reviewと同じVPSに共存

PR(Pull Request) Review

  • Pull Requestで変更を加えたWebアプリケーション
  • Gitのブランチは、それぞれのPRブランチ
  • データベースはStagingへ接続
  • Stagingと同じVPSに共存

構成図

diagram

Dokkuのインストール

こちらを参考に、Dokkuをインストールします。

https://dokku.com/docs/getting-started/installation/

Webアプリケーションのデプロイ

こちらを参照に、Production, Staging環境ごとのWebアプリケーションを作成・デプロイします。

https://dokku.com/docs/deployment/application-deployment/

簡単な流れは以下の通りです。

  1. dokku apps:createでデプロイ先のアプリケーションを作成
  2. dokku postgres:createでデータベースを作成
  3. dokku postgres:linkでアプリケーションからデータベースを参照できるようにする
  4. git pushでアプリケーションに対してデプロイ
  5. Domain, SSL(Let's Encrypt)を設定

※ Production, Stagingを区別できるようにsuffixなどを付けるとよいと思います。

途中経過

ここまでで、Production, Staging環境ごとにWebアプリケーションが手動でデプロイできる状態になったと思います。
ここからは、Github Actionsを使用して、自動でデプロイ(CD)できるようにしていきます。

Github Actions Staging環境へのデプロイ設定

Staging環境へのデプロイは、developブランチへプッシュ・マージされたときに自動で行います。
設定ファイルは以下のようなものになります。

.github/workflows/deploy_staging.yml

name: Deploy Staging

on:
  push:
    branches: [develop]

jobs:
  deploy:
    name: Deploy Staging
    runs-on: ubuntu-latest

    steps:
      - name: Cloning repo
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Push to dokku
        uses: dokku/github-action@v1.4.0
        with:
          git_remote_url: ${{ vars.DOKKU_GIT_REMOTE_URL_FOR_STAGING }}
          ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY_FOR_STAGING }}
          branch: 'main'
          git_push_flags: '--force'

Github Actions Production環境へのデプロイ設定

次にProduction環境へのデプロイ設定ファイルを作成します。
内容はStaging環境とほぼ同じで、対象のブランチやGitのプッシュ先やキーを変更します。

.github/workflows/deploy_staging.yml

name: Deploy Production

on:
  push:
    branches: [develop]

jobs:
  deploy:
    name: Deploy Production
    runs-on: ubuntu-latest

    steps:
      - name: Cloning repo
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Push to dokku
        uses: dokku/github-action@v1.4.0
        with:
          git_remote_url: ${{ vars.DOKKU_GIT_REMOTE_URL_FOR_PRODUCTION }}
          ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY_FOR_PRODUCTION }}
          branch: 'main'
          git_push_flags: '--force'

Github Actions PR Review環境へのデプロイ設定

最後にPR Review環境へのデプロイ設定を作成します。
こちらに関しては、動作確認したいときにアプリケーションの作成・デプロイし、PRが閉じたときにはアプリケーションを削除します。

PR Review環境へのデプロイ設定

  • PRに/deploy_pr_reviewとコメントしたときにデプロイを実行
    • xt0rted/slash-command-actionを使用し、スラッシュコマンドでデプロイを実行
  • Domain, SSL(Let's Encrypt)を設定
    • bin/ci-pre-deployファイルを作成することで、デプロイ時に自動実行される
    • domains:addでDomainを設定
    • letsencrypt:enableでSSLを設定
      • letsencrypt:enableは設定済みでもスキップされない
      • Let's Encryptの制限対象にならないように、設定済みの場合はスキップする
  • Deploy PR Reviewラベルを追加
    • デプロイ済みかラベルで判断

.github/workflows/deploy_pr_review.yml

name: Deploy PR Review

on:
  issue_comment:
    types: [created, edited]

concurrency:
  group: deploy_pr_review

jobs:
  check_slash_command:
    name: Check slash command
    runs-on: ubuntu-latest
    if: ${{ github.event.issue.pull_request }}

    outputs:
      command_name: ${{ steps.slash_command_action.outputs.command-name }}
      command_arguments: ${{ steps.slash_command_action.outputs.command-arguments }}
      outcome: ${{ steps.slash_command_action.outcome }}
      target_branch_name: ${{ steps.get_branch_name.outputs.result }}

    steps:
      - name: Check for Command
        id: slash_command_action
        continue-on-error: true
        uses: xt0rted/slash-command-action@v2
        with:
          command: deploy_pr_review

      - name: Get branch name
        id: get_branch_name
        if: ${{ steps.slash_command_action.outcome == 'success' }}
        uses: actions/github-script@v7
        with:
          script: |
            const pull_request = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number
            })
            return pull_request.data.head.ref
          result-encoding: string

  deploy:
    name: Deploy PR Review
    runs-on: ubuntu-latest
    needs: check_slash_command
    if: ${{ needs.check_slash_command.outputs.outcome == 'success' }}

    steps:
      - name: Cloning repo
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
          ref: ${{ needs.check_slash_command.outputs.target_branch_name }}

      - name: Set ENV
        run: |
          DOKKU_REVIEW_APP_NAME=${{ vars.DOKKU_REVIEW_APP_NAME_PREFIX }}${{ github.event.issue.number }}
          PR_REVIEW_DOMAIN=${{ vars.REVIEW_APP_DOMAIN_PREFIX }}${{ github.event.issue.number }}.${{ vars.STAGING_DOMAIN }}
          PR_REVIEW_URL=https://$PR_REVIEW_DOMAIN
          echo "DOKKU_REVIEW_APP_NAME=$DOKKU_REVIEW_APP_NAME" >> $GITHUB_ENV
          echo "PR_REVIEW_DOMAIN=$PR_REVIEW_DOMAIN" >> $GITHUB_ENV
          echo "PR_REVIEW_URL=$PR_REVIEW_URL" >> $GITHUB_ENV

      - name: Create bin/ci-pre-deploy file
        run: |
          cat << EOF > bin/ci-pre-deploy
          #!/bin/sh -l

          if [ "\$IS_REVIEW_APP" = "true" ]; then
            ssh "\$SSH_REMOTE" -- domains:add "\$APP_NAME" "$PR_REVIEW_DOMAIN"
            ssh "\$SSH_REMOTE" -- ps:scale "\$APP_NAME" web=1 --skip-deploy

            LETSENCRYPT_LIST_COUNT=\`ssh "\$SSH_REMOTE" -- letsencrypt:list | grep "\$APP_NAME" | wc -l\`
            if [ "\$LETSENCRYPT_LIST_COUNT" = "0" ]; then
              ssh "\$SSH_REMOTE" -- letsencrypt:enable "\$APP_NAME"
            else
              echo "skip letsencrypt:enable"
            fi

            echo "configured the review app"
          fi
          EOF

          cat bin/ci-pre-deploy

      - name: Push to dokku
        uses: dokku/github-action@v1.4.0
        with:
          command: review-apps:create
          git_remote_url: ${{ vars.DOKKU_GIT_REMOTE_URL_FOR_STAGING }}
          ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY_FOR_STAGING }}
          branch: 'main'
          git_push_flags: '--force'
          review_app_name: ${{ env.DOKKU_REVIEW_APP_NAME }}

      - name: Add Label
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.addLabels({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              labels: ["Deploy PR Review"]
            })

      - name: Post deploied comment
        uses: actions/github-script@v6
        env:
          MESSAGE: |
            Deploied PR Review: ${{ env.PR_REVIEW_URL }}
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: process.env.MESSAGE
            })

PR Review環境のアプリケーション削除設定

  • PRをマージ・クローズしたときにアプリケーションを削除
  • Deploy PR Reviewラベルがついていない場合、スキップする
  • アプリケーション削除後、Deploy PR Reviewラベルを外す

.github/workflows/drop_pr_review.yml

name: Drop PR Review

on:
  pull_request:
    types: [closed]

jobs:
  drop_pr_review:
    name: Drop PR Review
    runs-on: ubuntu-latest

    steps:
      - name: Check for label
        id: check_for_label
        uses: actions/github-script@v6
        with:
          script: |
            const response = await github.rest.issues.listLabelsOnIssue({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
            })
            const hasLabel = (response.data ?? []).some(label => label.name === 'Deploy PR Review')

            if (hasLabel) {
              return 'do_not_skip'
            }
            return 'skip'
          result-encoding: string

      - name: Set ENV
        if: steps.check_for_label.outputs.result != 'skip'
        run: |
          DOKKU_REVIEW_APP_NAME=${{ vars.DOKKU_REVIEW_APP_NAME_PREFIX }}${{ github.event.pull_request.number }}
          echo "DOKKU_REVIEW_APP_NAME=$DOKKU_REVIEW_APP_NAME" >> $GITHUB_ENV

      - name: Drop PR Review
        if: steps.check_for_label.outputs.result != 'skip'
        uses: dokku/github-action@v1.4.0
        with:
          command: review-apps:destroy
          git_remote_url: ${{ vars.DOKKU_GIT_REMOTE_URL_FOR_STAGING }}
          ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY_FOR_STAGING }}
          review_app_name: ${{ env.DOKKU_REVIEW_APP_NAME }}

      - name: Remove Label
        if: steps.check_for_label.outputs.result != 'skip'
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.removeLabel({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              name: ["Deploy PR Review"]
            })

      - name: Post droped comment
        if: steps.check_for_label.outputs.result != 'skip'
        uses: actions/github-script@v6
        env:
          MESSAGE: |
            Droped PR Review
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: process.env.MESSAGE
            })

まとめ

DokkuをVPSをインストールし、Production, Staging, PRごとの環境・CDを構築してみました。
Webアプリケーションをデプロイしたい、Production環境だけでなくStaging環境もほしいなどあれば、Dokkuを使用してみてはいかがでしょうか。

Discussion