Open19

CodePipeline, CodeBuildを用いてRails on DockerアプリをElastic Beanstalk環境にデプロイ

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

やりたいこと

  • すでにproduction環境が動いているサービスのstaging環境を作成する

技術選定

  • AWS Elastic Beanstalk
  • AWS CodePipeline, CodeBuild
  • Rails / Nginx / Docker
  • ローカルの作業環境はMac M2 Pro
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

全体像

  • ローカルのアプリケーションコードを、staging環境用に変更
  • ローカルのソースバンドルの作成(.envなどを含まないように)
  • EBに新しい環境を作成 & ソースバンドルをアップロード
  • データベースの構築
  • DNSの設定等
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

アプリケーションコードのテスト環境対応

要対応

  • production.rb(action_mailer.default_url_optionsが今まではベタ書きだったので、環境変数化する)
production.rb
  config.action_mailer.perform_caching = false
  config.action_mailer.default_url_options = { :protocol => 'https', :host => 'app.production-domain.com' }
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = {
    enable_starttls_auto: true,
    address: "smtp.gmail.com",
    domain: "our-domain",
    port: PortNum,
    authentication: "login",
    user_name: ENV['GMAIL_USER_NAME'],
    password: ENV['GMAIL_PASSWORD']
  }

対応不要なもの

  • database.yml(環境変数で調整)
database.yml
production:
  <<: *default
  database: <%= ENV['RDS_DB_NAME'] %>
  username: <%= ENV['RDS_USERNAME'] %>
  password: <%= ENV['RDS_PASSWORD'] %>
  host: <%= ENV['RDS_HOSTNAME'] %>
  port: <%= ENV['RDS_PORT'] %>
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

ローカルのソースバンドルの作成

  • 公式の記事
  • Git管理されないファイルはいれないように作ったほうが良い感?
  • このコマンドで作成
    • HEADの状態でアーカイブが作られるので、まだcommitされていない差分は含まれない模様
$ git archive -v -o myapp.zip --format=zip HEAD
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

imageの切り替え

  • production環境と、staging環境ではこれを分けたい
    • 新しいgemの導入など、staging環境とproduction環境で内容が変わることがあるから。
  • 現在production環境で利用しているECRのimageのpathで全検索をしたところ、以下の2つのファイルが該当している。

Dockerrun.aws.json

  • Dockerrun.aws.jsonの中で、pullするimageを指定している。

CodeBuildの設定(buildspec.yml)

  • 現在、buildspec.ymlでは、push先のリポジトリの指定などを全て直書きで、production環境用に行っている。これをstaging環境へ出し分けられるようにしたい
  • https://dev.classmethod.jp/articles/codebuild-env/
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

どうやってDockerrun.aws.jsonの中身を動的に変更する?

調査に苦戦したのでAWS Supportに問い合わせた

  • 質問
# 前提
- AWS Elastic Beanstalkの、Dockerプラットフォーム、ECS running on 64bit Amazon Linux 2での環境構築

# やりたいこと
- production環境を今まで運用していたが、今回新たにstaging環境をデプロイする。EBの環境自体を新しく作る。
- Dockerrun.aws.jsonでimageのURLを指定するが、それをproductionとstagingでうまく切り分けられるようにしたい

# 詳細
- CodeBuildを用いて、GitHubからpullし、docker imageをbuildしています。
- その際、staging環境用のimageとproduction環境用のimageは別としたいと考えています。(そのため、CodeBuildも2つ別で設定し、buildspec.ymlで環境変数を見ることによって、imageに対してのタグ付けや、push先のECRのURLを分けたいと考えています)
- production環境とstaging環境で別のimageがECRにpushされた際に、Dockerrun.aws.jsonが参照するURLもそれぞれ別々にしたいと考えています。

# 調査過程
- Dockerrun.aws.jsonのver.2を用いている場合は、以下のことができないという情報を得ました。
  - "image": "${DOCKER_IMAGE_URL}" のような形で.jsonに記述し、EBのGUIから設定することでDOCKER_IMAGE_URLを取得する

# 問
- どのようにしたら、Dockerrun.aws.jsonを共通としながら、それぞれの環境に合わせたimageをpullできるか?
  • 回答(抜粋)
