👾

[Rails8] KamalでEC2にGitHub Actionsでデプロイするまでのメモ

2025/01/16に公開

はじめに

AWSとKamalを使ってRails8をEC2にデプロイした時の備忘録です。
DBはPostgreSQLを使ってます。
AWS内で完結させたかったのでレジストリ・サービスはDockerHubではなく、AWS ECRを使いました。

kamalとは

https://kamal-deploy.org/

SSH

Kamalのサーバー通信はSSHとSSHKitを使用している。
SSHKitは、1つまたは複数のサーバー上で構造化された方法でコマンドを実行するためのツールキットで、KamalがリモートのDockerコマンドを実行できるようにしている。

Docker

KamalはDockerを使用してKamal Proxy、アプリケーション、アクセサリを実行する。

Kamal Proxy

Kamal ProxyはKamalアプリケーションをサポートするために特別に作られたリバースプロキシ。
仮想サーバーや物理サーバーのロードバランシングではなく、各Webサーバー上のコンテナのロードバランシングにのみ使用されるので複数のサーバー間での負荷分散は別の仕組みが必要。

AWS環境構築

詳しいことは省略しています。
AWSについてはこちらの記事を参考にしました。
[Rails]AWS EC2へデプロイする

  • VPC・サブネット・インターネットゲートウェイ・ルートテーブルの作成
  • セキュリティグループの作成
  • EC2を作成
    • AMIはubuntuを使います
  • ElasticIPの紐づけ
  • SSH接続
    • ssh-add ~/.ssh/samlple.pemとしてEC2を作成した時のペアキーをsshエージェントに設定します。これでキーの指定なしでssh接続可能になります。
  • RDS作成
  • ECRでレジストリ作成

Rails デプロイ準備

config/deploy.yml.kamal/secrets,config/database.ymlの3つのファイルを編集していきます。

こちらの記事を参考にしました。
Kamal 2で さくらのVPS にRailsアプリをデプロイ
KamalでRailsアプリケーションを迅速にデプロイする方法

config/deploy.yml

deploy.ymlを編集します。以下の項目以外はデフォルトです。

  • serviceにアプリ名
  • imageにAWS ECRのリポジトリ名
  • servers:
    • webにElasticIPアドレス
  • proxy:
    • hostにドメイン名
    • app_portに3000を入れます
  • registry
    • serverにECRリポジトリのURI
    • nameにAWS
  • ssh
    • userをubuntuにします
config/deploy.yml
# Name of your application. Used to uniquely configure containers.
service: kamal-rails # アプリ名

# Name of the container image.
image: kamal-rails # AWS ECRのリポジトリ名

# Deploy to these servers.
servers:
  web:
    - 33.44.555.666 # ElasticIPアドレス
  # job:
  #   hosts:
  #     - 192.168.0.1
  #   cmd: bin/jobs

# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer.
#
# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
proxy:
  ssl: true
  host: example.com # ドメイン名
  app_port: 3000

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  server: 123456789.dkr.ecr.ap-northeast-1.amazonaws.com # ECRリポジトリのURI
  username: AWS

  # Always use an access token rather than real password when possible.
  password:
    - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
  # clear:
  #   # Set number of cores available to the application on each server (default: 1).
  #   WEB_CONCURRENCY: 2

  #   # Match this to any external database server to configure Active Record correctly
  #   DB_HOST: 192.168.0.2

  #   # Log everything from Rails
  #   RAILS_LOG_LEVEL: debug

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole"


# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
  - "kamal-rails_storage:/rails/storage"


# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets

# Configure the image builder.
builder:
  arch: amd64

  # # Build image via remote server (useful for faster amd64 builds on arm64 computers)
  # remote: ssh://docker@docker-builder-server
  #
  # # Pass arguments and secrets to the Docker build process
  # args:
  #   RUBY_VERSION: 3.4.1
  # secrets:
  #   - GITHUB_TOKEN
  #   - RAILS_MASTER_KEY

# Use a different ssh user than root
ssh:
  user: ubuntu

# Use accessory services (secrets come from .kamal/secrets).
# accessories:
#   db:
#     image: mysql:8.0
#     host: 192.168.0.2
#     # Change to 3306 to expose port to the world instead of just local network.
#     port: "127.0.0.1:3306:3306"
#     env:
#       clear:
#         MYSQL_ROOT_HOST: '%'
#       secret:
#         - MYSQL_ROOT_PASSWORD
#     files:
#       - config/mysql/production.cnf:/etc/mysql/my.cnf
#       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
#     directories:
#       - data:/var/lib/mysql
#   redis:
#     image: redis:7.0
#     host: 192.168.0.2
#     port: 6379
#     directories:
#       - data:/data

.kamal/secrets

deploy.ymlで設定した変数を記述します。

