明けましておめでとうございます!ようやく正月ボケが抜けてきたような気がします。。
新年一発目のiret.mediaブログ投稿になります!
本記事について
実際に運用しているLaravelで実装しているWebアプリケーションのローカル開発とCI/CDの実例(すなわちDevOpsのプラクティス)について、本記事で解説しようと思います。
コンテナベースのWebアプリケーションのローカル環境での開発手法とCI/CDの構成について、実践レベルの実例はほとんど公開されていません。
そのため、ここでコンテナベースのWebアプリケーションをローカル環境でどのように開発して、実用環境へCI/CDしているかといった実践的なDevOpsのプラクティスについて共有いたします。
(外部へ共有できない情報があり、一部構成を実際と少し異なるように表現したり説明をわかりやすくするために簡素化している箇所がございます。そちらについてはご了承のほどお願いいたします。)
対象読者
以下に当てはまる方はぜひご覧いただけると良いかと思います。
- Webアプリケーションの実践的なパイプライン構成について知りたい
- パイプラインでLaravelをはじめとしたWebアプリケーションのテストやマイグレーションの実例について知りたい
対象システム
本記事で言及するWebアプリケーションは弊社が提供する「cloudpack+」というシステムです。
cloudpack+導入事例:部署間でサイロ化したデータを統合し Web アプリケーションを開発!自社サービスのお客様ニーズに応える機能を実装
cloudpack+の技術的なポイントを簡単にまとめます。
- WebアプリケーションはLaravelフレームワークにて実装
- 環境はローカル/開発/本番の3環境構成であり、開発環境と本番環境は異なるAWSアカウント
- Webアプリケーションはコンテナで構成されており、ローカル環境はdocker-compose、開発/本番環境はECS(Fargate)により管理されている
※ システム構成図を上記リンクに記載しているので、そちらをご覧いただけると詳細なシステム構成がわかります。
コンテナ構成
開発環境/本番環境
開発環境と本番環境は1つのECSタスクに2コンテナを配置しています。
- APPコンテナ(PHP-FPMが稼働)
- Webコンテナ(NGINXが稼働)
DBはRDS(MySQL)です。シンプルにAPPコンテナからRDSに接続する形です。
ローカル環境
APPコンテナとWebコンテナに加えてDBコンテナ(MySQLが稼働)の3コンテナをdocker-composeで管理する形です。(本当はもういくつかコンテナがあるのですが、説明をわかりやすくしたいので言及しません)
ローカルのディレクトリをAPPコンテナのディレクトリにボリュームマウントしており、ローカルでの変更がAPPコンテナに反映されるようになっています。
services: app: build: context: . dockerfile: ./docker/app/Dockerfile environment: - ...省略... volumes: - ./sources:/data web: build: context: . dockerfile: ./docker/web/Dockerfile environment: - APP_HOST=${APP_HOST:-app} - WEB_PORT=${APP_HOST:-80} - WEB_HOST=${APP_HOST:-localhost} ports: - 8081:443 volumes: - ./sources/public:/data/public depends_on: - client-app db: build: context: . dockerfile: ./local/mysql/Dockerfile ports: - 3306:3306 volumes: - ./local/mysql/db-store:/var/lib/mysql volumes: db-store:
開発はローカル環境で行います。
以下が開発の流れの大枠です。
- ソースコードの編集
- テスト実行(LaravelのUnitテスト/Featureテスト)
- リモートブランチへ反映
フォルダ/ファイル構成
Webアプリケーションは1つのレポジトリ(CodeCommit)にて管理しています。レポジトのフォルダ/ファイル構成について説明いたします。
. ├── appspec.yaml ├── buildspec-app.yml ├── buildspec-migrate.yml ├── buildspec-taskdef.yml ├── buildspec-web.yml ├── docker │ ├── app │ │ └── Dockerfile │ ├── test-mysql │ │ └── Dockerfile │ └── web │ └── Dockerfile ├── docker-compose.yml ├── local │ ├── docker-compose.yml │ └── mysql │ └── Dockerfile ├── sources └── taskdef.json
フォルダ/ファイル構成についてのポイントは以下の通りです。
- sourcesディレクトリにLaravelアプリケーションのソースコード
- local/mysqlはローカル環境のみ利用するファイル
- 用途の異なるdocker-compose.ymlが2つ
- ルートディレクトリのdocker-compose.ymlはCI/CDパイプラインのユニットテスト用
- localディレクトリのdocker-compose.ymlはローカル環境デプロイ用
- taskdef.jsonは開発環境と本番環境で共用(詳細は後述)
- 各buildspec(詳細は後述)
- buildspec-app.yml => Buildステージ/Build-appアクション用
- buildspec-web.yml => Buildステージ/Build-webアクション用
- buildspec-taskdef.yml => Buildステージ/Build-taskdefアクション用
- buildspec-migrate.yml => Migrationステージ/Migrationアクション用
タスク定義
開発環境と本番環境は異なるAWSアカウントです。各AWSアカウントのECS(Fargate)にてWebアプリケーションが稼働する形です。
そのため開発環境と本番環境において異なるECSタスク定義を用意する必要があります。タスク定義はtaskdef.jsonに記述して、CI/CDパイプラインにてそれを読み込みデプロイするという形になります。
別のタスク定義ということはtaskdef.jsonもそれぞれの環境分(taskdef-prd.json/taskdef-dev.jsonのように)用意する必要があります。
本システムでは1つのtaskdef.jsonファイルを開発環境と本番環境は共通のタスク定義ファイルを利用しています。
どのように共通のタスク定義ファイルとしているかは、以下ブログにて記載しております。
パイプライン設計
アーキテクチャ
パイプラインのアーキテクチャを以下に示します。
以下ポイントです。
- ソースコードは開発環境のCodeCommitにて管理
- 開発環境と本番環境は異なるパイプライン
- パイプラインの実行をSlackで通知
- CodeCommitの該当ブランチへのPushをトリガーとして、CodeシリーズのCI/CDパイプライン実行
- developブランチ => 開発環境へのデプロイ
- mainブランチ => 本番環境へのデプロイ
フロー
以下はCI/CDパイプラインのフローです。
CodePipelineのステージとアクションの構成と、アクションの中で実行していることを表しています。
開発環境と本番環境は基本的に同じです。(開発環境では承認アクションを割愛など細かい違いはあります)
Sourceステージ
SourceステージにおけるアクションはアクションプロバイダーがCodeCommitであるSourceアクション1つです。
CodeCommitの該当ブランチへのコミット(マージ)をパイプライン実行のトリガーとなります。
Buildステージ
Sourceステージが完了したらBuildステージに遷移します。
BuildステージではアクションプロバイダーがCodeBuildである3つのアクションが並行して実行されます。
アクション名 | 内容 |
---|---|
Build-app | APPコンテナのビルドおよびテストや静的解析などを実施 |
Build-web | Webコンテナのビルドを実施 |
Build-taskdef | ECSタスク定義の変数展開を実施 |
Build-app
APPコンテナ(PHP-FPMが稼働)のビルドおよびテストや静的解析などを実施するアクションです。
このアクションに該当するbuildspec(buildspec-app.yml)の内容は以下の通りです。
version: 0.2 env: variables: DOCKERFILE_PATH: ./docker/app/Dockerfile phases: install: runtime-versions: php: X.X # PHPの実際のバージョンが入る pre_build: commands: - AWS_ACCOUNT_ID=$(echo ${CODEBUILD_BUILD_ARN} | cut -f 5 -d :) - echo Logging in to Amazon ECR... - aws --version - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com - REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/my_repository/app # my_repositoryには実際のレポジトリ名が入る - DOCKERFILE_PATH=${DOCKERFILE_PATH} - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7) - IMAGE_TAG=${COMMIT_HASH} # dockleをインストール(ベストプラクティスチェックツールのインストール) - DOCKLE_VERSION=0.4.14 - rpm -ivh https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.rpm build: commands: # appコンテナのイメージをビルド - echo Build started on `date` - cp ./sources/.env.example ./sources/.env - docker build -f $DOCKERFILE_PATH -t app:latest. # テスト環境を立ち上げ - docker compose up -d # テストの実行 - docker compose exec test-app php artisan migrate - docker compose exec test-app php artisan test # 静的解析の実行 - docker compose exec test-app ./vendor/bin/phpstan analyse --memory-limit=2G --no-progress # テスト環境を削除 - docker-compose down # ベストプラクティスチェックの実行 - dockle -i CIS-DI-0010 -i DKL-DI-0006 --format json --exit-code 1 --exit-level "FATAL" app:latest post_build: commands: - echo Build completed on `date` - echo Pushing the Docker images... - docker tag app:latest $REPOSITORY_URI:$IMAGE_TAG - docker push $REPOSITORY_URI:$IMAGE_TAG - printf '{"ImageURI":"%s"}' $REPOSITORY_URI:$IMAGE_TAG > imageDetail.json artifacts: files: - imageDetail.json
行っていることは
- イメージビルド
- テスト
- 静的解析
- ベストプラクティスチェック
- イメージプッシュ
です。それぞれについて解説していきます。
イメージビルド
buildspecの以下の箇所が該当します。
pre_build: commands: - AWS_ACCOUNT_ID=$(echo ${CODEBUILD_BUILD_ARN} | cut -f 5 -d :) - echo Logging in to Amazon ECR... - aws --version - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --sword-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com - REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/my_repository/ # my_repositoryには実際のレポジトリ名が入る - DOCKERFILE_PATH=${DOCKERFILE_PATH} - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7) - IMAGE_TAG=${COMMIT_HASH} # dockleをインストール(ベストプラクティスチェックツールのインストール) - DOCKLE_VERSION=0.4.14 - rpm -ivh https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.rpm
AWSのサンプルがベースとしたシンプルなビルドプロセスです。
環境変数のENVはビルドプロジェクトにて登録している環境変数です。環境ごとにprdやdevなどの固有の文字列が入ります。
また、後続の処理にてdockleを利用するので、pre_buildにてdockleをインストールしています。
テスト
CI/CDパイプラインにてテストを実行する方法はいくつかあります。本システムでは「ビルド環境上にてdocker-composeでテスト環境をデプロイして、そのテスト環境においてテストを実行」するという手法を採用しています。
buildspecの以下の箇所がテスト実行に該当します。
build: commands: # appコンテナのイメージをビルド - echo Build started on `date` - cp ./sources/.env.example ./sources/.env - docker build -f $DOCKERFILE_PATH -t app:latest. # テスト環境を立ち上げ - docker compose up -d # テストの実行 - docker compose exec test-app php artisan migrate - docker compose exec test-app php artisan test
まずテスト環境(テスト用のAPPコンテナとDBコンテナ)をデプロイします。
この時利用するイメージはイメージビルドにて作成されたものです。
services: test-app: image: "app:latest" test-db: build: context: . dockerfile: ./docker/test-mysql/Dockerfile ports: - 3306:3306 volumes: - ./docker/test-mysql/initdb.d:/docker-entrypoint-initdb.d
テスト環境のデプロイ後にマイグレーションとテスト(Unitテスト/Featureテスト)を実行します。
テストにてエラーやPassにならない項目があった場合は、docker compose exec test-app php artisan testコマンドの終了ステータスが0以外となります。CodeBuildではコマンドの終了ステータスが0以外の場合に、パイプラインの実行が失敗となります。
静的解析
buildspecの以下の箇所が該当します。
# 静的解析の実行 - docker compose exec test-app ./vendor/bin/phpstan analyse --memory-limit=2G --no-progress # テスト環境を削除 - docker-compose down
テスト用APPコンテナに対してPHPStanによる静的解析を実行します。
エラーなどがあった場合は、コマンドの終了ステータスが0以外になり、パイプラインの実行が失敗となります。
そして、静的解析が完了したらテスト環境を破棄します。
ベストプラクティスチェック
buildspecの以下の箇所が該当します。
# ベストプラクティスチェックの実行 - dockle -i CIS-DI-0010 -i DKL-DI-0006 --format json --exit-code 1 --exit-level "FATAL" app:latest
dockleによるコンテナのベストプラクティスチェックを行います。
ベストプラクティスチェックにより閾値以上の脆弱性が含まれている場合は、コマンドの終了ステータスが0以外になり、パイプラインの実行が失敗となります。
イメージプッシュ
post_build: commands: - echo Build completed on `date` - echo Pushing the Docker images... - docker tag app:latest $REPOSITORY_URI:$IMAGE_TAG - docker push $REPOSITORY_URI:$IMAGE_TAG - printf '{"ImageURI":"%s"}' $REPOSITORY_URI:$IMAGE_TAG > imageDetail.json artifacts: files: - imageDetail.json
単純にECRをプッシュしています。
これはAWSのサンプルがベースとしたシンプルなプロセスです。
Build-web
NGINXコンテナイメージをDockerfileからビルドして、ECRへプッシュするというシンプルな内容です。
特段ユニークなことはしていないため、詳細の記載は割愛いたします。
Build-taskdef
前述したとおり、本システムではtaskdef.jsonファイルを開発環境と本番環境で共用できるようにしている。
共用する仕組みは、taskdef.jsonの環境に依存する部分を変数化して、パイプラインにて環境に合わせて変数を展開する。
具体的にはsedコマンドを利用して変数化した部分をCodeBuildビルドプロジェクトの環境変数およびSecretManagerから取得した値で置換している。
以下はbuildspecの内容です。
version: 0.2 env: secrets-manager: task_cpu: /ecs:task_cpu task_ram: /ecs:task_ram phases: post_build: commands: - echo Create EC Task Definition - sed -i -e "s#<ENV>#${ENV}#" taskdef.json - sed -i -e "s#<ACCOUNT_ID>#${ACCOUNT_ID}#" taskdef.json - sed -i -e "s#<task_cpu>#${task_cpu}#" taskdef.json - sed -i -e "s#<task_ram>#${task_ram}#" taskdef.json artifacts: files: - taskdef.json - appspec.yaml
環境変数のENVはビルドプロジェクトにて登録している環境変数であり、環境ごとにprdやdevなどの固有の文字列が入ります。同じくACCOUNT_IDもビルドプロジェクトに登録している環境変数で、AWSアカウントIDが文字列として入ります。
この仕組みの詳細については、以下ブログをご参照ください。
Approvalステージ
アクションプロバイダーが手動承認のApprovalアクションがあり、マイグレーションとデプロイの前にプロダクト管理者による承認を行います。
パイプライン実行の通知をSlackで受け取ったプロダクト管理者が、後続処理の実行を承認します。
Migration
アクションプロバイダーがCodeBuildのMigrationアクションがあります。
以下がbuildspecの内容です。
version: 0.2 secrets-manager: DB_HOST: /rds:host DB_PORT: /rds:port DB_USERNAME: /rds:username DB_PASSWORD: /rds:password DB_DATABASE_APP: /rds:database phases: install: runtime-versions: php: X.X # 実際のPHPバージョンが入ります build: commands: - echo Installing packages... - cd ./sources - composer install - echo Creating .env file... - cp .env.example .env - php artisan migrate
ビルド環境にLaravelをインストール(Composer install)して、ビルド環境からDBに接続してマイグレーションを実行します。
Deploy
デプロイステージはアクションとしては1つです。
アクションプロバイダーはAmazon ECS(ブルー/グリーン)であり、Blue/Greenデプロイメント方式を採用しています。
アクションとしては1つですが、内部のフローを3つに分割して考えています。
それぞれについて解説します。
デプロイ①
Blue/Greenデプロイメント方式によりtaskdef.jsonから新しいタスク定義が作成されて、それを元に新しいタスクが作成されます。
この時点ではポート443へのアクセスはBlue環境にルーティングされ、ポート8080へのアクセスはテストリスナー経由でGreen環境へルーティングされる形となります。
この段階は、デプロイステータスの「ステップ2:テストトラフィックルーティングのセットアップ」までが完了している状況です。
デプロイ承認
トラフィックの再ルーティング(Blue環境からGreen環境への切り替え)の待機と手動再ルーティングを利用して、動作確認と実質的な承認をできるようにしています。
(トラフィックの再ルーティングを最大(2日間)まで待機させることが可能なので、待機時間を最大にしています)
再ルーティングの待機中にプロダクト管理者がGreen環境へアクセスして、アプリケーションが正常に稼働しているかを確認します。
アプリケーションが正常に稼働していることを確認してから、手動で再ルーティングを行います。
手動で再ルーティングを行うと、デプロイステータスの「ステップ2:テストトラフィックルーティングのセットアップ」以降のステップが開始されます。
デプロイ②
本稼働トラフィックを置き換えタスクセットに再ルーティングされ、ポート443へのアクセスはGreen環境に繋がるようになります。
この段階は、デプロイステータスの「ステップ3:本稼働トラフィックを置き換えタスクセットに再ルーティング中」までが完了している状況です。
なお、何か不足の事態が発生した時に即座にロールバックできるようにするため、半日(6時間)はBlue環境を残しておくようにしています。
6時間が経過したら、デプロイステータスは全て完了になります。
最後に
以上がECS上で稼働するWebアプリケーション(Laravel)のCI/CDパイプライン構成についての説明になります。
実際はもう少し複雑な構成になっておりますが、大枠は記載の通りになります。
またCI/CDパイプライン(それ以外もですが)は一度作成したら、それで完成というわけではありません。
継続的な改善などが必要になります。本システムでは直近、パイプラインでのセキュリティスキャン(最近CodePipelineにてInspectorスキャンがアクションとしてサポートされたので)の実装を検討しています。
以上です。
本ブログがどなたかの助けになれば幸いです。