■ 回答
ECS マネージド Docker プラットフォームにおいては、アプリケーションのデプロイ時に、Dockerrun.aws.json の設定に基づいて新しい ECS タスク定義のリビジョンが作成されます。
Elastic Beanstalk は、こちらの新しいリビジョンを使用して、アプリケーション用のコンテナを作成いたします。
こちらのリビジョンの作成に関して、Elastic Beanstalk 環境内の EC2 インスタンス側ではなく、アプリケーションをデプロイした側がリビジョンを作成いたします。
従いまして、それぞれの環境に合わせた Docker イメージを pull するためには、アプリケーションをデプロイする側で Dockerrun.aws .json を動的に書き換える仕組みが必要となります。
こちらの動的に書き換える仕組みに関して、参考となりそうな資料がございましたので紹介させていただきます。[2]
外部ドキュメントとなりますため内容の正当性について保証できるものではございませんが、お客様が直面されておられる課題解決の一助になればと考え、紹介させていただく次第でございます。
こちらの資料においては、テンプレート用の Dockerrun.aws .json.template を作成し、変数に応じて Dockerrun.aws .json を作成しております。
さらに AWS CLI や EB CLI 等を組み合わせていただくことで、取得する変数やイメージをカスタマイズできるかと存じます。

[2] Separate Dockerrun.aws  .json files for staging and production
https://stackoverflow.com/questions/50162321/separate-dockerrun-aws-json-files-for-staging-and-production

ChatGPTへの質問回答

  • ChatGPT曰く、AWSEBDockerrunVersion: 2の場合は、環境変数を直接Dockerrun.aws.jsonに挿入することができない模様。
  • シェルスクリプトを使って、自分で置換しろと言っている。その例↓
mode: "000755"
owner: root
group: root
content: |
  #!/bin/bash
  DOCKER_IMAGE_URL=$(aws ssm get-parameter --name DOCKER_IMAGE_URL --region us-east-1 --with-decryption --query 'Parameter.Value' --output text)
  ESCAPED_DOCKER_IMAGE_URL=$(echo $DOCKER_IMAGE_URL | sed 's/[\/&]/\\&/g')
  sed -i "s/\${DOCKER_IMAGE_URL}/$ESCAPED_DOCKER_IMAGE_URL/g" /var/app/current/Dockerrun.aws.json

container_commands:
  01_replace_imageurl:
    command: "/tmp/docker_imageurl.sh"
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

現時点で考えていること・方針

やりたいこと

  • prodとstgで、image自体を変える(gemが変わっていたりして、imageがずれることがあるから)
    • Dockerrun.aws.jsonのpull元のURLを変更
    • CodeBuildの際も、別のタグ付け & push先のECRのURLを変更
  • 動的にDockerrun.aws.jsonを書き換える
    • eb deployの直前に、prodかstgかに応じて書き換えることができればとりあえずOK
      • docker imageの中のファイル(もともとのファイル)は、eb deploy後にソースバンドルによってマウントされることで上書きされる認識
    • これをCodePiplineでできたら理想。Buildの前後?とかでスクリプトを変更し、そのまま変更されたスクリプトをデプロイする。

超えるべき壁

  • 動的にDockerrun.aws.jsonを書き換えること(ローカルのshell script実行、またはCodePipline)
  • (動的に書き換えるとか関係なく)CodePiplineでのデプロイに成功すること
    • 特にrails assets:precompileが難所?
  • CodeBuildで、imageのタグやpush先を環境変数から変更すること
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

とりあえずテスト環境作成

  • とりあえず何も考えずに環境自体を作ろう。まずはそこから。

新規環境作成

  • 新しく環境を作成し、zipをアップロード
  • => 環境を作成、完了
  • ヘルスチェックは緑
  • URLにアクセスすると永遠にロード中
  • curlコマンドで確認 => 無限リダイレクトされている
$ curl -v URL
...省略...
<head><title>301 Moved Permanently</title></head>
...省略
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

証明書を作成し、Application Load Balancerに設定する

Route 53で、親となるホストゾーンの元でサブドメインを設定する

  • Route53 > ホストゾーン > 親ドメインから、「レコードを作成」
    • Aレコード
    • トラフィックのルーティング先をEB環境に設定

AWS Certificate Managerで、パブリック証明書をリクエスト

Route 53でレコード作成をする

Elastic Beanstalkのロードバランサーの設定を変更する

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

