RailsアプリケーションのCIにDynamoDB Localを導入した話

こんにちは、事業開発部 サーバーサイドエンジニアの堀江(kentarohorie)です。

今回はRailsアプリケーションのCIにDynamoDB Localを導入した事例をご紹介します。

広告入稿システムとCI

クックパッドでは自社製の広告入稿システム・配信サーバーを運用しています。また広告の一部はDynamoDBを利用したアーキテクチャで入稿・配信されています。詳細は以前の記事「広告配信サーバーにおける DynamoDB Accelerator (DAX) 活用事例の紹介」で紹介されています。この入稿・配信のうち、広告入稿システムのCIに対してDynamoDB Localの導入を行いました。

広告入稿システムのCIではブランチへのpush、またはmasterへの変更をトリガーにCIサーバー上でスクリプトが実行されていました。CIサーバーにはMySQLやPostgreSQLの環境が用意されており、スクリプトが実行されるとサーバー上のDBを初期化してrspecが実行されていました。多くのテストでそれらのDBを利用したテストが実行されていましたが、DynamoDBに関しては実際のDBを使用できていませんでした。

そのため、DynamoDBを利用している箇所ではAWS SDK DynamoDBClientのput_itemやdelete_itemなどのメソッドを一つ一つstubしたテストが書かれていました。これは例えばDynamoDBを利用したコードが増えたり、その箇所を間接的に利用する必要があるコードが生まれた場合に、DynamoDBの利用を気にしながら必要に応じて都度stubするといった作業が必要になるということです。
例えば以下のようなstubがit句毎に書かれていました。

it "..." do
  expect(dynamodb_client).to receive(:delete_item).with(
    hash_including(
      table_name: "table_name",
      key: { pk: "product_key" },
    )
  )

  expect { subject }.to change { ... }.to(false)
end

こうした状況の中でDynamoDBを利用している箇所で、stubせずともテストを書けるようにしようというモチベーションがありました。

DynamoDB Local導入に必要な環境を整備

DynamoDB Localの導入にあたっては執筆時点で3つの方法がAWSで紹介されています。

  • Apache Mavenリポジトリとして利用
  • Java環境を用意して実行
  • Dockerイメージを利用

これら方法のうち、Dockerイメージを利用してDynamoDB Localを導入しました。理由は全社的にCodeBuildの利用が推進されており、CodeBuild上でDockerを利用してCIを回すという事例が社内に既に多く存在していたためです。CodeBuildはAWSが提供するCI/CD用ビルドサービスであり、Androidアプリ CIをCodeBuildに切り替えた事例などクックパッドでは広く活用されています。

上記検討の後、まずは既存のビルド部分をCodeBuildに置き換え、Codebuild上のDockerでテストを実行できる環境を用意しました。ビルド部分の置き換えはJenkinsのCodeBuildプラグインを利用しました。次に社内で用意されているCodeBuild用Dockerイメージをベースに広告入稿システムのDockerイメージを作り、MySQLやPostgreSQLを利用する処理はスクリプトを用意してdocker-compose up時に実行されるようにしました。具体的にはDBの初期化やrspecの実行などです。

f:id:kentarohorie:20200721123459p:plain
Before

f:id:kentarohorie:20200721123513p:plain
After

この置き換え作業では、既存のCIと比べた場合に可能な限りCI時間が長くならないことを意識して進めました。CodeBuildに置き換える場合これまでになかったDockerイメージのビルドや立ち上げといった工程が増えるためにCI時間が長くならざるをえません。しかしCI時間は短ければ短いほうが良いので、許容できる程度までCodeBuildでのCI時間を縮める必要がありました。

具体的には以下の工夫を行いました。

  1. CodeBuild上でのDockerイメージビルドはキャッシュを利用する
  2. docker-composeでマウントするファイルを可能な限り減らす

広告入稿システムはRailsで動いており、ビルド時間でネックになっていたのはnode_modulesとgemのインストール工程でした。当初はCodeBuildのS3キャッシュを利用してnode_modulesとgemをキャッシュする方針で作業を行っていました。しかしその方法ではnode_modulesとgemファイル群をCodeBuildサーバー(コンテナの外)に持つ必要があり、docker-composeでマウントする必要のあるファイルが多くなり結果コマンド実行時間が遅くなるという問題が発生しました。

次にDocker Layer Cacheを利用する方法を試しました。はじめはCodeBuildで用意されている「ローカルキャッシュ」のDocker Layer Cacheモードを利用していましたが、ライフスパンが30分程度と短いため、CIの稼働頻度が30分に一度回るほどは高くない広告入稿システムではあまり恩恵を受けれませんでした。

