エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

エムスリーエンジニアリンググループ 第1回開発合宿を開催しました

エムスリー ソフトウェアエンジニアの笹川です。 AI・機械学習チームに所属しており、データ基盤周りの整備を行なっています。 趣味は筋トレで、いいコードを書くためにBIG 3 (ベンチプレス、スクワット、デッドリフト)をメインに鍛えています (AIチームのリーダはベンチプレスの最高挙上重量で決まるという噂もあります(噂です))。

今回は、先日(7/27〜7/29)開催したエンジニアリンググループ初めての開発合宿の様子を報告したいと思います。

開発合宿を行なった背景

エムスリーでは、エンジニアは5〜8人程度のチームで日々担当プロダクト改善と、新規機能追加などを実施していますが、 工数や、施策の優先順位の問題で、既存施策の延長上にない「全く新しい」取り組みをすることは、 短期的には施策の進捗悪化などに繋がるため、簡単ではない状況があります。 そもそも「全く新しい取り組みをするべきなのか?」という問題はあるものの、 新しい技術の検証や、チーム外のエンジニアの知見を持ち寄って自由に施策を考え実現することは、 そのプロダクト(または、担当のエンジニア)への新しい刺激や、それに取り組んだエンジニア自体へのいい影響を与えることに繋がり、 この事は、短期的な悪影響を鑑みても、余りあるメリットがあると考えます。 今回は、上記をエンジニア側から提案し、上長に了承を得ることができたので、有志9名のメンバーで無事実施の運びとなりました。

道中の様子

当日は、新橋駅の乗り換えで2名が迷子(笑)になるなど、半ばお決まりの流れをだいたい踏みながら合宿所に向かいました。 当日は天気もよく、電車の車窓から見える景色の非日常感にメンバーのテンションが爆上がりでした。

f:id:hsasakawa:20180802101612j:plain
道中の様子

今回利用したのは、千葉県富津市金谷にある voido という施設です。 内房線浜金谷駅から徒歩5分ほどにある戸建住宅のようなこの施設は、テーブル、キッチン、ベッド、お風呂、洗面台など基本的な施設はもちろん、 wifi、プロジェクタ、電源タップ、USB-C対応の各種アダプタなども備え、エンジニアにはかゆい所に手がとどく素晴らしい施設でした。 タオル、歯ブラシなど各種アメニティなどもあるため、今回のように開発合宿で利用する場合は、最低限PCさえあれば大丈夫です。

合宿で取り組んだこと

合宿では、以下の2つのチームに別れそれぞれの課題に取り組みました。

  • チャットUIの問診アプリを作るチーム (以後、問診チーム)
  • 弊社サービスの AskDoctorsAndroidアプリを作るチーム (以後、Askチーム)

筆者も所属した問診チームは、LINEのようなチャットUIをベースとし、患者さんとインタラクションをしたのちに 患者さんの行くべき診療科を提案したり、反対にお医者さん側に患者さんの情報を提供する機能を実装することを目指しました。 一方Askチームは、AskDoctorsの基本的な機能を全て備えたAndroidアプリをクライアント側をKotlin、バックエンドのAPIをGraphQLで実装することに取り組みました。 どちらのチームも最終的には、社内の事業チームへ機能追加の提案に持っていくことを目標にしました。

問診チームは手を動かし始める前に、このアプリが解決する問題について議論する時間を設けました。 議論の中では、

  • 仮にスマホアプリとして実装した場合、患者さんはそもそもアプリを入れてくれるのだろうか
    • 一般にスマホアプリのDLは敷居が高い(なかなか入れてもらえない)
    • 患者さんは普段の健康な状態で、病気を想定したアプリを入れるモチベーションがあるのか
  • 病院の待合室でよくやっている紙とペンでの問診と比べて何かメリットはあるのか

など数多くの意見がでました。 最終的な結論として、病院の待合室で利用されることを想定し、紙とペンで行われている物を置き換えることで、 電子カルテへの入力の手間の削減などをメリットとして提供する、という方針となりました。 上記の議論には1日目の大半を使い、15時頃合宿所についてから実際にコードを書き始めたのは初日の21時過ぎ頃(initial commitの時間)でした。

Askチームは、実装する対象が決まっており、合宿所に向かう電車の中で基本的なデザインについて作業をしていたので 合宿所について早々コーディングに取り組んでいました。 コーディングには、VS CodeLive Share が威力を発揮し、 クライアント側の実装ではペアプロを別々の端末から行うなどして効率的に開発していました (同一のファイルを別々の人が編集したことで、コミットする際に、作業者が該当の変更に覚えがなくstashする面白事案も発生しました笑)。

f:id:hsasakawa:20180802101602j:plainf:id:hsasakawa:20180802100939j:plain
コーディングに取り組む様子

合宿初日の夜は、赤坂の弊社オフィスで実施されたM3 Tech Talk(隔週実施)にSkypeでリモート参加しました。 リモート参加は初の試みでしたが、音質や、画像に関しては、高品質とは言えないものの、 会場の空気を感じられ、会場とのやりとりもでき、合宿チーム側はワイワイ盛り上がっていました。 なお、M3 Tech Talkは社外の方の参加も可能ですので、ご希望の方はぜひ下記からご応募ください!

2日目は、一日中コーディングに励んでいましたが、この日は台風12号が接近し日中から夕方にかけて海が近い合宿所のあたりも大荒れでした。 家族が不安になって連絡してきたメンバーなど2名は途中で帰宅することとなりましたが、 途中で帰宅した2名も含め参加メンバーに特に被害はなく無事に合宿を終えることができました。 この日は、外が荒れていたこともあり全員集中して取り組むことができ、2チームともほぼこの日のうちにおおよその成果物ができあがっていました。 おおよそプロトタイプの完成の目処がたっていたので、夜ご飯を食べたあとに、今回取り組みで使った技術などを発表する即席のLT大会を実施しました。

f:id:hsasakawa:20180802101539j:plain
LT大会の様子

振り返りと成果発表会

3日目は各チーム疲れが見える状況でしたが、朝からプロトタイプの細かな修正を実施し、 お昼頃から合宿のラップアップとして、今回の合宿の振り返りと成果報告の発表会をしました。 振り返りのKPTでは、

  • ロケーションや、会場が素晴らしかった
  • 開発に没頭できてよかった
  • 新しい技術をガッツリ触って試すことができてよかった

というKeepや、

  • もう少し東京から近いところで実施してもいいかもしれない
  • チームごとに事前準備をもう少ししておくべきだった
  • 依存関係のある作業を分担したため、前行程が終わるまで後行程担当の人の手が空いてしまう時間があった

などのProblemもありました。 次へのTryも具体的に決めることができたので、次回以降でどんどん実施して改善していきたいと思います。

成果発表会は、各チーム今回取り組んだ成果物と、それまでのプロセスについて発表しました。 成果発表会には、弊社VPoEの山崎が自慢のバイク(Triumph Daytona 675R)で駆けつけ、 発表内容について、山崎含め、チーム内外から質問、議論が活発に行われました。

まとめ

f:id:hsasakawa:20180802100327j:plain
最後に撮った集合写真

本稿ではエムスリー初の開発合宿の様子について報告しました。 振り返ってみると、前述した「全く新しい」取り組みを実施するという目標は達成でき、成果物を作ることができました。 個人的にも、一つのプロダクトに対して、「何を解決するか」にフォーカスしてしっかり時間をとって議論することができたことは いい刺激になりました。 成果物に関しては、今後各ステークホルダーへ提案に行く予定です。 次回の合宿では今回の振り返りの内容を踏まえて、どんどん改善して、より有意義な時間としたいと思います。

P.S.

今回利用した合宿所 voidoの方には、個人的に備品として懸垂バーと、腹筋ローラーの追加をお願いしておきました(してません)。

エンジニア募集

エムスリーでは自身で手を動かし、技術で医療の課題を解決するエンジニアを募集しています。 この記事(or 他の記事も)を読んで興味を持った方はぜひ下記リンクよりご応募ください!

AWS Fargateのデプロイパイプライン(Gitlab > S3 > CodePipeline)を構築してみた

こんにちは、エムスリーエンジニアの園田です。

この記事はAWS FargateでElixirのコンテンツ配信システムを動かしてみた (実装編) - エムスリーテックブログの続きです。

エムスリーでは医療・ヘルスケアサイト向けのコンテンツ配信システムであるChuoiというサービスを運用しています。先日のポストで、ElasticBeanstalkからFargateに運用を切り替えたことについて書きました。

www.m3tech.blog

今回は前回に引き続きその実装編で、CodePipeline を利用したデプロイパイプラインの構築について書きます。

まずは構成のおさらいです。

f:id:ryoheisonoda:20180713112117p:plain

デプロイ周りは以下のような構成です。

f:id:ryoheisonoda:20180713115557p:plain

社内 Gitlab からの CI/CD パイプライン構築

先日の記事で述べたとおり、弊社ではソース管理にオンプレの Gitlab を使っており、 CodeBuild や CodePipeline のトリガとして利用できません。 そのため、Gitlab からは S3 へソースのアップロードのみを行い、以降の処理は CodePipeline で実行するという構成にしました。

デプロイパイプラインを構築した際の大まかな手順は以下の通りです。

  1. ビルドスクリプト (buildspec.yml) の実装
  2. ソース・中間成果物格納バケットの作成
  3. パイプライン用の IAM ロール作成
  4. CodePipeline の作成
    1. Source ステージの設定
    2. Build ステージの設定 (CodeBuild プロジェクトの作成)
    3. Deploy ステージの設定
  5. Gitlab CI のデプロイジョブを設定

ファイル・ディレクトリ構成

前回の記事にも記載しましたが、再掲します。 前回と違い、デプロイパイプラインと直接関係のないファイル類は省略しています。

.
├── dockerfiles/
│   ├── app/
│   ├── log/
│   ├── web/
│   ├── archive-and-upload-src.sh
│   ├── build-docker-images.sh
│   └── push-to-ecr.sh
├── .gitlab-ci.yml
└── buildspec.yml

buildspec.yml の作成

