明けましておめでとうございます!ようやく正月ボケが抜けてきたような気がします。。

新年一発目の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ファイルを開発環境と本番環境は共通のタスク定義ファイルを利用しています。

どのように共通のタスク定義ファイルとしているかは、以下ブログにて記載しております。

[ECS] タスク定義ファイル(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が文字列として入ります。

この仕組みの詳細については、以下ブログをご参照ください。

[ECS] タスク定義ファイル(taskdef.json)の運用について考える

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スキャンがアクションとしてサポートされたので)の実装を検討しています。

以上です。
本ブログがどなたかの助けになれば幸いです。