そこで最終的に、ECRを利用してDocker Layer Cacheすることになりました。具体的にはCodeBuildのPOST_BUILDフェーズでECRへDockerイメージをpushし、次のBuild時にそのイメージをキャッシュとして利用する、というようにしました。

phases:
  pre_build:
    commands:
      - ....
      - docker pull "${REPO}:latest" || true
      - ...
  build:
    commands:
      - ...
      - docker build --tag "rspec" --tag "${REPO}:latest" -- cache-from "${REPO}:latest" -f Dockerfile .
      - ...
  post_build:
    commands:
      - ...
      - docker push "${REPO}:latest"
      - ...

DynamoDB Localをテストへ導入

CodeBuildへの置き換えが完了した後はdocker-compose.ymlにAmazonが公式に配布しているDynamoDB Localイメージを組み込み、テスト時にそれを読み込むように設定しました。具体的にはAWSのconfigをアップデートする処理をテスト実行前に読み込むようにしました。広告入稿システムのテストでは他にAWSリソースを使用していなかったため、DynamoDBリソースに絞った設定はしませんでした。

次にテスト実行時にDBが初期化されるようにしました。 広告システム関連で使われているDynamoDBにはdynaというgemを利用したDB初期化の仕組みがあります。 dynaはDynamoDBをDSLで管理できるものです。したがって、テスト実行時のDB初期化はdocker-compose up時に走らせるscript内にDB初期化を行うdynaコマンドを実行することで達成しました。

最後に、広告入稿システムのテストでDynamoDBに関するstubを外していく作業を行いました。これでDynamoDB LocalのCI導入が完了しました。

導入結果

DynamoDB LocalをCIに導入することで以下を達成できました。

  • DynamoDBに関する処理のstubを考えずにテストが書けるようになった
  • DynamoDBに関するテストコードを、各人の環境で実行できるようになった
  • DynamoDBを利用したコードの保守性を向上させることができた
  • DynamoDBやClientの仕様変更に耐えやすいテストになった

導入後、DynamoDBに関する最初の作業としてDynamoDBのクライアントgem aws-sdk-dynamodbのアップデート作業を行いました。specではstubせずにDynamoDB Localにアクセスしているのでテストが通った結果に安心感を持つことができ、導入によるメリットを実感しました。

最後に

以上、広告入稿システムのCIにDynamoDB Localを導入した事例をご紹介しました。

クックパッドにはユーザーが触る画面を改善しているサービス開発領域や、収益を支えている広告領域など、様々な領域でエンジニアが活躍しています。そしてそれらの領域ではエンジニアを随時募集しています。興味を持っていただいた方のご応募をお待ちしております。

新卒採用: https://info.cookpad.com/careers/new-graduates/

キャリア採用: https://info.cookpad.com/careers/jobs/

エンジニア社内留学制度を利用してAndroidアプリ開発を体験した話

こんにちは、事業開発部でデータ分析やデータエンジニアリングをやっている佐藤です。最近の楽しみはクックパッドマートで買ったコーヒー豆を挽いて淹れることです。

今日はクックパッド社内で実施されているエンジニア社内留学制度について紹介します。

エンジニア社内留学制度とは

エンジニア社内留学制度は「異動をすることなく短期的に他の部署でその部署の仕事をする制度」というもので2019年4月に作られました。 この制度は異動をせずに視野を広げたり自分のキャリアを考えるための制度であり、普段自分が関わらない技術や分野に対して新しいチャレンジをする機会を提供するための制度です。

エンジニア社内留学制度を利用することで、最大2ヶ月の間もとの部署の仕事から離れて留学先部署の業務に取りかかれます。これは全エンジニアが利用可能な制度です。
この制度の概要は上記のとおりですが、制度を利用して留学させる・受け入れる側を含めた関係者の狙いは下記のようなものとなります。

  • 留学生側
    • 他部署の業務に取り組むことで、視野を広げ、技術や分野において新しいチャレンジをする機会とする
  • 留学元部署
    • メンバーの目線を広げ、技術や分野の違うチャレンジをするなど成長の機会とする
    • 他部署の業務を詳細に知る社員を増やすことで、留学終了後もより円滑に協力できるようにする
  • 留学先部署
    • 短期的な開発リソースの確保
    • 自部署の業務を詳細に知る社員を増やすことで、留学終了後もより円滑に協力できるようにする