CodeBuild で実行するジョブを定義する buildspec.yml を実装します。
buildspec.yml では以下のことを行います。

  1. Dockerイメージのビルド
  2. Dockerイメージを ECR に push
  3. imagedefinitions.json の生成

buildspec.yml にすべての処理を書くと非常に煩雑になるため、各処理を実行するためのスクリプトは buildspec.yml とは別のファイルとしています。

Dockerイメージのビルド

ファイル・ディレクトリ構成にある dockerfiles/build-docker-images.sh に実装します。内容は以下の通りです。 環境変数MIX_ENVSECRET_KEY_BASEは CodeBuild プロジェクトのビルド変数として設定されているものが渡されます。
SECRET_KEY_BASEParameter Storeに暗号化されて格納しており、ビルド実行時に復号化されて環境変数としてプロセスに渡されます。

echo Build started on `date`
echo Building the Docker image...

docker build -t "app:latest" \
  -f dockerfiles/app/Dockerfile  \
  --build-arg MIX_ENV=${MIX_ENV} \
  --build-arg SECRET_KEY_BASE=${SECRET_KEY_BASE} \
  ./

docker build -t "web:latest" ./dockerfiles/web
docker build -t "log:latest" ./dockerfiles/log

echo Build completed on `date`

Dockerイメージを ECR に push

つづいて dockerfiles/push-to-ecr.sh の中身です。AWS_REGIONは CodeBuild のビルトイン変数なので指定する必要はありませんが、 AWS_ACCOUNT_IDは CodeBuild のビルトイン変数ではないため、ビルド変数としてプロジェクトに設定する必要があります。

ECR_REPO_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"

echo Logging in to Amazon ECR...
$(aws ecr get-login --region ${AWS_REGION} --no-include-email)

echo Pushing the Docker images...

IMAGE_TAG=$(get_image_tag)

for repo in app web log; do
    docker tag "${repo}:latest" "${ECR_REPO_URI}/${repo}:latest"
    docker tag "${ECR_REPO_URI}/${repo}:latest" "${ECR_REPO_URI}/${repo}:${IMAGE_TAG}"
    docker push "${ECR_REPO_URI}/${repo}:${IMAGE_TAG}"
    docker push "${ECR_REPO_URI}/${repo}:latest"
done

上のサンプルのIMAGE_TAGを取得する関数get_image_tagは以下のような実装になっています。

get_image_tag() {
  if [ -e .git-commit-hash ]; then
    cat .git-commit-hash
  elif [ ! -z "$CODEBUILD_RESOLVED_SOURCE_VERSION" ]; then
    echo ${CODEBUILD_RESOLVED_SOURCE_VERSION}
  else
    echo latest
  fi
}

ソースのルートに.git-commit-hashファイルがあればその内容を、 なければ CodeBuild のビルトイン変数であるCODEBUILD_RESOLVED_SOURCE_VERSIONを、それもなければlatestを返しています。

後述しますが、.git-commit-hashファイルは Gitlab CI から S3 にソースをプッシュする際に生成しています。 なお、 CodeCommit や Github をソースリポジトリとする場合、コミットハッシュは環境変数CODEBUILD_RESOLVED_SOURCE_VERSIONから取得可能です。 今回は Gitlab を利用するので、環境変数からは取得できないため、ファイルに書き出しています。

imagedefinitions.json の生成

以下の内容を dockerfiles/push-to-ecr.sh の末尾に追記します。

echo Writing image definitions file...
cat <<__JSON__ > imagedefinitions.json
[
  {"name": "app", "imageUri": "${ECR_REPO_URI}/app:${IMAGE_TAG}"},
  {"name": "web", "imageUri": "${ECR_REPO_URI}/web:${IMAGE_TAG}"},
  {"name": "log", "imageUri": "${ECR_REPO_URI}/log:${IMAGE_TAG}"}
]
__JSON__

CodePipeline のデプロイプロバイダにECSを利用する場合、このimagedefinitions.jsonが必要になります。
コンテナ名とイメージの対応を JSON で渡すことで、 CodePipeline が以下のことをやってくれます。

  1. imagedefinitions.jsonの通りにタスク定義のコンテナイメージを変更し、新しいタスク定義のリビジョンを作成 (コンテナイメージ以外のパラメータはそのまま)
  2. 新しいタスク定義で起動するようにECSサービスを更新

なお、タスク定義の新しいリビジョンは、 現在サービスに適用されているタスク定義から 作成されます。 サービスに適用されていない最新のタスク定義があったとしても、そちらは利用されないのでご注意ください。

buildspec.yml

ビルドとECRプッシュを別ファイルに切り出したので、 buildspec.yml は非常にシンプルです。

version: 0.2
phases:
  build:
    commands:
      - dockerfiles/build-docker-images.sh
  post_build:
    commands:
      - dockerfiles/push-to-ecr.sh
artifacts:
  files:
    - imagedefinitions.json

ポイントは、imagedefinitions.jsonartifactsとして保存することです。これをしないと CodePipeline がECSにデプロイできません。

ここまでで AWS リソースを作成するための準備が整いました。次からは AWS の各リソースを作成していきます。

作成するAWSリソースの一覧

マネジメントコンソールでパイプラインを作成する場合に必要なリソースの一覧です。 マネジメントコンソールで作成する場合は CloudWatch Event Rule など一部のリソースが強制的に自動生成されるため、 Terraform で作成する場合と顔ぶれが異なるので注意が必要です。

  • ソースおよびパイプラインの中間成果物を格納するバケット
  • パイプライン用の IAM ロール (面倒なのでまとめて1つ)
  • Parameter Store の SECRET_KEY_BASE パラメータ
  • CodePipeline のパイプライン
  • CodeBuild のプロジェクト

以下はマネジメントコンソール利用の場合に自動生成されるリソースです。 Terraform で作成する場合は以下のリソースも Terraform のテンプレートに含めます。

  • ソースの更新を検知してパイプラインを起動する CloudWatch Event Rule
  • 上記 CloudWatch Event Rule の IAM ロール

ソース・中間成果物格納バケットの作成

CodePipeline で S3 をトリガとする場合、以下のバケットが必要です。

  • パイプラインのトリガとなるソースのバケット
  • パイプラインの中間成果物を格納するバケット

Gitlab からソースをアップロードする S3 バケットを作成します。注意点は 1 点だけで、バージョニングを有効にするだけです。
バージョニングが無効な場合、 CodePipeline 実行時に エラーになります。作成時ではないので注意が必要です。

パイプラインの中間成果物を格納するバケットは、何も指定しなければ CodePipeline が自動生成しますが、Terraform フレンドリではないので作成したほうがいいです。
今回の場合はソース格納バケットと共用にしました。

パイプライン用の IAM ロール作成

パイプラインを構成する上で必要なIAM ロールは3つあります。

  • ソースの変更を検知して CodePipeline を起動する IAM ロール (codepipeline:StartPipelineExecution)
  • CodeBuild のビルド実行 IAM ロール (成果物の取得・保存と ECR, Parameter Store に対するアクセス)
  • CodePipeline 自体の IAM ロール (成果物の取得・保存と各ステージの実行権限)

最初のロールは、マネジメントコンソールで CodePipeline を構築する際に自動で作成されるため、今は作成しませんが、 Terraform で作成する場合は定義を実装する必要があります。

管理が煩雑にならないように、必要な権限を 1 つの IAM ロールにまとめました。
1 つのロールに複数の役割を持たせるのはアンチパターンですが、デリバリプロセス全体のロールということで、1 つにまとめても問題ないと判断しました。 すべての権限をまとめた IAM ロールのポリシーは以下のようになります。

まずは AssumeRolePolicy です。CodeBuild, CodePipeline の信頼関係を持ちます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": [
          "codebuild.amazonaws.com",
          "codepipeline.amazonaws.com"
        ]
      }
    }
  ]
}

続いて CodeBuild のための IAM ロールポリシーです。 ECR に対するアクセスと、パラメータの参照、ソース取得、成果物の格納、ログの出力の権限が必要です。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::${SOURCE_ARTIFACT_BUCKET}",
        "arn:aws:s3:::${SOURCE_ARTIFACT_BUCKET}/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:PutLogEvents",
        "logs:CreateLogStream",
        "logs:CreateLogGroup",
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameters",
      "Resource": "arn:aws:codepipeline:ap-northeast-1:${AWS_ACCOUNT_ID}:parameter/SECRET_KEY_BASE"
    },
    {
      "Effect": "Allow",
      "Action": "ecr:*",
      "Resource": "arn:aws:codepipeline:ap-northeast-1:${AWS_ACCOUNT_ID}:repository/*"
    }
  ]
}

Parameter Store に KMS を利用している場合は、上記に加え KMS による復号化権限も必要です。

CodePipeline のための IAM ロールポリシーです。CodeBuild, CodeDeploy の実行等の権限が必要です。 Deploy プロバイダに ECS を利用するため、ECS に関する権限が必要です。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::${SOURCE_ARTIFACT_BUCKET}",
        "arn:aws:s3:::${SOURCE_ARTIFACT_BUCKET}/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole",
        "elasticloadbalancing:*",
        "ecs:*",
        "codedeploy:*",
        "codebuild:*",
        "cloudwatch:*"
      ],
      "Resource": "*"
    }
  ]
}

これらのインラインポリシー *1 を持った IAM ロールを作成して利用します。

Parameter Store の SECRET_KEY_BASE パラメータ

CodeBuild でビルド変数として利用する Parameter Store のパラメータは事前に設定しておく必要があります。
平文のパラメータであれば Terraform のテンプレートなどに含めれば問題ないですが、シークレットパラメータは Terraform 管理外にして CLI で事前に登録しておくのが楽です。 *2

aws ssm put-parameter \
  --name "SECRET_KEY_BASE" --value "xxx......" \
  --type "SecureString" --overwrite

CodePipeline のパイプライン

CodePipeline を作成することで、あわせて以下のリソースが作成されます。

  • ソースバケットを監視してパイプラインを起動するCloudWatch Event Rule
  • CodeBuild プロジェクト

