[Rails6] Capistrano+CircleCiでEC2にデプロイ

# 記事を書いた理由
今回が初めての記事作成です。CircleCi+CapistranoでEC2へデプロイするための方法を他の記事を参考にデプロイできたので、記事作成の練習として内容をまとめてみました。

#前提条件
- CircleCiのGUIは執筆時(2021年1月19日)のもの。
- ローカルでCapistranoでEC2インスタンスへのデプロイは完了済み。
- すでにCircleCi上でRSpecやRuboCopは動く。（まだであれば[こちら](https://qiita.com/YK0214/items/bbed63ea7ca5367dae2f)が参考になりました。）

#下準備（SSH鍵の形式変更）

最初にCircleCiへ登録するためのにSSH鍵の形式を変更します。
[こちら](https://qiita.com/tomoyuki_kt/items/7a4c714a674da6efa502)の記事を参考にして、過去にEC2インスタンスへアクセスするために作成したであろう
手元のSSH鍵(~/.ssh/your_app_key_rsa)の形式を変更するため以下の操作を行います。

```
$ chmod 700 ~/.ssh/your_app_key_rsa
$ ssh-keygen -p -m PEM -f ~/.ssh/your_app_key_rsa
$ chmod 400 ~/.ssh/your_app_key_rsa
$ cat ~/.ssh/your_app_key_rsa  #このコマンドで表示されるファイルの内容をCircleCiに登録します。
```
ファイルの内容が以下のように 「BEGIN RSA PRIVATE KEY」 になっていればOKです。
-----BEGIN RSA PRIVATE KEY-----
数字を含む文字の羅列
-----END RSA PRIVATE KEY-----
>
参考:[【AWS】 CircleCI/CD 自動デプロイでハマったエラーの解消【Capistrano 】](https://qiita.com/tomoyuki_kt/items/7a4c714a674da6efa502)

#CircleCiでの操作(SSH鍵登録 環境変数登録)

##SSH鍵登録
次に先ほど形式を変更したSSH鍵をCircleCiへ登録するために以下の操作を行います。

```
1. CircleCiに登録しているリポジトリページの画面右上にある 「Project Settings」 をクリック
2. 画面左側の「SSH KEYS」を選択。
3. 画面下の「Additional SSH Keys」項目の右側にある「Add SSH Key」を選択。
4. 表示される項目の「Hostname」にEC2インスタンスに割り当てている「Elastic IP」を入力します。
5.「Private Key*」に「~/.ssh/your_app_key_rsa」の内容をコピーして貼り付けます。
6.「Hostname」と「Private Key* 」が入力できたら「Add SSH Key」でSSH鍵を登録します。
```

###補足
5では、数字と文字の羅列だけじゃなく以下も含めてコピーしてください。
-----BEGIN RSA PRIVATE KEY-----
-----END RSA PRIVATE KEY-----
6で登録が完了すると「Additional SSH Keys」の項目に「Hostname」には「Elastic IP」が、
「Fingerprint」には「12:34:56:ab...」のような内容が表示されているはずです。
表示されている「Fingerprint」はこの後に行う「環境変数」の登録に使います。(環境変数では「:」は含みません。)

SSH鍵のファイルが生成される場合、 ~/.ssh/id_rsa_ の後にFingerprintから:を抜いたものとして生成されます。
登録したSSH鍵を使用する場合は自動的に「ssh-agent」に追加されます。
サーバー環境からGitHubへ接続するのに「ssh-agent」を使用している場合でも、ローカルからEC2インスタンスへの接続に使うためのSSH鍵の1個の登録のみで済みます。

##環境変数登録
次にCircleciに環境変数を追加します。

```
1.　現在の画面(「SSH KEYSの画面」)の左側にある「Environment Variables」を選択します。
2.　次に「Add Environment Variable」を選択します。
3.　SSH鍵の登録と同じような項目が出るので、「Name*」 に変数名として「PRODUCTION_SSH_KEY」と入力。
4. 「Value*」に「~/.ssh/id_rsa_123456abcd」 のように ~/.ssh/id_rsa_ の後ろに先ほど登録した
    SSH鍵の「Fingerprint」の内容から「:」を省いて入力します。
5. 「Add Environment Variable」で環境変数を登録します。
``` 
###補足
CircleCiにSSH鍵を複数登録した場合でも、「~/.ssh」配下に「id_rsa_123456az...」のようなid_rsa_Fingerprint(:抜き)のファイル名として保存されるため、環境変数としてSSH鍵を登録しています。
確認したい場合は、公式の[SSH を使用したデバッグ](https://circleci.com/docs/ja/2.0/ssh-access-jobs/#section=pipelines)を参考にしてください。

# Capistranoファイルの編集
一ヶ所だけの変更なのですぐに終わります。

```ruby:config/deploy/production.rb
server 'Elastic IP',
       user: 'ユーザー名',
       roles: %w[web app db]

set :ssh_options, {
  keys: (ENV['PRODUCTION_SSH_KEY']), #ここだけ編集
  forward_agent: true
}

```

#CircleCiファイルの編集
[CircleCIでSystemSpec(RSpec)とRubocopを走らせる](https://qiita.com/YK0214/items/bbed63ea7ca5367dae2f)の内容を参考に自分用に編集したものです。

```yml:.circleci/config.yml(編集前)
version: 2.1

orbs:
  node: circleci/node@4.1.0
  ruby: circleci/ruby@1.1.2
  aws-cli: circleci/aws-cli@1.3.2

jobs:
  rubocop:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - ruby/rubocop-check

  brakeman:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - run:
          name: Run Brakeman
          command: bundle exec brakeman -6 -A -w1

  rspec:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - DB_HOST: 127.0.0.1
          - RAILS_ENV: test
          - BUNDLER_VERSION: 2.1.4
      - image: circleci/mysql:8.0
        command: |
          mysqld --default-authentication-plugin=mysql_native_password
        environment:
          - MYSQL_ROOT_PASSWORD: password
          - MYSQL_USER: root
          - MYSQL_ROOT_HOST: '%'
      - image: selenium/standalone-chrome:latest
        name: chrome
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - run: mv config/database.yml.ci config/database.yml
      - run:
          name: Wait for DB
          command: dockerize -wait tcp://localhost:3306 -timeout 1m
      - run: bundle exec rake db:create
      - run: bundle exec rake db:schema:load
      - ruby/rspec-test
   
workflows:
  version: 2
  build_and_test:
    jobs:
      - rubocop
      - brakeman:
          requires:
            - rubocop
      - rspec:
          requires:
            - brakeman
```

これに[CircleCI + Capistrano + AWS(EC2) + Railsで自動デプロイしてみた](https://qiita.com/YK0214/items/98bbdf7ce25c557c77a3)を参考に以下の部分を追加します。

```yml:.circleci/config.yml(追加箇所)
#省略
  deploy:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - add_ssh_keys:
          fingerprints:"XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX"
      - deploy:
          name: Capistrano deploy
          command: bundle exec cap production deploy

workflows:
  version: 2
  confirmation_passed_deploy:
    jobs:
      - rubocop
      - brakeman:
          requires:
            - rubocop
      - rspec:
          requires:
            - brakeman
      - deploy:
          requires:
            - rspec
          filters:
            branches:
              only: master
```
```yml:.circleci/config.yml(全体)
version: 2.1

orbs:
  node: circleci/node@4.1.0
  ruby: circleci/ruby@1.1.2
  aws-cli: circleci/aws-cli@1.3.2

jobs:
  rubocop:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - ruby/rubocop-check

  brakeman:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - run:
          name: Run Brakeman
          command: bundle exec brakeman -6 -A -w1

  rspec:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - DB_HOST: 127.0.0.1
          - RAILS_ENV: test
          - BUNDLER_VERSION: 2.1.4
      - image: circleci/mysql:8.0
        command: |
          mysqld --default-authentication-plugin=mysql_native_password
        environment:
          - MYSQL_ROOT_PASSWORD: password
          - MYSQL_USER: root
          - MYSQL_ROOT_HOST: '%'
      - image: selenium/standalone-chrome:latest
        name: chrome
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - run: mv config/database.yml.ci config/database.yml
      - run:
          name: Wait for DB
          command: dockerize -wait tcp://localhost:3306 -timeout 1m
      - run: bundle exec rake db:create
      - run: bundle exec rake db:schema:load
      - ruby/rspec-test

  deploy:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - add_ssh_keys:
          fingerprints: "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX"
      - deploy:
          name: Capistrano deploy
          command: bundle exec cap production deploy

workflows:
  version: 2
  confirmation_passed_deploy:
    jobs:
      - rubocop
      - brakeman:
          requires:
            - rubocop
      - rspec:
          requires:
            - brakeman
      - deploy:
          requires:
            - rspec
          filters:
            branches:
              only: master
```
###補足

```yml
      - add_ssh_keys:
          fingerprints: "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX"
```
の部分は、

```

1. CircleCiに登録しているリポジトリページの画面右上にある「Project Settings」をクリック
2. 画面左側の「SSH KEYS」を選択。
3. 画面一番下に追加したSSH鍵があるのでそこのFingerprint（ 12:34:56:ab:cd:ef:... みたいなの）
　　をコピーして貼り付け。
```
###注意
CircleCiで使用されるIPアドレスは常に同じではないため、ここまで内容ではセキュリティグループでSSH接続可能なIPアドレスを限定している場合はデプロイできません。(限定していない場合はデプロイ可能)
続きはこのセキュリティに関する問題への対処です。
>
参考:[CircleCIでSystemSpec(RSpec)とRubocopを走らせる](https://qiita.com/YK0214/items/bbed63ea7ca5367dae2f)
　　 [CircleCI + Capistrano + AWS(EC2) + Railsで自動デプロイしてみた](https://qiita.com/YK0214/items/98bbdf7ce25c557c77a3)

#特定のIPアドレスでのみデプロイを許可する
次にSSH接続可能なIPアドレスをセキュリティグループで限定したIPアドレス(マイIP)と、CircleCi上で使用されるIPアドレスでのみデプロイができるようにしていきます。
##AWSでの設定
まずは[こちらの記事](https://qiita.com/rintaro-ishikawa/items/02e6a63dbc90ea67a991)を参考にIAMユーザーを作成しCSVを必ずダウンロードしてください(CSVの内容をCircleCiに環境変数として登録するため)。
次に以下の操作を行います。

```
1. サービスから「EC2」を選択。
2. EC2のダッシュボードから「セキュリティグループ」を選択。
3. 画面右上の「セキュリティグループを作成」を選択。
4.「セキュリティグループ名 」と「説明」に「CircleCi-SecurityGroup」と入力。
5.「VPC」にデプロイ先のインスタンスがあるVPCを選択。
6.「セキュリティグループを作成」選択して作成。
7. EC2のダッシュボードから「インスタンス」を選択。
8. デプロイ先のインスタンスの「アクション」から「セキュリティグループを変更」を選択
9.「関連付けられたセキュリティグループ」に作成したセキュリティグループを追加します。
```
これでAWSでの設定は終了です。

###補足
セキュリティグループ作成時にインバウンドルールなどを追加する必要はありません。
作成したセキュリティグループのIDを後ほどCircleCiへ環境変数として登録します。
##CircleCiでの設定

CircleCIでは以下の4つを環境変数として追加します。
「AWS_ACCESS_KEY_ID」「AWS_SECRET_ACCESS_KEY」「AWS_DEFAULT_REGION」「SECURITY_GROUP_ID」

先ほどダウンロードしたCSVの内容を以下の名称で2つ登録します。

```
Nameを「AWS_ACCESS_KEY_ID」、Valueに「Access key ID」の値を登録。
Nameを「AWS_SECRET_ACCESS_KEY」、「Secret access key」の値を登録。
```
残り2つの環境変数を登録します。

```
Nameを「AWS_DEFAULT_REGION」、Valueに「ap-northeast-1」を入力して登録。
Nameを「SECURITY_GROUP_ID」、Valueに「先ほど作成したセキュリティグループのID」を登録。
```
これでCircleCiでの設定は終了です。
###補足
「AWS_DEFAULT_REGION」の値は基本的に「ap-northeast-1」だと思いますが、もし違った場合は適切なリージョン名を登録して下さい。
>
参考: [CircleCI2.0からEC2にアクセスするときだけ特定のIPを許可したい](https://qiita.com/rintaro-ishikawa/items/02e6a63dbc90ea67a991)

##ファイルの編集とシェルスクリプトの作成
それでは設定ファイルを編集していきます。(一番最後にソースコード全体があります)

```yml:.circleci/config.yml(変更箇所)
orbs:
  ruby: circleci/ruby@1.1.2
  node: circleci/node@4.1.0
  aws-cli: circleci/aws-cli@1.3.2 #追加

jobs:
#省略
  deploy:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - aws-cli/install #追加
      - add_ssh_keys:
          fingerprints: "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX"
      - deploy:
          name: Capistrano deploy
          command: ./deploy.sh #シェルスクリプトの実行に変更
```
次にシェルスクリプトを[こちらの記事](https://mgi.hatenablog.com/entry/2014/10/30/085403)をもとに「deploy.sh」という内容で作成しました。（[こちらの記事](https://qiita.com/ryshinoz/items/85eecd2b860227a45ccd)を参考に内容を少し編集しています。）

```shell:deploy.sh
#!/bin/sh
set -ex

IP=`curl -f -s ifconfig.me`

trap "aws ec2 revoke-security-group-ingress --group-id ${SECURITY_GROUP_ID} --protocol tcp --port 22 --cidr ${IP}/32" 0 1 2 3 15
aws ec2 authorize-security-group-ingress --group-id ${SECURITY_GROUP_ID} --protocol tcp --port 22 --cidr ${IP}/32
bundle exec cap production deploy

```
最後に「deploy.sh」の権限を以下のコマンドで変更します。

```
$ chmod 700 deploy.sh 
```

###補足
これでCircleCiからEC2インスタンスへのデプロイはできますが、確認としてデプロイ時に作成したセキュリティーグループにCircleCiのIPアドレスが追加されることや、デプロイ完了後または失敗時には追加されたIPアドレスが削除されていることを確認してください。

>
参考:[CircleCI から deploy させる話](https://mgi.hatenablog.com/entry/2014/10/30/085403)
　　 [CircleCIからCapistranoを利用してAWS（EC2）にデプロイする](https://qiita.com/ryshinoz/items/85eecd2b860227a45ccd)

#ソースコード
先人に倣ってソースコードを置いておきます。

```yml:.circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@4.1.0
  ruby: circleci/ruby@1.1.2
  aws-cli: circleci/aws-cli@1.3.2

jobs:
  rubocop:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - ruby/rubocop-check

  brakeman:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - run:
          name: Run Brakeman
          command: bundle exec brakeman -6 -A -w1

  rspec:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          - DB_HOST: 127.0.0.1
          - RAILS_ENV: test
          - BUNDLER_VERSION: 2.1.4
      - image: circleci/mysql:8.0
        command: |
          mysqld --default-authentication-plugin=mysql_native_password
        environment:
          - MYSQL_ROOT_PASSWORD: password
          - MYSQL_USER: root
          - MYSQL_ROOT_HOST: '%'
      - image: selenium/standalone-chrome:latest
        name: chrome
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - run: mv config/database.yml.ci config/database.yml
      - run:
          name: Wait for DB
          command: dockerize -wait tcp://localhost:3306 -timeout 1m
      - run: bundle exec rake db:create
      - run: bundle exec rake db:schema:load
      - ruby/rspec-test

  deploy:
    docker:
      - image: circleci/ruby:2.7.2-node
        environment:
          BUNDLER_VERSION: 2.1.4
    steps:
      - checkout
      - ruby/install-deps
      - node/install-packages:
          pkg-manager: yarn
          cache-path: 'yarn.lock'
      - aws-cli/install
      - add_ssh_keys:
          fingerprints: "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX"
      - deploy:
          name: Capistrano deploy
          command: ./deploy.sh

workflows:
  version: 2
  confirmation_passed_deploy:
    jobs:
      - rubocop
      - brakeman:
          requires:
            - rubocop
      - rspec:
          requires:
            - brakeman
      - deploy:
          requires:
            - rspec
          filters:
            branches:
              only: master
```

```shell:deploy.sh
#!/bin/sh
set -ex

IP=`curl -f -s ifconfig.me`

trap "aws ec2 revoke-security-group-ingress --group-id ${SECURITY_GROUP_ID} --protocol tcp --port 22 --cidr ${IP}/32" 0 1 2 3 15
aws ec2 authorize-security-group-ingress --group-id ${SECURITY_GROUP_ID} --protocol tcp --port 22 --cidr ${IP}/32
bundle exec cap production deploy
```

```ruby:config/deploy/production.rb
server 'Elastic IP',
       user: 'ユーザー名',
       roles: %w[web app db]

set :ssh_options, {
  keys: (ENV['PRODUCTION_SSH_KEY']),
  forward_agent: true
}
```

```ruby:config/deploy.rb
# config/credentials.yml.encの内容を使えるようにする
require File.expand_path('./environment', __dir__)
# config valid for current version and patch releases of Capistrano
lock '~> 3.15.0'
# アプリケーション名
set :application, 'app_name'
# githubのurl。プロジェクトのgitホスティング先を指定する
set :repo_url, 'git@github.com:user_name/app_name.git'
# デプロイ先のサーバーのディレクトリ。フルパスで指定
set :deploy_to, '/var/www/rails/app_name'

# Rubyのバージョンを指定
set :rbenv_ruby, '2.7.2'

# シンボリックリンクのファイルを指定、具体的にはsharedに入るファイル
append :linked_files, 'config/master.key'
# シンボリックリンクのディレクトリを生成
append :linked_dirs, 'log', 'tmp/pids', 'tmp/cache', 'tmp/sockets', 'node_modules'
# タスクでsudoなどを行う際に必要
set :pty, true
# 保持するバージョンの個数
set :keep_releases, 3
# 出力するログのレベル。
set :log_level, :debug

# bundler
set :bundle_flags,      '--quiet' # this unsets --deployment, see details in config_bundler task details
set :bundle_path,       nil
set :bundle_without,    nil

# puma
set :puma_init_active_record, true

# Nginxの設定ファイル名と置き場所を修正
set :nginx_sites_enabled_path, '/etc/nginx/conf.d'
set :nginx_config_name, "#{fetch(:application)}.conf"

namespace :deploy do
  desc 'Config bundler'
  task :config_bundler do
    on roles(/.*/) do
      within release_path do
        execute :bundle, :config, '--local deployment true'
        execute :bundle, :config, '--local without "development test"'
        execute :bundle, :config, "--local path #{shared_path.join('bundle')}"
      end
    end
  end
end

before 'bundler:install', 'deploy:config_bundler'
```

```ruby:Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.7.2'

gem 'autoprefixer-rails'
gem 'bcrypt'
gem 'bootsnap', require: false
gem 'devise'
gem 'devise-i18n'
gem 'fast_blank'
gem 'image_processing'
gem 'jbuilder'
gem 'mini_magick'
gem 'mysql2'
gem 'pagy'
gem 'puma', '< 5'
gem 'rack-attack'
gem 'rails', '~> 6.0.3', '>= 6.0.3.5'
gem 'rails-i18n'
gem 'ransack'
gem 'recaptcha', require: 'recaptcha/rails'
gem 'sassc-rails'
gem 'turbolinks'
gem 'webpacker'

group :development, :test do
  gem 'brakeman', require: false
  gem 'byebug', platforms: %i[mri mingw x64_mingw]
  gem 'factory_bot_rails'
  gem 'rspec-rails'
end

group :development do
  gem 'annotate'
  gem 'bcrypt_pbkdf'
  gem 'better_errors'
  gem 'binding_of_caller'
  gem 'capistrano'
  gem 'capistrano3-puma', '< 5'
  gem 'capistrano-bundler'
  gem 'capistrano-rails'
  gem 'capistrano-rbenv'
  gem 'capistrano-rbenv-vars'
  gem 'ed25519'
  gem 'letter_opener'
  gem 'letter_opener_web'
  gem 'listen'
  gem 'rack-mini-profiler', require: false
  gem 'rubocop', require: false
  gem 'rubocop-performance', require: false
  gem 'rubocop-rails', require: false
  gem 'rubocop-rspec'
  gem 'spring'
  gem 'spring-watcher-listen'
  gem 'sshkit-sudo'
  gem 'web-console'
end

group :test do
  gem 'capybara'
  gem 'capybara-email'
  gem 'database_cleaner'
  gem 'rspec_junit_formatter'
  gem 'selenium-webdriver'
end

group :production do
  gem 'aws-sdk-s3', require: false
  gem 'google-analytics-rails'
end

gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]