KAMAL_REGISTRY_PASSWORD
ECRのパスワードの有効期限は12時間なので、CLIからパスワードを取得します。
aws ecr get-login-password --region <REGION>はECRにログインするためのパスワードを取得するためのコマンドです。
前段階としてIAMを作成し、ECRへログインできる必要があります。
こちらの記事が参考になりました。
AWS CLIでECRへログインするまでの手順

.kamal/secrets
# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets,
# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.

# Example of extracting secrets from 1password (or another compatible pw manager)
# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS})
# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})

# Use a GITHUB_TOKEN if private repositories are needed for the image
# GITHUB_TOKEN=$(gh config get -h github.com oauth_token)

# Grab the registry password from ENV
KAMAL_REGISTRY_PASSWORD=$(aws ecr get-login-password --region ap-northeast-1)

# Improve security by using a password manager. Never check config/master.key into git!
RAILS_MASTER_KEY=$(cat config/master.key)

config/database.yml

database.ymlにRDSを使うための設定をします。
database、username、password、hostを指定します。

config/database.yml
production:
  <<: *default
  database: kamal-rails
  username: <%= Rails.application.credentials.dig(:database, :username) %>
  password: <%= Rails.application.credentials.dig(:database, :password) %>
  host: <%= Rails.application.credentials.dig(:database, :host) %>

kamal setup

kamal setupを実行すると、docker stderr: permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post というエラーになりました。ubuntuユーザーが、/var/run/docker.sockへのアクセスが拒否されています。

ubuntuユーザーに権限を追加することで対応します。
EC2にSSH接続しsudo usermod -aG docker ubuntuを実行し、ubuntuユーザーがdockerグループに追加します。

再度kamal setupを実行するとデプロイが成功すると思います。

CI/CD構築

GitHub Actionsを使って、kamal deployできるようにします。(手探りで試したのでこの方法がベストかどうかはわかりません。。。)

kamal-deploy.ymlの上から順番に説明していきます。

  1. トリガー設定
name: kamal-deploy

on:
  push:
    branches:
      - main

ワークフローの名前をkamal-deployにします。
mainブランチへのプッシュ時のみ実行されるようにします。

  1. ジョブ基本設定
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    timeout-minutes: 20

ubuntu-latest 環境で実行
タイムアウトは20分に設定

    env:
      DOCKER_BUILDKIT: 1
      VERSION: ${{ github.sha }}
      AWS_REGION: ap-northeast-1

DOCKER_BUILDKITを設定し、Dockerのレガシー・ビルダーを置き換える改良されたバックエンドであるBuildKitを選択します。

  1. デプロイメントステップ
steps:
      - name: Checkout code
        uses: actions/checkout@v3

リポジトリのコードをチェックアウト

- name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

AWS認証情報を設定
GitHub Secretsに保存されている認証情報を使用

- name: Get GitHub Actions IP
        id: ip
        run: |
          echo "::set-output name=runner_ip::$(curl -s https://api.ipify.org)"

GitHub Actionsの現在のIPアドレスを取得します。

kamal deployするためにはEC2のセキュリティグループにGithub ActionsのIPアドレスからSSH接続できることを許可しておかないといけないのですが、Github Actionsは複数のIPアドレスを持ち、変更されることもあるので事前に作成できません。

そのためGithub Actions上で動的にIPアドレスを取得し、一時的にセキュリティグループを作成します。

- name: Update security group
        run: |
          SECURITY_GROUP_ID=${{ secrets.AWS_SECURITY_GROUP_ID }}
          aws ec2 authorize-security-group-ingress \
            --group-id $SECURITY_GROUP_ID \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.runner_ip }}/32

AWSのセキュリティグループを更新
GitHub ActionsのIPからのSSHアクセス(ポート22)を一時的に許可

- name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v2
        with:
          driver-opts: image=moby/buildkit:master

Docker Buildxをセットアップ
最新のBuildKitイメージを使用

- name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

AWS ECRにログイン

- name: Setup SSH directory
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh

SSHディレクトリを作成し、適切な権限を設定

- name: Clear known hosts if exists
        run: |
          ssh-keygen -R 12.34.567.890 || true

既存のknown_hostsエントリをクリア(存在する場合)

- name: Add host key securely
        run: |
          ssh-keyscan -H -t ed25519 12.34.567.890 > /tmp/known_hosts_new
          FINGERPRINT=$(ssh-keygen -lf /tmp/known_hosts_new | awk '{print $2}')
          EXPECTED_FINGERPRINT="${{ secrets.SSH_HOST_FINGERPRINT }}"
          
          if [ "$FINGERPRINT" = "$EXPECTED_FINGERPRINT" ]; then
            cp /tmp/known_hosts_new ~/.ssh/known_hosts
            chmod 600 ~/.ssh/known_hosts
          else
            echo "Host key verification failed!"
            echo "Expected: $EXPECTED_FINGERPRINT"
            echo "Got: $FINGERPRINT"
            exit 1
          fi