マネジメントコンソールで CodePipeline を作成する際に必要なパラメータは以下の通りです。

  • ソースとなる S3 バケットと S3 オブジェクトのキー
  • CodeBuild のビルド変数
  • CodeBuild の IAM ロール (前段で作成済み)
  • ECS クラスター名と ECS サービス名 (前回の記事で作成済み)

パイプラインの作成

マネジメントコンソールで CodePipeline の作成を行うと、まずはじめにパイプライン名称の入力を求められます。 パイプライン名称を入力して次のステップボタンを押したら Source ステージの設定に続きます。

f:id:ryoheisonoda:20180731102729p:plain

Source ステージの設定

ソースを取得するバケットとオブジェクトのキーを入力します。これによりソースを監視する CloudWatch Event Rule と IAM ロールが自動生成されます。*3

以下を入力して次のステップボタンを押します。

  • ソースプロバイダ: Amazon S3
  • Amazon S3 の場所: s3://${ソースバケット名}/${オブジェクトキー}
  • 変更検出オプション: 変更が発生したときに Amazon CloudWatch イベントを使用してパイプラインを自動的に開始する (推奨)

f:id:ryoheisonoda:20180731102947p:plain f:id:ryoheisonoda:20180731102950p:plain

Build ステージの設定と CodeBuild プロジェクトの作成

続いてビルドステージを設定します。ビルドステージでは CodeBuild プロジェクトの作成を行います。

  • ビルドプロバイダ: AWS CodeBuild
  • 新しいビルドプロジェクトを作成
    • プロジェクト名: CodeBuild のプロジェクト名称
    • 説明: 任意の説明
    • 環境イメージ: AWS CodeBuild マネージド型イメージの使用
    • OS: Ubuntu
    • ランタイム: Docker
    • バージョン: aws/codebuild/docker:17.09.0
    • ビルド仕様: ソースコードのルートディレクトリの buildspec.yml を使用
  • キャッシュ: キャッシュなし *4
  • CodeBuild サービスロール: 前段で作成した IAM ロール
  • VPC:
    • VPC ID: 非 VPC
  • アドバンスト
    • ビルドタイムアウト: 任意のタイムアウト時間
    • 特権付与: チェックなし (ランタイムがDockerの場合、自動でONになる)
    • コンピューティング: 任意のリソース (build.standard.largeなど)
  • 環境変数
    • MIX_ENV: 環境に応じた値 (develop, staging, productionなど)
    • SECRET_KEY_BASE: TypeParameter Storeに設定し、値に Parameter Store の パラメータ名 を入力
    • AWS_ACCOUNT_ID: AWSのアカウントID (Build ステージで ECR リポジトリURI 特定に利用する)

上記を入力したらビルドプロジェクトの保存ボタンを押して CodeBuild プロジェクトを作成したら、 次のステップボタンを押してデプロイステージの設定に移ります。

f:id:ryoheisonoda:20180731103543p:plain f:id:ryoheisonoda:20180731103548p:plain f:id:ryoheisonoda:20180731103554p:plain f:id:ryoheisonoda:20180731103558p:plain

Deploy ステージの設定

最後にデプロイステージを設定します。

  • デプロイプロバイダ: Amazon ECS
  • クラスター名: 前回作成した ECS クラスター名
  • サービス名: 前回作成した ECS サービス名
  • イメージのファイル名: 空欄のまま

イメージのファイル名は、ECSデプロイプロバイダの場合、デフォルトでimagedefinitions.jsonを参照するようになります。 ファイル名を変更する場合は CodeBuild の buildspec.yml で artifacts に定義したファイル名を入力してください。

f:id:ryoheisonoda:20180731104007p:plain

動作確認

Gitlab にジョブを作成する前に、 CodePipeline の動作確認を行います。

アプリケーションコードを zip 圧縮して S3 の指定したパスにアップロードしてみます。

zip -r src.zip .dockerignore ./dockerfiles buildspec.yml \
  mix.exs mix.lock ./config ./lib ./priv ./rel \
  package.json tsconfig.json webpack.config.js yarn.lock ./ts/main

できあがった src.zip をマネジメントコンソールからアップロードしました。 CodePipeline が起動して ECS へのデプロイが完了することを確認します。

以上で CodePipeline による ECS へのデプロイパイプライン構築は完了です。

Gitlab CI のデプロイジョブを設定

デプロイ IAM ユーザの作成

Gitlab に設定する IAM ユーザを作成します。 設定するポリシーは CodePipeline で監視しているバケットとオブジェクトに対するs3:PutObject権限のみです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::${SOURCE_ARTIFACT_BUCKET}/${SOURCE_OBJECT_KEY}"
    }
  ]
}

Gitlab プロジェクトに IAM 認証情報を設定

Gitlab プロジェクトに AWS の認証情報を設定します。メニューから Settings > CI / CD > Variables を選択して、 作成した IAM ユーザのAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYを指定します。

アップロードスクリプトの実装

ファイル・ディレクトリ構成にある dockerfiles/archive-and-upload-src.sh にデプロイのスクリプトを実装しました。 やってることはシンプルで、これだけです。

  1. Git のコミットハッシュをファイルに出力
  2. テストコードなどを除いたファイルを zip 圧縮
  3. 圧縮したファイルを S3 にアップロード
SOURCES="
.dockerignore ./dockerfiles buildspec.yml mix.exs mix.lock
package.json tsconfig.json webpack.config.js yarn.lock ./ts/main
./config ./lib ./priv ./rel
"

rm -f src.zip
git rev-parse HEAD | cut -c 1-8 > .git-commit-hash
zip -r src.zip .git-commit-hash $SOURCES

aws s3api put-object \
  --bucket $S3_BUCKET --key ${S3_OBJECT_KEY} \
  --body src.zip

Gitlab CI のジョブを作成

.gitlab-ci.ymlにデプロイのマニュアルジョブを追加します。

trigger_pipeline:
  image: python
  variables:
    AWS_REGION: ap-northeast-1
    S3_BUCKET: hogehoge-source-dev
    S3_OBJECT_KEY: hogehoge-src.zip
  before_script:
    - apt-get update && apt-get install -y zip git
    - pip install --upgrade awscli
    - aws configure set default.region ${AWS_REGION}
  script:
    - dockerfiles/archive-and-upload-src.sh
  when: manual

デプロイパイプラインの実行

Gitlab からマニュアルジョブを実行し、CodePipeline が正常終了すれば成功です。

次回は前回構築した Fargate 構成、および今回の CodePipeline を含めた Terraform テンプレートの説明をしたいと思います。

エンジニア募集中

エムスリーでは AWS の新サービスを試したいエンジニアを募集しています。 AWS に限らず、Firebase や GCP なども積極的に利活用しています。興味が湧いた方はぜひ Tech Talk やカジュアル面談にお越しください。 お問い合わせは以下フォームより、お待ちしております。

jobs.m3.com

*1:ユーザ定義ポリシーを作成してそれをロールにアタッチするというのがベストプラクティスのようですが、私はそれを実践する気はありません。 作成したポリシーを複数ロールで利用することがほぼありえないのと、 Terraform などを利用する場合、インラインポリシーでも十分に管理可能であるからです。 ユーザ定義ポリシーは依存関係の複雑化を促し、削除可能なリソースなのかどうかの判断を迷わせるので、私は Disposable なインラインポリシーを好んで使っています。

*2:シークレットパラメータの Infrastructure as Code 上での扱いに関してはいろんな方法があると思いますが、 私は手運用にしてドキュメントに運用手順を記載する方法を取っています。 なぜなら、シークレットをコード化するためにシークレット情報の暗号化など複雑な仕組みを構築しても、たいていの場合それは有効に機能せず、 結局は手作業で登録や更新を行うことになることが多いからです。扱うシークレットが少ないのであればなおさらです。

*3:CodePipeline の作成が完了したタイミングで作成されます。

*4:キャッシュを利用する場合はキャッシュを保存する S3 バケットに対する権限が必要

エムスリーのエンジニア採用について

こんにちは、人事の友永です。主にエンジニアの採用(中途/新卒)を担当しています。先日技術顧問就任をお知らせしましたが、エムスリーでは「エンジニアが生き生きと働ける環境を作ること」それにより「医療業界のイノベーションを更に加速していくこと」を目指しています。今回は組織作りの一環として、エムスリーのエンジニア採用の特徴についてお伝えしたいと思います。

私たちの採用におけるポイントは以下3つです。

  • 主体的にエンジニアが採用に関わる
  • 普段のエムスリーを知ってもらう
  • 全員一致でオファーをする

f:id:nsb248:20170602112904j:plain

主体的にエンジニアが採用に関わる

私は2014年5月にエムスリーに入社しました。その時既にエンジニアグループの中に採用チームがあり、面接や面談対応だけでなく、採用プロセスを検討したり、エンジニアを探すために適切な媒体を選定するなど主体的に採用に関わっていました。それを見て「良い採用ができるのでは!」と直感的に思ったことをよく覚えています。 実務が進んで行くと、私が想像している以上にエンジニアが採用に協力をしてくれるので少し驚くくらいでした。そんななか「何でこんなに協力してくれるのですか?」とあるエンジニアに聞いてみたことがありました(人事担当が聞くのも不思議ですが笑)回答はシンプルで「一緒に働く仲間を自分たちで集めたいから」ということでした。チームでより大きなアウトプットを出すためには、誰と働くかが大事だよねと。このコメントは今でも強く記憶に残っています。ちなみに、当時そのエンジニアは新規事業を担当しており、結果的にチームに優秀なエンジニアが集まり、今ではエムスリーを支える事業の一つに成長していることは個人的にも嬉しいです。

現在もエンジニア3名が採用チームとしてテックブログを盛り上げてくれたり、技術イベントスポンサーの推進やスカウトなど採用活動をリードしてくれています。採用チーム以外でも多くのエンジニアが面接やカジュアル面談の対応でプロセスに関わっており、自分たちで仲間を集める姿勢は今でも変わりません。人事担当としてはお会いする方の情報から興味の方向をイメージし、どのエンジニアと会ってもらうと有意義な時間になりそうか、いつも考えながら面談や面接のアレンジをするよう心がけています。