この制度が作られた後、サービス開発を行う部署から技術基盤の部署へのエンジニア留学が何件か実施されました。 自分はこの制度を利用して5月〜6月の2ヶ月間モバイル基盤部でAndroid留学を行いましたので、以降の内容ではそのAndroid留学に関して書いていきます。

Android留学の流れ

当記事の冒頭に書いたとおり、自分は普段は事業部でデータ分析やデータ整備作業などを主務として行っていました。 そんな自分が今回エンジニア社内留学制度を使ってAndroid開発に関する知識を身に着けようと思った動機はおおまかに下記の3つです。

  1. Androidエンジニアが足りないということで丁度モバイル基盤部がAndroid留学を募集してた(下記の図を参照)
  2. 部署でデータ分析をしているうちにモバイルの知識が必要になってきた
  3. Android留学を一回しておくと今後iOSで同じようなことをしたくなったときの取っ掛かりにもなりそう

f:id:ragi256:20200716142952p:plain
Android留学募集の様子

というわけで上長に相談し、次の目標を掲げての2ヶ月の社内留学が決定しました。

  • Android版クックパッドアプリのどの部分のコードでどうやってログデータを送ってるか把握する
  • Androidアプリのロギング処理をクライアント側で調査・デバッグできるようになる
  • 誰かが新たにロギング処理を仕込む際に、相談相手になったりコメントできるようになる
  • 今後もモバイル基盤部と協力してモバイルのログ周辺がより良くなるよう整備をしていけるようになる
  • モバイルエンジニアに依頼するばかりでなく自分でもログを仕込めるようになる

この時点でAndroidアプリ開発もKotlinもJavaも全く触れたこともありませんでした。完全に未経験の状態です。 このあたりの留学決定に関する流れは4月頭の1on1で相談したら即留学用チャンネルにinviteされ、3週間ほどの調整期間の後、留学を実施というスピード感でした。調整期間というのは元いた部署の仕事から離れても大丈夫なよう片付けるための期間だったので、特に何かしらの準備があったわけではありません。

やってみてどうだったか

留学期間で実際に着手したタスクは下記の4つでした。

  1. アプリ画面リファクタリングに伴うログ変更に関する調査と周知
  2. 古いコードのVIPER化
  3. モバイルアプリにあるロギング実装に関するドキュメント整備
  4. 旧ロギング実装のリファクタリング

各タスクについて個別に書いていきます。

1. アプリ画面リファクタリングに伴うログ変更に関する調査と周知

クックパッドが提供しているレシピアプリはiOS・Androidの両プラットフォームともにVIPERというレイヤードアーキテクチャを採用しています。このVIPERアーキテクチャ採用は2018年に決定したもので、今利用しているコードの中には旧アーキテクチャのままになっている箇所もあります。よって既存コードをVIPERのアーキテクチャに置き換える作業(通称VIPER化)が行われています。
最近行われたとある画面のVIPER化に伴って、意図せずログ送信内容が書き換わっている可能性が高いことがわかりました。そのため、その問題の調査と社内周知に留学初タスクとして取り掛かりました。実際にやったことはVIPER化の手順を追いかけ、ログ実装を読み、実際に送られたログデータの変化を確認するだけです。

2. 古いコードのVIPER化

初タスクでVIPER化の作業を追いかけて読んだため、Android開発の素振りとしてVIPER化に取り組むこととなりました。しかし、結論から言えばこのタスクは断念することとなりました。
理由は初めてのモバイルアプリ開発に対して、あまりに知識が足りなかったためです。開発するためのキャッチアップに時間を浪費してしまい、そのままでは定められた期間で留学の目的を達成することが困難と判断したためです。VIPERもそうですが、Rx・DI・マルチモジュール・Android知識など予め備えておくべき知識の諸々を学びながらの期間であったため、見てもらうためのPRの実装を作るまでに時間がかかってしまいました。初めてレシピアプリ開発に取り掛かる開発者も困らないようにと初学者用ドキュメントは整っており、それを読みながらの実装でしたがとにかく初めての概念が多いため覚えることがたくさんありました。
この点に関してはまずGoogle CodeLabsをやるのが良かっただろうというのが反省です。

3. モバイルアプリにあるロギング実装に関するドキュメント整備