環境変数の設定

  • 何も環境変数を設定しない状態でアクセスしたら以下のようなエラーがでた
  • RAILS_ENV等の環境変数をprodと(ほぼ)同じ用に設定したところ、Railsの本番環境でのエラーに変わった
    • ってことは、環境変数を設定しない場合、development環境になってるのかもな(エラーがdev環境のレイアウトだったため)
  • ログを調べたところ、上記エラーはデータベースへつながっていないことによるエラー(設定していないので当たり前)
ActiveRecord::ConnectionNotEstablished (Can't connect to local MySQL server through socket '/run/mysqld/mysqld.sock' (2)):
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

DBを作成する

  • EBの設定画面から、RDSを作成する
  • エラーが以下のように変わる
ActiveRecord::StatementInvalid: Mysql2::Error: Table 'ebdb.users' doesn't exist
  • まだmigrationを実行していないため

EBの環境にSSH接続する

  • db:migrateを実行するためには、本番環境のインスタンスに入る必要がある
  • --setupを実行し、keypairを設定する。(新規作成するか、既存のものを使うか)
% eb ssh --setup EnvName
  • SSHでアクセスした後、db:migrateコマンドを実行
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

アセットパイプライン関連のエラーの解決

  • 次に出ているエラー
ActionView::Template::Error (The asset "application.css" is not present in the asset pipeline.
  • ソースバンドルに rails assets:precompile したものが含まれていないことが原因
  • 今回は、本番環境で直接precompileを実施 => 結果変わらない
$ sudo docker exec -it ID rails assets:precompile
$ sudo docker exec -it ID bundle exec pumactl restart
Command restart sent success  
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

CodePipelineでビルドしてみる!

  • とりあえず、prodとstgの出し分けとかは考えずに、stg環境に特化して設定を組んで(buildspec.ymlどか、Dockerrun.aws.jsonとか)デプロイできるようにしてみる。

1. ECRにリポジトリを作る

  • これは簡単。普通にリポジトリ作成するだけ
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

2. CodePipelineの作成

  • どんどん進めていく
  • CodeBuildは、CodePiplineの中から作成できる。それが一番シンプルそう
  • ソースステージの追加
    • GitHubはバージョン1と2を選べるが、2が推奨。公式doc
  • ビルドステージの追加
    • 中段の「プロジェクトの作成」をクリック
    • CodeBuildの設定は次のコメントで
  • デプロイステージの追加
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

CodeBuildの設定

  • CodePipelineの作成画面から作成
  • buildspec.ymlはあとで貼る(今調整中)

必要なIAMを付与する

  • 自分の場合は、以下2つのIAMを、作成したCodeBuildのロールに対して付与する必要があった。
    • AmazonSSMReadOnlyAccess(環境変数を参照しているため)
    • AmazonEC2ContainerRegistryPowerUser(ECRにpushするため)
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

assets:precompile周りの処理

現状の問題

  • サービスはエラーを返す
ActionView::Template::Error (The asset "application.css" is not present in the asset pipeline.
  • 本番環境にSSH接続し、コンテナの中を覗くと、public/assetsディレクトリは存在していない。

解決の方針

  • (色々苦戦した挙げ句出た結論)
  • Dockerfileの中でassets:precompileを実施
  • precompileされたものを、ディレクトリごとdockerコンテナの外にコピー(ソースバンドルに含めるため)
  • これでデプロイすると、デプロイされた後のコンテナの中にpublic/assetsが存在している

試行錯誤中にあたった問題

  • Dockerfileの中でassets:precompileをすると、containeの中ではディレクトリが生成されるため、ECRにpushされるimageには含まれていると思われる。
  • だが、containerの外のソース(GitHubから持ってきたやつ)の中には含まれてない。含まれていないソースでソースバンドルを作ってデプロイすると、デプロイ後のコンテナにはpublic/assetsは含まれなくなっている(imageにあるのに?。たぶんマウントのタイミングとかで消されているとか)
  • よって、ソースバンドルにもprecompileされた後のディレクトリを含めたい、という方針

実装

  • 最終形態は後で書くけどとりあえず変更部分
  • docker-compose.prod.ymlからDockerfileに環境変数を渡す。参考
docker-compose.prod.yml
version: '3'
services:
  web:
    build:
      context: .
      args:
        - SECRET_KEY_BASE=${SECRET_KEY_BASE}
  • Dockerfileでprecompile. 参考
Dockerfile
FROM ruby:3.1.2

# 追記
ARG SECRET_KEY_BASE

...略...
RUN bundle install
COPY . ./
...略...

# 追記
RUN SECRET_KEY_BASE=${SECRET_KEY_BASE} bundle exec rails assets:precompile \
 && yarn cache clean \
 && rm -rf node_modules tmp/cache

COPY entrypoint.sh /usr/bin/
...略...
  • buildspec.ymlで、buildフェーズに追記
buildspec.yml
  build:
    commands:
      - echo Building the Docker image...
      - docker-compose -f ./docker-compose.prod.yml build --no-cache

      # 追記
      - docker create --name tmp-container-for-assets appName-prod-web
      - docker cp tmp-container-for-assets:/var/www/AppName/public/assets ./public/assets
      - docker rm -f tmp-container-for-assets

      - docker tag appName-prod-web:latest xxxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/AppName-web:latest
      - docker tag appName-prod-server:latest xxxxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/appName-server:latest

調査中: SECRET_KEY_BASEについて

  • 明示的にRAILS_ENV=productionをつけてassets:precompileコマンドを呼ぶ場合、SECRET_KEY_BASEを渡さないと以下のようなエラーがでる
ArgumentError: Missing `secret_key_base` for 'production' environment, set this string with `bin/rails 
  • なお、直近(2022/12/8)のPRで、SECRET_KEY_BASE_DUMMY=1と指定したらSECRET_KEY_BASEが不要になる開発がマージされた。該当PRについて書かれた記事
  • だが自分の使っているRails7.0.4ではその機能は未実装。
  • 今回、RAILS_ENV=productionを渡してprecompileする必要性を感じなかったため、SECRET_KEY_BASEも渡さない対応とした。(上のソースコードでは記述しているが、書き換えた)
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

buildspec.ymlで環境変数を使用する

  • buildspec.ymlの書き換え
buildspec.yml
  build:
    commands:
      ......
      - docker tag appName-prod-web:latest xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/${ECR_REPOSITORY_WEB}:latest
      - docker tag appName-prod-server:latest xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/${ECR_REPOSITORY_SERVER}:latest
  post_build:
    commands:
      - echo Build completed on `date`
      - echo Pushing the Docker image...
      - docker push xxxxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/${ECR_REPOSITORY_WEB}:latest
      - docker push xxxxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/${ECR_REPOSITORY_SERVER}:latest
  • CodeBuildのコンソールで環境変数を登録
  • ECRのプッシュされた日時も、環境変数を用いてビルドした時間になってて成功!
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

動的にDockerrun.aws.jsonを変更する

方針

  • Dockerrun.aws.jsonを変更するshell scriptを作成し、それをCodeBuildのpre_buildで呼ぶ。
  • 必要な値はbuildspec.ymlから渡す。

実装

  • deploy/dockerrun-setter.sh
dockerrun-setter.sh
#!/bin/bash

ECR_REPOSITORY_WEB=$1
ECR_REPOSITORY_SERVER=$2
MEMORY=$3

sed -e "s/\${ECR_REPOSITORY_WEB}/$ECR_REPOSITORY_WEB/g" \
    -e "s/\${ECR_REPOSITORY_SERVER}/$ECR_REPOSITORY_SERVER/g" \
    -e "s/\${MEMORY}/$MEMORY/g" \
    ./deploy/Dockerrun.aws.json.template > ./Dockerrun.aws.json
  • deploy/Dockerrun.aws.json.template
Dockerrun.aws.json.template
{
    "AWSEBDockerrunVersion": 2,
    "volumes": [
      {
        "name": "source-bundle",
        "host": {
          "sourcePath": "/var/app/current"
        }
      },
      {
        "name": "public-data",
        "host": {
          "sourcePath": "/var/app/current/public"
        }
      },
      {
        "name": "tmp-data"
      }
    ],
    "containerDefinitions": [
      {
        "name": "appName-web",
        "image": "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/${ECR_REPOSITORY_WEB}:latest",
        "essential": true,
        "memory": ${MEMORY},
        "command":  ["bundle","exec","puma","-C","config/puma.rb"],
        ...略...
  • buildspec.yml
buildspec.yml
  pre_build:
    commands:
       ......
      - sh ./deploy/dockerrun-setter.sh ${ECR_REPOSITORY_WEB} ${ECR_REPOSITORY_SERVER} ${MEMORY}
  • Dockerrun.aws.jsonは、中身に何を書いても上書きされるので空ファイルにしておいた
  • CodeBuildの環境変数に必要な値を入れておく