普段のエムスリーを知ってもらう

人それぞれフィットする会社は違いますし、転職する際の目的や就職先でどのような成長をしたいかも異なります。そのため、候補者の方に会社を正しく理解してもらうことはとても重要です。エムスリーの場合エンジニア界隈の認知にまだ課題があるので、まずはエムスリーについて知ってもらう・興味を持ってもらう機会を設けるようにしています。

具体的には

* Tech Talkなどの勉強会をオープンに実施する

Tech TalkはLT形式のライトな勉強会ですので、まずエンジニアの雰囲気を知りたいなという方にはお勧めです。気軽に参加できますし、Tech Talk後に懇親会を実施することもあるのでお酒を飲みながら一緒に楽しんでいただくこともできます。

* カジュアル面談でお話しする

エムスリーの事業内容や各チームの取り組みなど具体的に聞いてみたい場合はしっかりとコミュニケーションできる環境でお話しをさせていただきます。面談からそのまま会食に行ってざっくばらんに盛り上がるパターンもあります。

勉強会や面談にお越しいただいた方からは「実際の雰囲気が感じられて良かった」「イメージが変わりました!」とFBをいただくこともあるので、直接お話ししたり普段の姿を見ていただくことは重要だなと感じています。また、AIチームが主催している機械学習コンペに参加したことがきっかけでエムスリーに興味を持ち入社に至ったケースもあります。少しでも気になる企業があるようでしたら、積極的に勉強会などに参加してみることをお勧めします。

全員一致でオファーをする

エムスリーの採用プロセスの最後には、候補者にお会いした社員全員と人事で集まる機会があります。このプロセスはエムスリーらしく個人的にも好きなポイントです。具体的には、対象の候補者について期待する点や入社いただいた際にチャレンジいただくミッションなどの共有やすり合わせを行います。その上で全員が是非一緒に働きたい!と結論が出た場合、オファーをさせていただきます。 会社として取り組みたいことがたくさんあるがゆえ、人を採用すること自体が優先されてしまい採用基準が下がったり、懸念があるもののオファーをしてしまうことが時に起きる可能性がありますが、エムスリーではそうならないよう最後に全員で集まり、全員一致したらオファーするようにしています。また、この時間は会社の少し先の未来をイメージしたり、候補者の方が活躍する姿を想像するので、個人的にも楽しい時間です。

最後に

エムスリーの採用についてポイントを書きましたが、まだまだできていないことや改善点も(本当に・・・)たくさんありますのでエンジニアグループ全体で引き続き頑張っていきたいと思います。また採用活動に積極的に関わりたいエンジニアの方も大歓迎です!一緒に組織を前進させていきましょう!

Tech Talkの参加やカジュアル面談希望者も随時募集しておりますので、少しでも気になる方は以下サイトよりお気軽にお問い合わせください。お会いできることを楽しみにしております! jobs.m3.com

胸部X線画像のAI診断エンジンを作ってみる

はじめまして、AIラボ所長兼エンジニアリンググループAI・機械学習チーム所属の高木です。 キャリアのスタートはエンジニアだったのですが、今では9割くらいの時間はビジネス開発に使っています。 今回は合間の時間でやっているエンジニア的な側面でAIラボで何をやっているのかを書いてみることにしました。

AIラボの紹介

AIラボは、臨床現場へのAIの社会実装を実現することを目的にした組織です。 社会実装が目的なので、ビジネスの研究開発とプロダクトの研究開発の両方をやっています。 今回はAIラボの取り組みの一つとして、胸部X線画像のAI診断エンジンを作ってみたので紹介します。

概要

2017年9月にアメリ国立衛生研究所(以下NIH)から約11万枚の胸部X線画像データセットが公開されました。 データセットこちらに公開されています。 このデータセットには14の疾患のラベルがついています。 今回はこのデータセットを用いてAI診断エンジンを作ってみました。 また今回のデータセットではデータ作成をしたNIHが使った訓練とテストのデータ分割方法が公開されていますので同じ条件で精度比較することができます。 ただし次に紹介する論文では必ずしも公式の分割方法を使った性能評価を行なっていない状況です。

先行研究の紹介

(1) ChestX-ray8: Hospital-scale Chest X-ray Database and Benchmarks on Weakly-Supervised Classification and Localization of Common Thorax Diseases

今回のデータセットの作成と開発したエンジンの性能を評価したNIHの論文です。 Resnet50の転移学習をベースに実装されています。 プーリング層と損失関数で一部独自の工夫がなされています。

(2) CheXNet: Radiologist-Level Pneumonia Detection on Chest X-Rays with Deep Learning

Stanford大学のAndrew NGのグループの論文です。 DenseNet121のファインチューニングをベースに実装がされています。 損失関数に関してはNIHの論文と同様に各クラスのデータ数の偏りを補正する工夫が使われています。 また独自に肺炎のデータを収集し、肺炎の検出精度の評価も行なっています。 本論文では公式データ分割ではなく、訓練とテストで患者間の重なりがないpatient-wise分割を採用しています。

(3) Thoracic Disease Identification and Localization with Limited Supervision

GoogleのFei Fei Liのグループの論文です。 Resnet50の転移学習をベースにして実装がされています。 今回のデータセットには11万枚の一部に病変のアノテーションデータがついているので アノテーションデータも活用できるようにResnetの後段に追加のネットワークを入れているのが特徴です。 本論文では公式データ分割ではなく、訓練とテストで患者間の重なりがないpatient-wise分割を採用しています。

(4) Learning to diagnose from scratch by exploiting dependencies among labels

アメリカの医療画像AIスタートアップのenliticの論文です。 こちらは転移学習を行わず、スクラッチでdensenetを学習しているのが特徴です。 転移学習を行わないので512 x 512という大きな画像サイズで学習をしています。 これは縮小によって小さな病変が潰れてしまうことを防いでいます。 本論文では公式データ分割ではなく、完全ランダム分割を使用しています。また3月に出た論文で別の方法で公式分割を行なった論文も出ています。

(5) Diagnose like a Radiologist: Attention Guided Convolutional Neural Network for Thorax Disease Classification

北京交通大学の研究グループの論文です。 こちらはDenseNet121の転移学習をベースに、画像全体での分類と画像全体での分類から得られたclass activation mappingで反応した部分を拡大した画像をもう一度転移学習を行い、全体と部分の両方で分類する手法を取っています。 単純に言うと細部に関するアテンションを見ていることになります。 こちらのデータ分割方法は明示的に書いてはいませんが、おそらく患者間の重なりを考慮せず完全ランダムに訓練とテストを分割しているものと思われます。

(6) Learning to recognize Abnormalities in Chest X-Rays with Location-Aware Dense Networks

医療機器メーカーのシーメンスの研究グループの論文です。 本論文もDenseNet121の転移学習をベースに実装をしています。 画像の縮小を高解像度で行うために転移学習の前に2段のCNNを入れて高解像度の縮小を実現するネットワークを入れているのが特徴です。 本論文ではNIHの公式のデータ分割とpatient-wise分割の両方を実行しています。

(7) Comparison of Deep Learning Approaches for Multi-Label Chest X-Ray Classification

医療機器メーカーのフィリップスの研究員が共著で入っている論文です。 Resnet50をフルスクラッチで実装し、画像サイズを418 x 418にしているのが特徴です。 また画像以外の患者情報も使って手法の比較をしています。 本論文では公式データ分割ではなく、訓練とテストで患者間の重なりがないpatient-wise分割を採用しています。

実装方針

今回の問題を着手し始めた時にtensorflowにまだdensenet121がなかったなどの開発環境の諸条件を考慮して、転移学習を用いないフルスクラッチで構築することにしました。 試行錯誤の末、最終的なモデルは次のような要素を含むチューニングを行ないました。

  1. DenseNetをベースにInceptionを入れたネットワーク
  2. 512 x 512の大きな画像サイズを使用
  3. 画像サイズが大きく、開発環境のメモリが少ないため、Batch Renormalizationを導入
  4. 活性化関数はSWISHを使用
  5. 学習係数は一定期間で減衰するように設定
  6. dilated CNNを使ってメモリを削減

などいくつかのチューニングを行いました。

実験方法

今回は公式のデータ分割、patient-wise分割、ランダム分割の3種類を行いました。 マシン環境はAWS p3.xlargeを使用して学習を行った。

結果

4,5の論文の精度には残念ながら勝てませんでした。 6の論文の精度には勝ったり負けたりですが、若干向こうの方が上です。 f:id:yuzo-takagi:20180726175258p:plain

Class activation mappingで確認してみる

日本放射線技術学会画像部会のデータセットを使って肺腫瘤(nodule)に関してclass activation mappingを見ました。

オリジナル画像

f:id:yuzo-takagi:20180726190656p:plain:w300

病変の場所

f:id:yuzo-takagi:20180726190730p:plain:w300

Class activation mapping

f:id:yuzo-takagi:20180726190803p:plain:w300

なんとなくそれっぽくなっているようです

今後の展開

m3 AIラボでは医療画像の診断補助AIプラットフォームを作ることを発表しました。 今後、各疾患のAIエンジンやプラットフォーム開発などを進めていく予定です。 また希少疾患の発見につながるAIシステムを開発する完全内製プロジェクトもいくつか進んでいます。 臨床現場で使われるAIを開発したい方はぜひm3エンジニアリンググループをご検討ください。 興味のある方は以下のフォームで募集しています

jobs.m3.com

医師への情報伝達を最適化したい

機械学習エンジニアの西場です。

私が「今していること」から「将来しようとしていること」や「仕事のモチベーション」が連想するのが難しいのかなっと思うことが何度かあったので、実現したいことやモチベーションを紹介します。

仕事のモチベーション

私の仕事のモチベーションは、医師への情報伝達を最適化し、医療の質の向上に貢献すること

そのために、様々なプロジェクトを進めており

  • Archimedesでは医師の短期トレンド、長期トレンドのモデル化
  • Bourbakiではコンテンツのバナーのデザインの最適化
  • Cantorではコンテンツのテキストから特徴量の抽出
  • GaussやIsaacsでは医師が興味がある医療系のキーワードの取得