サーバーのSSHホストキーを安全に追加
フィンガープリントを検証して、正しいサーバーに接続していることを確認

- name: Setup SSH Agent
        uses: webfactory/ssh-agent@v0.7.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

SSH agentをセットアップ
デプロイ用の秘密鍵を追加

- name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true

Rubyをセットアップ
Bundlerのキャッシュを有効化

- name: Create master key
        run: |
          mkdir -p config
          echo "${{ secrets.RAILS_MASTER_KEY }}" > config/master.key

Rails用のmaster.keyファイルを作成
.kamal/secretsではRAILS_MASTER_KEY=$(cat config/master.key)のようにRAILS_MASTER_KEYが設定されていますが、github上にmaster.keyはないと思うのでgithubの環境変数からmaster.keyを作成します。

- name: Create secrets file
        run: |
          mkdir -p .kamal
          echo "KAMAL_REGISTRY_PASSWORD=$(aws ecr get-login-password --region $AWS_REGION)" >> .kamal/secrets
          echo "RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }}" >> .kamal/secrets

Kamal用のシークレットファイルを作成
ECRのログインパスワードとRailsのmaster keyを設定

- name: Deploy
        env:
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
        run: bundle exec kamal deploy --version=$VERSION

Kamalを使用してアプリケーションをデプロイ
現在のコミットのバージョンを指定

- name: Remove IP from security group
        if: always()
        run: |
          SECURITY_GROUP_ID=${{ secrets.AWS_SECURITY_GROUP_ID }}
          aws ec2 revoke-security-group-ingress \
            --group-id $SECURITY_GROUP_ID \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.runner_ip }}/32

if: always() - 前のステップの成功/失敗に関わらず必ず実行
セキュリティグループから一時的に追加したIPアドレスを削除
セキュリティのため、不要になったSSHアクセスを取り除く

.github/workflows/kamal-deploy.yml

name: kamal-deploy

on:
  push:
    branches:
      - deploy

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    timeout-minutes: 20

    env:
      DOCKER_BUILDKIT: 1
      VERSION: ${{ github.sha }}
      AWS_REGION: ap-northeast-1

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Get GitHub Actions IP
        id: ip
        run: |
          echo "::set-output name=runner_ip::$(curl -s https://api.ipify.org)"

      - name: Update security group
        run: |
          SECURITY_GROUP_ID=${{ secrets.AWS_SECURITY_GROUP_ID }}
          aws ec2 authorize-security-group-ingress \
            --group-id $SECURITY_GROUP_ID \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.runner_ip }}/32

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v2
        with:
          driver-opts: image=moby/buildkit:master

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Setup SSH directory
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh

      - name: Clear known hosts if exists
        run: |
          ssh-keygen -R 12.34.567.890 || true

      - name: Add host key securely
        run: |
          # SSHホストキーを取得し、ED25519キーのフィンガープリントを確認
          ssh-keyscan -H -t ed25519 12.34.567.890 > /tmp/known_hosts_new
          
          # フィンガープリントを取得
          FINGERPRINT=$(ssh-keygen -lf /tmp/known_hosts_new | awk '{print $2}')
          EXPECTED_FINGERPRINT="${{ secrets.SSH_HOST_FINGERPRINT }}"
          
          if [ "$FINGERPRINT" = "$EXPECTED_FINGERPRINT" ]; then
            cp /tmp/known_hosts_new ~/.ssh/known_hosts
            chmod 600 ~/.ssh/known_hosts
          else
            echo "Host key verification failed!"
            echo "Expected: $EXPECTED_FINGERPRINT"
            echo "Got: $FINGERPRINT"
            exit 1
          fi

      - name: Setup SSH Agent
        uses: webfactory/ssh-agent@v0.7.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true

      - name: Create master key
        run: |
          mkdir -p config
          echo "${{ secrets.RAILS_MASTER_KEY }}" > config/master.key

      - name: Create secrets file
        run: |
          mkdir -p .kamal
          echo "KAMAL_REGISTRY_PASSWORD=$(aws ecr get-login-password --region $AWS_REGION)" >> .kamal/secrets
          echo "RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }}" >> .kamal/secrets

      - name: Deploy
        env:
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
        run: bundle exec kamal deploy --version=$VERSION

      - name: Remove IP from security group
        if: always()
        run: |
          SECURITY_GROUP_ID=${{ secrets.AWS_SECURITY_GROUP_ID }}
          aws ec2 revoke-security-group-ingress \
            --group-id $SECURITY_GROUP_ID \
            --protocol tcp \
            --port 22 \
            --cidr ${{ steps.ip.outputs.runner_ip }}/32

Discussion