VIPER化を断念した後、自分が何をするべきかを留学当初の目標に立ち返って考え、取り組むべき課題を考えることとしました。元々の目標の中心にあった「ログ周辺」の課題がなにかないか考えたところ、「レシピアプリ内で使われるロギングの実装がとっちらかっているように見えるのでなんとかしたい」という課題を留学期間中に感じていました。
そこで実際に取り組んだタスクがこのドキュメント整備と次の旧ロギング実装のリファクタリングです。
レシピアプリはiOS・Androidともに開発に参加しやすい状況を維持すべく、開発参加者への支援が手厚く用意されています。オンボーディングや開発者向けドキュメントなどがそうです。ですが、アプリから送られるログ周りに関しては専門家がいなかったため、包括的なドキュメントがありませんでした。 そこで留学という機会を利用して、レシピアプリ開発へ新規に参加するエンジニアでもロギング実装に困らないようなドキュメントを書きました。

4. 旧ロギング実装のリファクタリング

3番目のドキュメント整備タスクと並行して、古いログ送信処理を置きかえる作業を実施していました。旧ロギング実装はアプリ開発からしてみれば何か大きく問題点があるわけではなかったため、誰にも気づかれずそのままとなっていました。しかし、実際に送信されたログを保守・加工・分析を行っている側では微妙に扱いづらいものであり、ログデータを利用する側(分析者やデータ整備者)ではちょっとした負債となっていました。この分析サイドからみた負債を解消することが、旧ロギング実装リファクタリングの目的でした。 こういった負債の指摘やリファクタリング作業やドキュメント整備はログデータを送る側からも利用する側からも扱いやすい、より良いログデータ環境を目指そうという意識付けにも繋がりました。データ基盤はは送信箇所や分析箇所などの特定の箇所の改善では使いやすくなりません。実際の利用フローに合わせ、足並みを揃えてトータルの改善をすることで多くの人から喜ばれるデータ基盤となります。

上記4つのタスクをひたすらにこなしているうちに気づけば2ヶ月が経過してしまい、エンジニア社内留学が終了となりました。留学自体は終わりましたが、自分自身がクックパッド社内でデータに関わるいちエンジニアであるということには変わりがないため、今回得た経験を活かして今後もデータ分析環境の改善に取り組んでいくつもりです。

エンジニア社内留学からの副産物的成果

実際にやってみたところ、予想していなかった副産物的成果がいくつかありました。自分としては「完全なAndroid初心者では手取り足取り教えてもらうだけになりそう」と思っていたのですが、留学をしてみたら意外と好影響もあったようです。

1. Android入門者用のドキュメントが改善された

初めてのAndroid開発に参加するため、レシピアプリに関する全ドキュメントに目を通すこととなりました。この際に疑問に思ったところは片っ端から質問をするようにしていたため、ドキュメントの不備・陳腐化した内容・分かりにくい説明などはどんどん修正されていきました。

2. ログに関する議論が活発になった

留学先のモバイル基盤部はお昼会という名のデイリーミーティングと、週次で行われる振り返りミーティングがありました。リモート期間中だったので1これらのミーティングは全てZoom越しに行われました。このミーティングで同僚の着手タスクの概要や進捗状況を把握するわけですが、こういった日々の会話の中で常にログデータの取り扱いに関する話に対して質問やコメントをしたりし続けていました。
折しも社内でログの取り扱いに関する話題が活発化しているタイミングで、そういった議論に関して「今こういう話が活発ですよ」「このチャンネルでこういう議論がかわされていますよ」という誘導を会話の中でし続けていました。
ロギングのドキュメント整備で話し合う機会もあり、「他部署ではログデータをこう取り扱っている」といった部署横断的な知識の提供に繋がりました。

3. 今まで方針の定まっていなかったロギング実装に関して、話し合いの場を設けて合意をとった

「やったこと」の3つ目に書いてあるとおり、留学後にこなした業務の中で「ロギングのドキュメント整備」がありました。このドキュメント整備ですが、今まで明文化されていなかったものをドキュメントに書き起こすだけで済むかと思いきや、そうではありませんでした。
これまで言語化されていなかったため、明確になっていたなかった点がいくつもあったのです。ドキュメントを制定するに当たり、同時にプルリクエストレビューで多くの人と意識のすり合わせがなされました。また、PRだけでは決まりそうにない、ロギング実装に関する大きな意思決定のため有識者会議を開くこともありました。
多くの人が関わるクックパッドのレシピアプリ開発の方針決定に関わることになるとは留学前には考えていもいませんでした。

終わりに

クックパッドでのエンジニア社内留学制度の紹介と、その制度を利用したAndroid留学体験を紹介しました。
社内で異動することなく、別分野のエンジニア業務を体験してみるのは新鮮なことでしたし、自分が取り組める業務の幅も広がったと思います。また、初心者かつ異分野エンジニアが留学してみると、留学ならではの好影響も与えられるという発見がありました。