を行っています。(※ プロジェクト名に数学者・物理学者を使っています。個人的にはBourbakiは不満ですが)

それぞれのプロジェクトは独立して既存サービスに貢献しています。将来的にそれらを組み合わせて、さらに情報伝達を最適化することを考えています。

(※ 各プロジェクトの詳細は別のところで紹介したいと思います)

医師に情報を届けることの大切さ

医師に最適な情報を届けることによって医療に貢献できると考えています。

根拠の一つとして、カリフォルニア大学ロサンゼルス校助教の津川友介の論文があります。

この論文は内科医を対象にアメリカで調査が行われています。担当医師が若いほうが入院30日以内に死亡する確率が低いことが示されています。

若い医師のほうが致死率が低い要因として、若い医師のほうが最新の医学知識を持っており、ガイドラインも守る傾向が高いという調査結果が紹介されています。

つまり、最新の医学知識やガイドラインを知っている医師のほうが患者の致死率が低くなることが示唆されます。

情報伝達を効率化する

医学は日々進歩しています。新しい治療法や薬が開発されています。また症例共有も注目されるようになりました。

インターネットのおかげで様々な情報にアクセスできるようになり、昔に比べ情報収集が容易になったと思います。弊社でも様々な情報を医師に発信しています。

そこで次のステップは医師に届ける情報を正しく選別することだと考えています。

多くの医師は非常に忙しく、ニュースで医師不足や激務等が取り上げられているのを度々見ます。

医師の限られた時間で必要な医学情報を効率的にアップデートするためのサポートをしたいと考えています。

理想的には、

「ジャーヴィス、この患者と似た症例とそれらに関する最新の医学情報をまとめてくれ」

の一言で全てが集まることですが。

まだまだ理想には程遠いですが、少しでも効率化できるように精進します。

達成するために必要なこと

これらの理想を実現するためには周辺のことも進めていく必要があります。

たとえば、

  • 継続的に医師にとって価値のあるコンテンツを作る・集める
  • 継続的に医師にアクセスしてもらう

というようなことも非常に重要です。理想的なアルゴリズムができたときに「医師会員がほとんどいなかった」というのは残念すぎます。

これらのことは多くの人が協力し様々な施策を実施して改善しようとしています。

私達も協力できることは積極的に行おうと思っています。

広告やメルマガ等のクリック数の増加等の施策も技術的にもサービス的にも実現したいことに繋がっており、医療の向上にも繋がっていると考えています。

一緒に医療に貢献する仲間がほしい

理想を実現するためには、一緒に働く仲間が必要です。

色んなスキルを持ったエンジニアと協力し社会に大きな価値を提供したいです。 興味のある方はカジュアル面談等の申込ください。

AWS FargateでElixirのコンテンツ配信システムを動かしてみた (実装編)

こんにちは、エムスリーエンジニアの園田です。

この記事は先日のAWS FargateでElixirのコンテンツ配信システムを本番運用してみた - エムスリーテックブログの続きです。

エムスリーでは医療・ヘルスケアサイト向けのコンテンツ配信システムであるChuoiというサービスを運用しています。先日のポストで、ElasticBeanstalkからFargateに運用を切り替えたことについて書きました。

www.m3tech.blog

今回はその実装編で、実際のコードを見ながら説明します。

まずは構成のおさらいです。

f:id:ryoheisonoda:20180713112117p:plain

Fargate化のためにやったこと

AWS Fargateで運用するために実際にやった作業は大まかに以下の通りです。

  • Elixir/PhoenixアプリのDocker化
  • Docker化したアプリのFargate動作確認
  • 社内GitlabからのCI/CDパイプライン構築
  • Terraformテンプレート作成

今回はこのうち Fargate での動作確認までを、弊社事例をサンプルに説明したいと思います。 なお、 Fargate より Elixir の内容が多めです。 Elixir なんて使ってないぜ、という方は次回からご覧いただいて大丈夫です。

デプロイパイプラインの構築とTerraformテンプレートについてはおって投稿します。乞うご期待。

2018/08/01 追記 デプロイパイプラインの構築記事を書きました。 www.m3tech.blog

Elixir/PhoenixアプリのDocker化

Fargateで動かすためには当然のことながら、Dockerイメージを作成する必要があります。

元々の構成では、Elixirソースを直接EC2に配置し、ElasticBeanstalkのデプロイフックでコンパイルを行い、Phoenixの起動コマンドにmix phx.serverというコマンドで起動していました。*1
しかし、これだとDocker化するにあたりイメージサイズが肥大化してしまうため、リリースビルドによって生成された配布可能なバイナリを含むDockerイメージを作成しました。

なお、このサービスではクリックカウントなど業務メトリクスをログファイルに出力してfluentdでデータレイクに送信しているため、Dockerボリューム上にログを吐き出し、同一タスク内のfluentdコンテナでログを配信可能にする必要がありました。
また、静的コンテンツをnginxのgzip_staticで配信したかったので、nginxのコンテナも作成しました。

最終的に作成したDockerfileはアプリ(app)、ウェブ(web)、ログ配信(log)の3つとなりました。

Dockerイメージの作成に関連するディレクトリ構成は以下のようになっています。

アプリケーションディレクトリ構成
.
├── config/
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── qa.exs
│   └── test.exs
├── dockerfiles/
│   ├── app/
│   │   ├── Dockerfile
│   │   └── entrypoint.sh
│   ├── log/
│   │   ├── Dockerfile
│   │   ├── entrypoint.sh
│   │   └── fluent.conf
│   ├── web/
│   │   ├── Dockerfile
│   │   ├── entrypoint.sh
│   │   └── nginx.conf
│   ├── archive-and-upload-src.sh
│   ├── build-docker-images.sh
│   └── push-to-ecr.sh
├── lib/
├── priv/static/
├── rel/config.exs
├── ts/
├── buildspec.yml
├── mix.exs
├── mix.lock
├── package.json
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

dockerfilesディレクトリに作成するイメージごとのディレクトリを作成し、Dockerfileやentrypoint、設定ファイル等を格納しています。
dockerfilesディレクトリ直下にあるシェルはAWSCodeBuildで実行されるビルドスクリプトで、Dockerイメージには含まれません。*2

コンテンツの取得・描画にTypeScriptを利用しているため、tsディレクトリやwebpack.config.jsがありますが、これらはDockerイメージビルド時にトランスパイルされ、Phoenixによってダイジェストが付与されます。

Dockerfile、エントリポイント、設定ファイル

appコンテナのDockerfile

app コンテナイメージの作成は大きく分けて以下のステップとなります。

  1. 依存ライブラリのインストール
  2. TypeScript のトランスパイルと minify, gzip 圧縮
  3. 静的コンテンツに対してダイジェスト付与
  4. 配布可能なリリースビルドの生成
  5. リリースビルドを含むイメージの作成

以下がElixir/PhoenixアプリのDockerfile(dockerfiles/app/Dockerfile)です。

# -------------------------------------- #
# Multi Stage Build - Build Stage
# -------------------------------------- #
FROM elixir:1.6.0-alpine AS builder

ARG MIX_ENV=dev        # ・・・(1)
ARG SECRET_KEY_BASE="" # ・・・(2)
ARG LOG_DIR=/var/log/chuoi

# mix.exs と mix.lock を環境にコピーして依存パッケージを取得/コンパイル
COPY mix.* /work/
WORKDIR /work
RUN mix do local.hex --force, \
           local.rebar --force, \
           deps.get --only=${MIX_ENV}, \
           compile

# Chuoi では brunch を使わず yarn を使っている
COPY package.json yarn.lock /work/
RUN apk add --no-cache yarn \
    && yanr install --production --non-interactive

# TypeScript のコンパイルと、ダイジェスト付与
COPY ts /work/ts
COPY priv/static /work/priv/static
COPY config /work/config
COPY webpack.config.js tsconfig.json /work/
RUN yarn run webpack \
    && MIX_ENV=${MIX_ENV} mix phx.digest # ・・・ (3)

# 残りのソースをコピーしてリリースアーカイブ(tar.gz)をビルド ・・・ (4)
COPY . /work
RUN SECRET_KEY_BASE=${SECRET_KEY_BASE} LOG_DIR=${LOG_DIR} \
    mix release --env=${MIX_ENV} \
    && mv _build/${MIX_ENV}/rel/chuoi/releases/*/chuoi.tar.gz /

# -------------------------------------- #
# Multi Stage Build - Runtime Stage
# -------------------------------------- #
FROM alpine:3.7 # ・・・ (5)

ENV APP_ROOT=/var/app
EXPOSE 4000

# tzdata, bash, openssl をインストール ・・・ (6)
RUN apk add --no-cache tzdata bash openssl \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# アプリ起動スクリプト(内容は後述) ・・・ (7)
COPY dockerfiles/chuoi/entrypoint.sh /entrypoint.sh
CMD [ "/bin/bash", "/entrypoint.sh" ]

# 前段で作成したイメージからビルド済みのパッケージを取得
COPY --from=builder /chuoi.tar.gz /chuoi.tar.gz

# パッケージを展開
# 静的ファイルディレクトリにシンボリックリンクで別名をつける ・・・ (7)
RUN mkdir -p $APP_ROOT \
    && tar -zxf /chuoi.tar.gz -C $APP_ROOT \
    && rm -f /chuoi.tar.gz \
    && ln -s $APP_ROOT/lib/chuoi-*/priv/static /web-static

WORKDIR $APP_ROOT

最終的に生成されるDockerイメージを小さくするため、マルチステージビルドによりビルドイメージとランタイムイメージを分けています。*3

