[Rails8] KamalでEC2にGitHub Actionsでデプロイするまでのメモ
はじめに
AWSとKamalを使ってRails8をEC2にデプロイした時の備忘録です。
DBはPostgreSQLを使ってます。
AWS内で完結させたかったのでレジストリ・サービスはDockerHubではなく、AWS ECRを使いました。
kamalとは
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にします
# 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へログインするまでの手順
# 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を指定します。
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の上から順番に説明していきます。
- トリガー設定
name: kamal-deploy
on:
push:
branches:
- main
ワークフローの名前をkamal-deployにします。
mainブランチへのプッシュ時のみ実行されるようにします。
- ジョブ基本設定
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を選択します。
- デプロイメントステップ
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