留学を終了した証書
無事に留学修了証書をもらいました


  1. クックパッドでは新型コロナウイルス感染症の拡大に伴い、2月から全従業員を対象に在宅勤務を実施しています。在宅勤務に対する取り組み例はこちら。記事1記事2

Trivy + AWSによるコンテナイメージ脆弱性検査パイプラインの構築

技術部セキュリティグループの水谷(@m_mizutani)です。最近はPCゲーム熱が再燃しており、今はCities: Skylinesに時間を溶かされ続けています。

クックパッドでは レシピサービス の継続的なサービス改善の他にも、生鮮食品販売プラットフォームの クックパッドマート やキッチンから探せる不動産情報サイト たのしいキッチン不動産 をはじめとする新しいサービス開発にも取り組んでいます。さらに内部的なシステムも多数あり、動かしているアプリケーションの数は300以上に及びます。これらのアプリケーションには多くのOSSパッケージが利用されており開発を加速させますが、同時にOSSパッケージのアップデート、とりわけ脆弱性の修正にも向き合う必要があります。

これまでクックパッドでは(重大な脆弱性が見つかった場合を除いて)各サービスを担当するエンジニアが事業や開発の状況にあわせてパッケージのアップデートなどをしていました。しかし、管理すべきアプリケーションが多くなってきていることから、全社で統一したパッケージの脆弱性対応の仕組みを整える必要がでてきました。その一環として各アプリケーションのデプロイで使われるコンテナに含まれるパッケージの脆弱性を把握するための仕組みを整えました。

この記事では社内でのパッケージ脆弱性の検査に対してどのような要求があり、それをどうやって実現したのかを紹介します。

脆弱性スキャンのパイプライン構築における要件

現在、クックパッドでは大部分のアプリケーションがコンテナ化され、Amazon ECS(Elastic Container Service)上で動作しています。また、そこへのデプロイも主にCodeBuildを使ったCI(Continuous Integration)の環境が整備されています。そのため、このCIの仕組を利用することで脆弱性スキャンの機能を構築することにしました。

構築にあたってはいくつか解決しないといけない課題や要件があったため、それをまず紹介します。

要件1) 観測からはじめる

CI/CDにおける脆弱性管理の文脈では「CIのパイプラインで脆弱性を検査し、脆弱性があった場合はCIを止める」といったものが多く語られているように思います。検出されている脆弱性をすべて無くしてからしかデプロイできないようにする、というのは確かに理想形ではありますが、実際の事業に照らし合わせてみると必ずしも正しいとは言えないと考えています。

例えば1つのパッケージのバージョンを上げることで破壊的な変更が入る、あるいは連鎖的に複数のパッケージも更新する必要があり、結果的に大幅な改修が必要になってしまう、ということはままあることと考えられます。これが事業的に一刻も早くデプロイしなければならない状態だとすると、現場判断で脆弱性スキャンの機能を無効にせざるをえない、ということがありえます。

もちろん、攻撃が成功しやすい・影響が大きいような脆弱性の場合は事業を止めてでも修正する必要があります。しかし、脆弱性の中には複数の条件を突破しないと攻撃が成立しないような種類のものも少なからずあります。そしてそれはアプリケーションの設定や実行環境に依存するため、一律に判断するのは困難です。CVSSなどによるスコアリングでも、結局は環境などに依存してリスクが変動してしまい、これをセキュリティチームから開発チームに押し付けることは互いにとってあまり良い結果にならないのではと考えています。

そのため、まずはコンテナ内のパッケージの脆弱性がどのくらいあって、どのように変動しているかを把握し、どうすればリスクの極小化ができるかの仮設をたてて検証していく必要があります。そのためにも全体像を把握できるようにまずは観測できる環境を整えるという要求事項を設定しました。

要件2) CIと密結合にしない

いくつかの脆弱性スキャンツールはCIの途中で実行することを想定して作られており、CIのスクリプトなどに埋め込んでシンプルに実行することができます。しかし、アプリケーション数が多くなってくるとそれに比例して脆弱性スキャンツールを動かすための管理・統制にかかるコストが大きくなってしまいます。これは脆弱性スキャンツールの導入だけでなく、例えばツールの仕様が変わるなどしてうまく動かなくなった際の障害対応とメンテナンスの手間も含まれてきます。