各ステップのポイントは以下の通りです。

  • (1) リリースビルドの生成には Phoenix 公式にもあるdistilleryを利用しています。distilleryを利用した場合、config.exsの設定情報は 静的にコンパイルされる ため、設定値に環境変数を利用している場合、たとえば MIX_ENV*4などはビルド時に与える必要があります。実行時には MIX_ENV を必要としません。実際にはこの環境変数CodeBuildのプロジェクト環境変数として設定されていて、docker build --build-argsコマンドでビルドプロセスに注入されます。

  • (2) SECRET_KEY_BASE も MIX_ENV 同様、CodeBuildからパラメータとして受け取りますが、こちらはCodeBuildに直接設定されているわけではなく、Parameter Storeに暗号化されて格納されています。CodeBuildでは Parameter Storeのパラメータを環境変数として利用可能な機能が備わっている ため、シークレット情報を安全にDockerイメージに埋め込むことが可能です。

  • (3) TypeScriptをトランスパイルして生成されたjsファイルにPhoenixの機能でダイジェストを付与しています。ここでは、WebpackによるMinifyとGzip圧縮も行っており、圧縮された静的ファイルをappコンテナを介さずNginxのgzip_staticで直接配信できる ようにしています。リリースビルド時に生成されるアーカイブファイルに含めるため、生成コンテンツはpriv/staticディレクトリに出力しています。

  • (4) distilleryを使ったリリースバイナリのビルドです。実際にはバイナリや静的ファイル、ライブラリおよび起動スクリプトが同梱されたtarボール(.tar.gz)が生成されます。distilleryErlangのランタイムを含めてビルドできるため、このリリースバイナリの実行イメージにはErlangをインストールする必要がなくなります 。これのおかげで、ランタイムイメージは非常にコンパクトなサイズにすることが可能です。*指定しているパスは実際にはバージョン番号が入りますが、Docker上でビルドする場合は常にシングルバージョンとなるため*で問題ありません。後でコピーしやすいようにルートパスに移動しています。

  • (5) ランタイムイメージのベースイメージは、ビルドイメージのベースイメージと同じイメージを利用します。ここでは、elixir:1.6.0-alpineのベースイメージerlang:21-alpineのさらにベースとなるalpine:3.7を指定しています。

  • (6) distilleryで生成される起動スクリプトbashで実装されているため、ランタイムイメージにbashをインストールする必要があります。opensslはないとエラーになったので入れました。

  • (7) アプリ起動時にGzip圧縮済みの静的ファイルをNginxのドキュメントルートにコピーします。そのため、静的ファイルのディレクトリにはシンボリックリンクでわかりやすい別名を付けています。Nginxのドキュメントルートは、読み取り専用のDockerボリュームとしてWebコンテナ(Nginxコンテナ)にマウントされます。*5

出来上がったイメージは40MBで、だいぶコンパクトにできました。

なお、webpackビルドもステージを分けようかと思いましたが、pullするイメージサイズが増えてかえって遅くなったのでやめました。

SECRET_KEY_BASE など環境ごとに異なるパラメータをイメージに埋め込むことはベストプラクティスではないです。 実行時の環境変数として渡せるのであればそれに越したことはないですが、 distillery を利用するため仕方なくそうしているのと、 セッションに機密性がないシステムであるため、このシステムに限ってはイメージに埋め込んでいます。
機密度の高いシークレット情報はイメージには埋め込まず、実行時にParameter StoreSecret Managerから取得できるようにした方が良いでしょう。 Railsであれば以前紹介した拙作のgemが利用できます。 また、ECRの利用者が限られていて、イメージ利用に関する監査などが行われているのであれば、イメージに埋め込んでも問題ないはずです。

appコンテナのentrypoint
# このディレクトリを app コンテナに書き込み可能でマウントしておく
# また、このディレクトリは web コンテナにも読み取り専用でマウントされる
WEB_DOC_DIR=/var/app/public