先述したとおり、クックパッド内では300を超えるアプリケーションが動いており、それら全てのCIでそういった管理をするのはあまり現実的ではありませんでした。そのため、既存のCIの仕組みとは完全に独立させ、CI側に影響を与えないような疎結合なシステムを構築する必要がありました。これによって、今後さらにアプリケーションの数が増えても容易にスケールできることが期待されます。

要件3) 脆弱性の発見だけでなく修正もとらえる

脆弱性スキャンツールを使う主な目的は脆弱性のあるパッケージの発見であるため、検査結果をそのまま閲覧・通知することでこれは達成できます。しかし継続的にコンテナをメンテナンスしていく場合、コンテナに含まれる脆弱性が修正された、という情報も役に立つことがあります。

  • 脆弱性のあるパッケージが含まれていたコンテナイメージ修正の進捗状況を把握できる
  • 脆弱性のあるパッケージを更新したつもりのコンテナイメージをビルドした際、意図したとおりにパッケージが修正できたのか把握できる
  • 脆弱性が発見されてから修正されるまでの期間を計測できる

これらを実現するためには各コンテナイメージの脆弱性の状態を管理する必要があります。

要件4) ベースイメージに含まれているパッケージの脆弱性を識別できるようにする

クックパッドではアプリケーション用のコンテナイメージを作成する際に利用できる、社内共通のベースイメージが用意されています。このイメージにはおおよそ共通して使われるであろうパッケージが事前にインストールされており、これを使うことでアプリケーション用イメージごとのビルドのステップを短縮しています。

しかし、ベースイメージからビルドされたコンテナイメージの脆弱性をスキャンすると、ベースイメージにもともと入っていたパッケージの脆弱性とアプリケーション用に新たにインストールしたパッケージの脆弱性が混在した結果が出力されてしまいます。発生ポイントがどこであれ修正するべき脆弱性は修正しなければなりません。ですが、ベースイメージを管理しているチームとアプリケーションを開発しているチームが異なるため、脆弱性の発生レイヤが混在して通知されてしまうと、どのチームが対応するべき脆弱性なのかが判断しにくくなってしまいます。このため、検出された脆弱性がどのイメージをビルドした際に入り込んでしまったのかを識別できるようにしたい、という要求が生まれました。

ベースイメージが1つだけであれば、そのイメージの検査結果との差分をみることで脆弱性の発生ポイントを判定できますが、ベースイメージが複数あるとその紐付けの情報を管理する必要がでてきます。Dcokerfileからビルドする場合は FROM を見ることでベースイメージのレポジトリはわかりますが、いつビルドされたイメージが実際に使われているのかまではわかりません。とはいえ手動で管理するのはあまりにも煩雑なので、自動的に判定するような仕組みが必要になります。

脆弱性スキャンツールの選定

脆弱性スキャンのツールとしてはTrivy を採用しました。選定にあたって他のOSSや製品の脆弱性スキャンツールとも比較をしたのですが、

  1. 単体のバイナリだけで簡単にスキャンが実行でき、小回りがきくこと
  2. 入力や出力もシンプルになっており自分たちのシステムとのインテグレーションが容易であること
  3. OSのパッケージおよびrubyなどランタイムのパッケージの脆弱性もまとめて把握できること

という3つの理由からTrivyを使うことにしました。

ちなみに、クックパッドではCI/CDにおけるコンテナイメージの保存にはAmazon ECR(Elastic Container Registry)を利用しており、ECRのImage Scanningの機能を利用することも検討しました。しかし、スキャンできる対象がOSのパッケージのみだったことから採用を見送りました。

ちょうど先日、AWS Security Blog で How to build a CI/CD pipeline for container vulnerability scanning with Trivy and AWS Security Hub というTrivyをCIに取り入れるというブログが公開されていました。このブログでもCodeBuildでのCIを想定しており、CIの中にTrivyによる脆弱性スキャンを実行して、その結果をSecurity Hubに格納するというアーキテクチャについて述べられています。このアプローチも小さくはじめるにはよい構成なのですが、先述した要件をクリアするのは十分ではなかったため、我々は別のアーキテクチャによって脆弱性スキャンのパイプラインを実現しました。

アーキテクチャと実装

TrivyとAWSの各種マネージメントサービスを利用し、コンテナイメージの脆弱性スキャンパイプラインを構築しました。AWSのサービスと接続することから、基本的な制御の部分にはLambdaを利用し、サーバレスなアーキテクチャになっています。デプロイにはAWS CDK(Cloud Development Kit)を利用しています。

また、アーキテクチャ図からは省いていますが、スキャン結果から得られたデータを確認するためのWeb管理コンソールも用意しています。

イメージのスキャン

f:id:mztnex:20200713200112p:plain
イメージスキャンに関連するAWS構成

クックパッドでは原則コンテナイメージをCodeBuildでビルドし、ECR(Elastic Container Registry)にプッシュしたのち、ECS(Elastic Container Service)へデプロイするという構成になっています。要件2の疎結合なアーキテクチャにするという観点から、今回はCodeBuild内で実行されるビルドのプロセスには一切手を加えず、ECRにプッシュされたイメージを利用することで、CI/CDのパイプラインに一切影響しないような構成にしました。

スキャンの開始は2つのトリガーがあります。1つはイメージがプッシュされた際にCloudWatch Events経由で送信されるECRイベント、もう1つは定期的(現在は24時間ごと)に発行されるCloudWatch EventsのScheduledイベントです。それぞれのトリガーによって起動されたLambdaがスキャンすべき対象のイメージの情報をキューとしてScanQueueに詰めます。定期的に実行されるトリガーはECRからレポジトリの一覧を取得し、そこからスキャンが必要なイメージを選定します。

ECRにプッシュされたイメージの中身は後からは変更されないため、同じ脆弱性を見つけるためには何度もスキャンする必要はありません。しかし脆弱性スキャンツールにTrivyを使う場合、新たに発見された脆弱性を見つけるためには脆弱性DBを更新して、再度検査をするというのがシンプルな対応になります。そのため、イメージがプッシュされたイベントとは別に定期実行の仕組みを取り入れました。

Trivyを使った実際のスキャンはFargate上で実行することにしました。Fargateを選択した主な理由は、1) 実行環境が独立しているため、ECSのように他のタスクに影響を及ぼさない、2) スケールアウトが容易、の2つになります。特に定期スキャンでは数百のイメージをスキャンするためのキューが一度に発生するため、スケールアウトによって短時間でスキャンを完了させられます。Fargate上ではこのパイプラインを制御するためのプログラムを動かしており、それがTrivyを起動させます。具体的には、次のような制御をしています。

  1. ScanQueueからスキャン対象イメージの情報を取得
  2. 脆弱性DBの更新(図中では割愛)
  3. Trivyの起動とスキャン結果の保存
  4. 対象イメージのレイヤ情報をECRから取得
  5. スキャン結果をS3に保存
  6. スキャン完了通知をResultQueueに送る

Trivyのスキャン結果は多少のメタデータを付与したあと、なるべくそのままS3に保存します。これのデータをもとに結果処理のLambdaが管理コンソールからの検索に必要なインデックス情報などをDynamoDBに保存します。

脆弱性の状態管理

f:id:mztnex:20200713200216p:plain
脆弱性の状態を管理するためのAWS構成

脆弱性の状態を管理するのに必要なのは「直前のスキャン結果との比較」です。これはRDBを使って管理するというようなアプローチもありますが、今回はS3に保存してあるスキャン結果を単純に比較してコンテナイメージに含まれる差分を計算する、という方法にしました。これによってイメージごとの差分計算処理が1つのLambdaに集約され、大量のリクエストがきても容易にスケールアウトできます。

差分計算の処理はシンプルに最新のスキャン結果と直前のスキャン結果を比較しているだけです。最新のスキャン結果が保存されたS3パスが(「イメージのスキャン」のアーキテクチャ図にもあった)スキャン結果処理のLambdaから送信されたQueueに、直前のスキャン結果が保存されたS3パスがDynamoDBにあります。これらをもとに、それぞれのスキャン結果をS3からダウンロードし、新しく出現した脆弱性と削除された脆弱性の情報を比較結果としています。比較結果のデータサイズがSQSのデータサイズ制限(256KB)を超える可能性があるので、比較結果を直接SQSには流さずS3へ保存しています。その後、SNS → SQS を経由して Lambda に通知を送り、DynamoDB上にある脆弱性の状態(未修正・修正済み)を更新したり、Slackに通知したりしています。

f:id:mztnex:20200713200248p:plain
新たな脆弱性が発見された、あるいは脆弱性が修正された際のSlack通知

管理コンソールからはどのコンテナイメージのどこにその脆弱性があり、それぞれの修正状況も把握できるようなユーザインターフェイスを用意しました。これによって社内での脆弱性対応の進捗が可視化されています。

f:id:mztnex:20200714103346p:plain
脆弱性の修正状況を確認できる管理コンソールのビュー

ベースイメージの判定