cp -r /web-static/* $WEB_DOC_DIR/

# distillery によって生成された起動スクリプト実行
exec bin/chuoi foreground
web コンテナのDockerfile

web コンテナのDockerファイルはオフィシャルのnginxを利用するため、かなりシンプルです。

FROM nginx:alpine

# ヘルスチェックコマンド用に curl をインストール
RUN apk add --no-cache tzdata curl \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /entrypoint.sh

# CMD は entrypoint.sh で上書き
CMD [ "/bin/sh", "/entrypoint.sh" ]
web コンテナの nginx.conf と entrypoint.sh

nginx.conf はポイントだけ記載します。

upstream appserver {
    server %APP_SERVER%:4000; # nginx起動時に環境変数の値で置換する
}

Fargate を利用する場合、コンテナ間通信の宛先は必ず localhost になります。 ただしそのままだとローカル環境や通常のDockerホストでの開発やテストができないため、APP_SERVERという環境変数に app コンテナのホスト名を入れることで 開発時やテスト時にも通信可能なようにしました。*6

そのため、entrypoint.sh では以下のように nginx.conf を書き換えてから nginx を起動しています。

# デフォルトは localhost
# 実際には 127.0.0.1 を指定すること、詳しくは後述
[ -z "$APP_SERVER" ] && APP_SERVER=localhost

# nginx.conf の該当設定箇所を文字列置換
sed "s|%APP_SERVER%|$APP_SERVER|g" -i /etc/nginx/nginx.conf

/usr/sbin/nginx -g "daemon off;"
log コンテナのDockerfile

log コンテナもほぼ公式のイメージそのままでシンプルです。

FROM fluent/fluentd:v1.2

RUN apk add --no-cache tzdata \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime \
    && gem install fluent-plugin-s3 -v 1.0.0 --no-document

COPY fluent.conf /fluentd/etc/fluent.conf

fluentd の設定ファイルで注意しなければいけないのは、コンテナなので終了時にバッファをフラッシュする必要があることです。 ECS ではコンテナの停止時にSIGTERMが送出されますが、fluentd の file バッファではSIGTERMで flush されないので、 file バッファを利用している場合はflush_at_shutdown trueを設定します。

ここまででFargate上で動かすためのファイルが揃いました。
CodePipelineを構築する前に、実際にFargate上で動作確認を行います。

Docker化したアプリをFargateで動かす

Fargateを構築するまでの手順はかなり多く、大まかに以下の通りです。

  1. ECRリポジトリを作成
  2. ECRリポジトリにイメージをpush
  3. ECSサービスとタスク用のIAMロールを作成
  4. タスク定義を作成
  5. ECSサービス用のセキュリティグループを作成
  6. ECSサービス用のALBとターゲットグループなどを作成
  7. ECSクラスタとFargateサービスを作成

以降の説明において、マネジメントコンソールのスクリーンショットは、面倒なので どうせすぐに変わるので、あえて貼っていません。

ECR リポジトリの作成とファーストイメージの push

事前にAWSの開発環境に ECR のリポジトリを 3 つ (app/web/log) 作成しておきます。
ここではまだ Terraform は使わず、マネジメントコンソールで作成しました。
ここはCLIで作成したほうが楽なので、コマンドも載せておきます。

for repo in app web log; do
  aws ecr create-repository --repository-name $repo
done
Dockerfileをローカルでビルド
docker build -t app:latest \
  --build-args SECRET_KEY_BASE="xxxx..." \
  --build-args MIX_ENV=qa \
  -f dockerfiles/app/Dockerfile ./

docker build -t web:latest ./dockerfiles/web
docker build -t log:latest ./dockerfiles/log
Dockerイメージを ECR にプッシュ
AWS_ACCOUNT_ID="xxxxxx"
AWS_REGION=ap-northeast-1
ECR_REPO_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com

# ECR にログイン
$(aws ecr get-login --region ${AWS_REGION} --no-include-email)

# 各イメージを各リポジトリに push
for repo in app web log; do
  docker tag "${repo}:latest" "${ECR_REPO_URI}/${repo}:latest"
  docker push "${ECR_REPO_URI}/${repo}:latest"
done

これでECRにファーストイメージが latest というタグで登録されました。(ややこしいな)

なお、予めイメージを登録しておかないと、ECSサービスを作成した際にタスクが起動せずにエラーになります。 エラーが出てもあとから登録すれば勝手に起動してくれるので実害はないですが、エラーが出るのが気持ち悪いので先にイメージを登録しておく手順としました。

Fargate(ECSサービスとタスク)用のIAMロールを作成

Fargateを実行するために以下の2つのIAMロールを作成します。

  • ECSタスク実行ロール (ecsTaskExecutionRole)
  • ECSタスクロール

ECSタスク実行ロールは、ECSサービスに割り当てられるIAMロールで、ECSタスクを実行するための認可を与えます。こちらはマネージドポリシー(AmazonECSTaskExecutionRolePolicy)が用意されているので、IAMロールを作成してポリシーをアタッチすればOKです。IAMロールのAssumeRolePolicyにはecs-tasks.amazonaws.comの信頼関係を設定しておきます。*7

ECSタスクロールは、実行されたタスクのコンテナ内部で利用するIAMロールです。log コンテナで利用する S3 への書き込み権限などを付与しておきます。こちらもAssumeRolePolicyはecs-tasks.amazonaws.comとなります。

タスク定義 (TaskDefinition) の作成

AWSのマネジメントコンソールで3つのコンテナを起動するタスク定義を作成します。
ここでのポイントはボリュームの定義です。以下の3つのボリュームを定義しました。

  • アプリのログを格納するボリューム app-log
  • gzip圧縮された静的ファイルのボリューム app-static
  • fluentdのbuffer/posファイルのボリューム log-tmp

静的ファイルはアプリのイメージに含まれているので、アプリの起動コマンドの中でボリュームにコピーするのは前述の通りです。web コンテナではそれを読み取り専用でマウントします。 log コンテナが再起動した際にログを重複送信しないように、bufferファイルとpositionファイルはDockerボリュームに格納します。*8

マネジメントコンソールでタスク定義を作成しますが、最終的に出来上がった TaskDefinition は以下のようになります。(コメントが書けるようにyamlにしていますが、実際にはjsonです)

# Fargate の場合は以下2行は固定
requiresCompatibilities: [ FARGATE ]
networkMode: awsvpc

# ECSタスク実行ロール
executionRoleArn: arn:aws:iam::999999999999:role/ecs-task-execution-role
# 実行されたタスクのコンテナ内部で利用するIAMロール
taskRoleArn: arn:aws:iam::999999999999:role/task-role

# CPUとメモリ とりあえず最低限を確保
# Fargate では CPU とメモリの組み合わせに制限があるので注意
cpu: '256'
memory: '512'

# Dockerボリュームの定義
# Fargate では sourcePath の指定をしていはいけない
volumes:
  - { name: app-log, host: { sourcePath: } }
  - { name: app-static, host: { sourcePath: } }
  - { name: log-tmp, host: { sourcePath: } }

containerDefinitions:

  # app コンテナの定義
  -
    # コンテナ名
    # CodePipelineでのデプロイ時にこのコンテナ名を参照するため、わかりやすい名前にする
    name: app

    # ECRに登録されているイメージのURIを指定
    # CodePipeline によるデプロイでは、ここだけが書き換わる
    image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest

    # essential を true にすると、このコンテナがエラーとなった場合にタスク全体が失敗する
    essential: true

    # ポート指定 `docker run -p`のパラメータ
    # コンテナ間通信が必要な場合は必ず定義する
    # Fargateの場合、hostPort と containerPort は必ず同じでなければならない
    portMappings: [ { hostPort: 4000, containerPort: 4000, protocol: tcp } ]

    # コンテナに直接渡される環境変数 `docker run -e`のパラメータ。
    # DB_PASSWORD などのシークレット値はコンテナ内のプロセスから取得するようにし、ここには埋め込まない。
    environment:
      - { name: REDIS_HOST, value: redis.xxxxxx.ng.0001.apne1.cache.amazonaws.com }
      - { name: REDIS_PORT, value: '6379' }

    # Dockerボリュームのマウント指定 `docker run -v`のパラメータ。
    mountPoints:
      # 静的コンテンツ格納ボリューム: web コンテナでは読み取り専用マウントする
      - { containerPath: /var/app/public, sourceVolume: app-static, readOnly: }
      # アプリログ格納ボリューム: log コンテナでは読み取り専用マウントする
      - { containerPath: /var/log/chuoi, sourceVolume: app-log, readOnly: }

    # Fargateではawslogsのみをサポート
    # CloudWatch Logs にコンテナの標準出力と標準エラー出力を配信する
    logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /chuoi/ecs/tasks/app
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: qa

    # ulimit
    ulimits:
      - { name: nofile, softLimit: 65536, hardLimit: 65536 }

  # web コンテナの定義
  -
    name: web
    image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/web:latest
    essential: true
    portMappings: [ { hostPort: 80, containerPort: 80, protocol: tcp } ]

    mountPoints:
      # 静的コンテンツ格納ボリューム: 読み取り専用マウントする
      # app コンテナが起動時にファイルを書き込む
      - { containerPath: /var/app/public, sourceVolume: app-static, readOnly: true }

    logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /chuoi/ecs/tasks/web
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: qa
    ulimits:
      - { name: nofile, softLimit: 65536, hardLimit: 65536 }

  # log コンテナの定義
  -
    name: log
    image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/log:latest
    essential: true

    mountPoints:
      # アプリログ格納ボリューム: 読み取り専用でマウントする
      - { containerPath: /var/log/chuoi, sourceVolume: app-log, readOnly: true }
      # fluentd一時ファイル格納ボリューム: 書き込み可能でマウントする
      - { containerPath: /fluentd/log, sourceVolume: log-tmp, readOnly: }

    environment:
      - { name: S3_REGION, value: ap-northeast-1 }
      - { name: S3_BUCKET, value: hogehoge-log-bucket }

    logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /chuoi/ecs/tasks/log
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: qa
    ulimits:
      - { name: nofile, softLimit: 65536, hardLimit: 65536 }

セキュリティグループの作成

FargateをWeb利用する上で必要なセキュリティグループは以下の2つです。

  • ECSサービスのセキュリティグループ
  • ALBのセキュリティグループ

実現したいことは以下の通りです。

  • app コンテナから ElastiCache Redis の 6379/tcp に対するアクセス許可
  • ALB から web コンテナの 80/tcp に対するアクセス許可
  • インターネットから ALB の 80/tcp, 443/tcp に対するアクセス許可

上記を実現するためのセキュリティグループを実装します。 なお、ECSではサービスに対してセキュリティグループを割り当てるため、コンテナごとにセキュリティグループを分けることはできません。

今回作成したセキュリティグループ

今回作成したセキュリティグループを例に説明しますが、実現方法は1つではないのであくまでも一例としてとらえてください。

ElastiCache Redis のセキュリティグループはすでに作成済みで、以下のルールとなっています。

  • Redisのセキュリティグループ (sg-redis)
    • 自身のセキュリティグループから 6379/tcp へのアクセス許可

それとは別に、以下のセキュリティグループを作成しました。

  • ECSサービスのセキュリティグループ (sg-ecs-service)

    • 自身のセキュリティグループから 80/tcp へのアクセス許可
  • ALBのセキュリティグループ (sg-ecs-alb)

    • 外部から 80/tcp, 443/tcp へのアクセス許可

上記3つのセキュリティグループに対し、ECSサービスとALBを以下のように所属させます。

  • ECSサービス => sg-ecs-service, sg-redis
  • ALB => sg-ecs-alb, sg-ecs-service

余談ですが、私はセキュリティグループを作成するときは自身からのアクセス許可のみで作成し、そこにリソースを追加する形式で実装します。
こうすることで、セキュリティグループが他のセキュリティグループやリソースに依存しなくなり、Terraformで作成する際にモジュール化しやすくなります。
ネットではよくセキュリティグループ間に対して通信許可を与えるパターンを目にしますが、これはTerraform利用においてはアンチパターンです。

ECSサービス用のALBとターゲットグループを作成

こちらの手順は通常のALB作成手順のため割愛しますが、ターゲットグループはipをターゲットとした空のターゲットグループを作成します。 セキュリティグループは前述のとおりに割り当てます。

ALBを利用せずにECSサービスをPublicサブネットに配置する場合はこの手順は不要です。 動作確認するだけであればALBを使わずにIP直アクセスでも事足りることが多いでしょう。

ECSクラスタとFargateサービスを作成

必要なリソースができたら ECS クラスタを作成し、そのクラスタに Fargate サービスを作成します。 まだ動作確認だけなので、タスク数は1で、スケーリングポリシーは設定しません。
ポイントは以下です。

  1. 起動タイプでFARGATEを選択、タスク定義を選択してサービス名を入力、タスク数には1を入力
  2. 作成済みの VPC と Private サブネット、作成したセキュリティグループを選択し、パブリックIP割り当ては Private サブネットなのでDISABLEDを選択
  3. Application Load Balancerを選択し、作成した ALB とターゲットグループを選択、ターゲットに web コンテナを選択

ECSサービスを作成すると、自動的にタスクが開始されます。 開始されたタスク内の各コンテナログは CloudWatch Logs に配信されているのでそちらで動作確認を行います。

Fargate上で起動した際に発生したエラー

Redisにつながらない

これはdistilleryのドキュメントをよく読んでいなかったために発生したエラーです。
config.exsの設定がリリースビルド時に静的にコンパイルされますが、それを知らなかったため発生しました。

もともとのconfig.exsでは以下のようにRedisのホスト名やポートを環境変数から取得していたのですが、 これらはDockerイメージのビルド時に評価されるため、localhostに対して接続しようとしてエラーになっていました。

redis_host = System.get_env("REDIS_HOST") || "localhost"
redis_port = (System.get_env("REDIS_PORT") || "6379")
             |> String.to_integer()
redis_connection_size = (System.get_env("REDIS_CONNECTION_SIZE") || "1000")
                        |> String.to_integer()
config :redix,
  args: [
    host: redis_host,
    port: redis_port,
    database: 0
  ],
  max_connection: redis_connection_size

こちらは実行時(コネクション接続時)に環境変数から取得するように関数化しました。 以下はRedisコネクションプール作成時の実装(簡易版)です。

defp get_env_of_integer(name, default_value) do
  (System.get_env(name) || default_value) |> String.to_integer()
end

def init([]) do
  redix_args = [
    host: System.get_env("REDIS_HOST"),
    port: get_env_of_integer("REDIS_PORT", "6379"),
    database: 0
  ]
  pool_option = [
    name: {:local, :redis_pool},
    worker_module: Redix,
    size: get_env_of_integer("REDIS_CONNECTION_SIZE", "1000")
  ]
  children = [
    :poolboy.child_spec(:redis_pool, pool_option, redix_args)
  ]
  supervise(children, strategy: :one_for_one)
end

log コンテナがDockerボリュームにファイルを書き込めない

log (fluentd) コンテナが buffer ファイルの書き込みに失敗していました。
fluentd の実行ユーザをrootに変更したら書き込めるようになりました。

公式fluentイメージの entrypoint.sh を以下のように修正して、DockerイメージにCOPYします。

- exec su-exec fluent "$@"
+ exec su-exec root "$@"
COPY entrypoint.sh /bin/entrypoint.sh

ここらへんはFargateというよりDockerの制約ですね。

nginx がまれに 502 エラーになる

これは運用開始後に気づいたのですが、およそ1時間に1回くらいの頻度で 502 エラーが発生していました。
ALB -> web (nginx) -> app (phoenix) というフローで、 app に到達せずに web で 502 を ALB に返していました。

詳しくは調査していませんが、NginxでのProxy先ホストをlocalhostとしていたため、127.0.0.1だけでなく[::1]にProxyしようとしてエラーになっているようでした。

2018/07/13 15:05:51 [crit] 7#7: *3 connect() to [::1]:4000 failed (99: Address not available) while connecting to upstream, client: 10.0.0.116, server: _, request: "GET /system/healthcheck HTTP/1.1", upstream: "http://[::1]:4000/system/healthcheck", host: "10.0.7.111"

参考: qiita.com

なので、明示的に127.0.0.1にProxyするように web コンテナの entrypoint.sh を以下のように書き換えました。

- [ -z "$APP_SERVER" ] && APP_SERVER=localhost
+ [ -z "$APP_SERVER" ] && APP_SERVER=127.0.0.1

これで 502 のエラーが発生しなくなり、スループットも向上しました。*9

Redisコネクション数が急増してRedisが詰まる

これは単純なオペミスですが、ElasticBeanstalkの旧環境が起動したままFargateを起動したため、一時的にRedisのコネクション数が倍増してRedisが詰まりました。
なので、正しい手順として以下のようにデプロイする必要があります。Redisだけでなく、DB(RDS等)でも同様です。

  1. ElasticBeanstalkのRedisコネクション数を半分程度にする (縮退運転)
  2. FargateタスクのRedisコネクション数を本来の半分程度にする
  3. Fargateサービスを作成する
  4. Route53のALIASレコードをElasticBeanstalkからFargateのALBに変更する
  5. ElasticBeanstalkのRedisコネクション数をさらに少なくする
  6. Fargateのコネクション数を増やす

なお、当然のことながらRedisコネクション数を環境変数等で制御できるようになっている前提です。
一時的に縮退運転となるため、トラフィックが安定している時間帯に行います。もともとRedisのコネクション数に余裕があればこの対応は不要です。
Fargate云々ではなく、一般的なシステム移行では当たり前のことですね。凡ミスでした。

まとめ

  • Dockerfile のマルチステージビルドで Elixir の実行コンテナイメージを軽量化できる。
  • Elixir のリリースビルドを生成する場合、設定値の環境変数をビルド時に渡すか、実行時評価のための関数に変更する。
  • 同一タスク内のコンテナ間通信では 127.0.0.1 を利用する。
  • コンテナ停止時のシグナルは SIGTERM なので、必要に応じてトラップする。
  • Fargate ではDockerボリュームもタスク内だけの揮発性。ファイルの永続化を行う場合は S3 などを利用する。
  • セキュリティグループを作成するときは自身からのアクセス許可のみで作成し、そこにリソースを追加する。
  • 自動生成されるリソースも、自前で作成したほうがTerraformフレンドリー。

今回はここまでです。次回は AWS Fargate へのデプロイパイプラインの構築について書きたいと思います。

2018/08/01 追記 デプロイパイプラインの構築記事を書きました。 www.m3tech.blog

エンジニア積極募集!!

エムスリーではAWSの新サービスを試したいエンジニアを募集しています!!エムスリーならわずか1週間で試せちゃいます!! AWS に限らず、Firebase や GCP なども積極的に利活用しています。興味が湧いた方はぜひ Tech Talk やカジュアル面談にお越しください。 お問い合わせは以下フォームより、お待ちしております!!

jobs.m3.com

*1:サーバ上でコンパイルを行うElasticBeanstalkのお作法に則ったのと、起動時のトラブルや環境のトラブルを少なくするため

*2:実際には buildspec.yml でそれらのファイルを実行しています

*3:最終的なイメージサイズが小さくなればいいため、ビルドイメージはあえてステップを細かく刻んでいます

*4:MIX_ENVはElixirプロジェクトの環境を表す変数で、RAILS_ENVのElixir版みたいなものです。

*5:こちらもDockerイメージに含めてしまえばよかったのですが、ちょっとイメージの管理が煩雑になりそうだったのでやめました

*6:同じやりかたで、worker数なども環境ごとに変更できるようになります。

*7:マネジメントコンソールでECSサービスを作成した場合、ecsTaskExecutionRole というロールが自動生成されますが、Terraformフレンドリーではないので自前で作成します

*8:どれか1つでもコンテナが落ちたらタスク全体が失敗するように設定するためこの設定は不要かもしれませんが、このシステムではログが重要なため、念のためこうしています。

*9:ただ、 [::1] で接続できなければ 127.0.0.1 にフォールバックするはずなので、なぜ1時間に1回だけ 502 になっていたかはわからずじまいでした

なぜfoldRightが美しいのか

エンジニアリンググループの冨岡です。

私は最近関数型プログラミングにハマっていて、社内でFP in Scala (訳書)の輪読会を主催するなどして関数型やScalaが好きな人を増やす活動をしています。

この輪読会ですが、本自体の素晴らしさもあって未だに参加者7人が誰一人脱落することなく読み進めています。現在8章と折返し地点なのですが、これまでの章で十分に訓練された私たち参加者は、(説明の都合上)副作用を起こす処理が教科書に出てこようものなら大ブーイング。教科書の解法を確認しては「美しい!」「エレガントだ!」と盛り上がりながら読み進めるようになりました。

中でもfoldRightは大人気で、登場のたびに場を盛り上げてくれます。この記事では、このfoldRightなぜ美しいのかを解説します。

foldLeft / foldRight

まずは、似た処理であるfoldLeft / foldRightとはなにか、簡単に説明します。

trait List[+A]について、foldLeftは以下のように初期値としてのBから始まり、Listの左の要素から順に op: (B, A) => B再帰的に適用し、最終的なBを返却します。

f:id:jooohn:20180718223944p:plain

一方foldRightは、Listの右の要素から順に、op: (A, B) => B再帰的に適用し、最終的なBを返却します。

f:id:jooohn:20180718224141p:plain

opの引数A, Bの順番が、foldLeftfoldRightで逆になっていますが、このような図を考えると直感的でわかりやすいですね。

実装の違い

一見この2つのメソッドは、それぞれ単に処理する順番が違うだけ、それ以上でもそれ以下でもないように思えます。本当にそうでしょうか。

それぞれの実装を考えるために、まずは自分でListを定義してみましょう。

sealed trait List[+A]
case class Cons[A](head: A, tail: List[A]) extends List[A]
case object Nil extends List[Nothing]

foldLeftはこんな感じの実装になると想像できます。

sealed trait List[+A] {

  def foldLeft[B](z: B)(op: (B, A) => B): B =
    this match {
      case Nil => z
      case Cons(head, tail) => tail.foldLeft(op(z, head))(op)
    }

}

foldRightはこんな感じでしょうか。

sealed trait List[+A] {

  def foldRight[B](z: B)(op: (A, B) => B): B =
    this match {
      case Nil => z
      case Cons(head, tail) => op(head, tail.foldRight(z)(op))
    }

}
val list = Cons(1, Cons(2, Cons(3, Nil)))
list.foldLeft(0)(_ + _) // 6
list.foldRight(0)(_ + _) // 6

良さそうに見えます。

しかし、この素朴なfoldRightの実装には落とし穴があります。foldLeftは末尾再帰ですが、foldRightはそうでないため、長いListの場合にjava.lang.StackOverflowErrorの原因になります。

f:id:jooohn:20180720151319p:plain
@tailrec アノテーションにより foldRight が末尾再帰ではないことが警告されるの図

なお最近のScalaであれば、標準ライブラリのList.foldRightはスタックセーフな実装になっています。

では、このように素朴な実装でスタックオーバーフローを引き起こすfoldRightが、なぜ美しいのでしょうか。

foldLeft を使って他のメソッドを実装する

純粋関数の魅力の一つは、composabilityです。副作用を起こさない関数は他の関数と組み合わせることによって、別の関数を安全・簡潔に定義することができます。(注: 関数とメソッドは厳密には別の概念ですが、ここでは純粋であり合成可能なことが重要なのでこの差を無視します)

では、foldLeftを使って、mapを実装してみましょう。

sealed trait List[+A] {

  def map[B](f: A => B): List[B] =
    foldLeft(Nil: List[B])((acc, a) => ???)

}

この???を埋められるでしょうか。acc :+ f(a)というような、リストの末尾に項目を追加する処理が必要そうです。しかし、immutableなListの末尾に項目を追加するにはリストの長さnに対してO(n)の計算量が必要になります。これがfoldLeftの中で再帰的に行われるとなるとO(n^2)の計算量となり非常に効率の悪い処理となってしまいます。(f(a)の計算はnに無関係な定数時間で計算できるとします)

foldLeftで現実的な実装をするとなると以下のような感じでしょうか。

sealed trait List[+A] {

  def map[B](f: A => B): List[B] =
    foldLeft(Nil: List[B])((acc, a) => Cons.apply(f(a), acc)).reverse

  def reverse: List[A] =
    foldLeft(Nil: List[A])((acc, a) => Cons.apply(a, acc))

}

あまり直感的ではありませんね。

foldRightを使って他のメソッドを実装する

今度はfoldRightを使ってmapを実装してみましょう。

sealed trait List[+A] {

  def map[B](f: A => B): List[B] =
    foldRight(Nil: List[B])((a, acc) => Cons(f(a), acc))

}

右の要素から順に、fを適用した結果をheadに追加していく、非常に素直な実装になっています。 Cons(f(a), acc)はO(1)の計算量のため、全体でもリストを走査する分のO(n)の処理となります。美しい上に、効率的です。

同様に、filterや、2つのリストを連結するappendといったメソッドも、foldRightを使ってエレガントに実装することが可能です。

sealed trait List[+A] {

  def filter(f: A => Boolean): List[A] =
    foldRight(Nil: List[A])((a, acc) => if (f(a)) Cons(a, acc) else acc)

  def append[B >: A](that: List[B]): List[B] =
    foldRight(that)(Cons.apply)

}

美しいですね!

これが何を意味するのか

この2つのメソッドの違いはいったい何を意味するのでしょうか。

これを確認するために、データ構造の形に注目してみましょう。 immutableなListは以下のように、長いListが、末尾により短いListを保持する形になっています。

f:id:jooohn:20180720022505p:plain

Listを新しく作成するときは、immutableであるために以下のように最も短いリスト(Nil)から順に、長いListを作る必要があります。

f:id:jooohn:20180720022610p:plain

これが、foldRightの畳み込み処理の順番なのです。あるListから新しいListを作るmapfilterといった操作をfoldRightを使うことで素直に書けるのは、foldRightがListの作成時と同じ順番で値を処理するためです。逆に、foldLeftは、最後に追加された要素から順番に処理していきます。QueueとStackの関係に似ているかもしれませんね。

言い換えると、immutableなデータ構造に関して、foldRightの処理順は"右から左"よりも寧ろ、"内側から外側"に処理すると考えられます。これがデータ構造にとてもよくマッチして美しく再利用しやすい処理になるのです。

まとめ

foldLeft / foldRightの対比から、foldRightの美しさについて解説しました。

  • foldLeftfoldRightは似ているようで性質の異なる関数である
  • foldRightはimmutableな再帰的データ構造によく馴染む美しい関数である

この記事を見てfoldRightが好きになったという方がいたら是非一緒に畳み込まれましょう!