f:id:mztnex:20200713200445p:plain
ベースイメージを判定する手順の概要

「要件4) ベースイメージに含まれているパッケージの脆弱性を識別できるようにする」で説明したとおり、ベースイメージに含まれているパッケージの脆弱性とアプリケーション開発によって追加されたパッケージの脆弱性とを区別する仕組みを取り入れました。この判定には各イメージのLayer Digestを利用しています。ベースイメージを利用してイメージをビルドする場合、ビルドしたイメージは一部のレイヤーをベースイメージと共有しています。そのため、Layer Digestが一致すればそれ以前のレイヤーは基本的にすべてベースイメージのものである、と判断することが出来ます。

Trivyのスキャン結果には各脆弱性が含まれるレイヤーのLayer Digestが記載されているため、アプリケーションイメージのどのレイヤーがベースイメージ由来なのかがわかっていれば、脆弱性を含むパッケージがどちらに属しているのかも判断できます。どのレイヤーからベースイメージなのかを後から判定するため、スキャン結果とLayer Digestの一覧を組み合わせて保存しておく必要がありますが、残念ながらTrivyのスキャン結果に記載されていません。しかしLayer Digestの一覧はECRに保存されているため、代わりにECRへアクセスすることで取得できます。先述したとおり、fargate上でのスキャン時にはTrivyのスキャン結果とECR上のレイヤ情報の両方を取得し、組み合わせてS3へ保存しています。

このような仕組みでベースイメージを検出するために、検索用のデータストアとしてDynamoDBを使っています。DynamoDBに全てのイメージの最新レイヤーのLayer Digestをキーとして保存し、アプリケーションイメージの脆弱性一覧を表示するタイミングで全てのLayer Digestをバッチで問い合わせ、その結果からどこからベースイメージかを判定します。一覧表示のタイミングで検索しているのは、ベースイメージとアプリケーションイメージがほぼ同時に更新された際、スキャン結果の到着が前後する可能性があるためです。

この仕組を使うことで、どのレポジトリやタグがベースイメージとして使われているのかという情報をメンテナンスしなくても、自動的に判定ができるようになりました。また、ベースイメージが複数ある(ベースイメージAからベースイメージBが作られ、ベースイメージBからアプリケーションイメージが作られる)場合でも、同じ仕組みによって正確に複数のベースイメージを判定できます。管理コンソールでは次の図のようにベースイメージ由来の脆弱性はリンク先で確認するようなUIにしました。

f:id:mztnex:20200714103605p:plain
ベースイメージとアプリケーションイメージの脆弱性情報が分かれて表示される

コスト

今回のアーキテクチャではコスト削減を目的としていたわけではないのですが、結果としては一日あたりの動作コストが$6弱になりました。

その中でも支配的なのがDynamoDBで、1日あたり$4ほどのコストになっています。これはCapacity設定の最適値が読めないため on-demand capacity mode で動作させているためと考えられ、これは今後適切な値でRead/Write Capacityを設定しAuto scalingと併せて使うことで改善できると考えています。また、クエリについても改善の余地がありそうな部分はあり、そちらも今後リファクタしていきたいと考えています。

一方、CPUリソースが必要とされるTrivyのスキャンに関しては一日あたりおよそ$0.5ほどになっています。これはスケールイン・アウトがうまく機能していること、そしてFargate spotを使っていることで大きくコストを抑えていると見ています。Fargate spotなので処理の途中で停止してしまう可能性もありますが、どの段階で処理が止まってもやり直しがきき、かつ複数回処理が実行されても冪等になるように実装しているため、特に問題なく利用できています。

まとめ

この記事ではTrivyとAWSのマネージドサービスを使った、CI/CDと疎結合にコンテナイメージの脆弱性スキャンパイプラインの要件、アーキテクチャと実装の一部を紹介しました。これは永続的に疎結合のまま運用することを目指しているわけではなく、CI/CDの中に直接組み込むとしたらどのような仕組みや運用ポリシーが必要になるか?という課題を解くための前段階という意味合いもあります。技術部セキュリティグループでは引き続きどのようなパッケージの脆弱性管理の戦略をとれば事業開発のスピードへの影響を最小化しつつセキュリティを担保していけるか、という問題にチャレンジしていこうと考えています。

このようなエンジニアリングのチャレンジをするにあたり、クックパッドでは(引き続き)セキュリティエンジニアを募集しています。情報セキュリティに強い方だけでなく、むしろサービス開発を得意としつつセキュリティにも強い関心がある、という方にも興味を持っていただければ幸いです。