Technote

by sizuhiko

Next.js で作ったアプリケーションを AppRunner にデプロイする

こちらの記事は AppRunner へのデプロイは cdk でサクッとできるのか? の続編となります。 前編を読まないとわからない内容ではないですが、もし良ければ事前に確認してください。

まず前編でも触れてますが、アプリケーションのリポジトリ構成は以下のようになっています。

  • Next.js のアプリケーションリポジトリ
  • AWSのリソースを管理するインフラリポジトリ

アプリのリポジトリでは、ECR へのデプロイまでやってます。 インフラのリポジトリで、AppRunner など AWS リソースを CDK で構築しています。

AppRunner で Next.js アプリケーションを動かすには

Next.js を standalone モードでビルドして、Docker コンテナで起動する。これだけで ok です。

ECR に Next.js の standalone モードビルドしたコンテナイメージを push する

まずは Next.js のアプケーションリポジトリの CI/CD で ECR に Docker イメージを push します。 アプリケーションリポジトリ側にも CDK を入れてあるので、以下のようなコードで ECR にデプロイしています。 タグは package.jsonversion から入手します。

import * as imagedeploy from 'cdk-docker-image-deployment'
import * as ecr from 'aws-cdk-lib/aws-ecr'

export class DeployStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const projectRoot = path.join(__dirname, '../..')
    const { version } = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json')).toString())
    const repository = ecr.Repository.fromRepositoryName(this, 'webapp-ect', 'webapp')
    new imagedeploy.DockerImageDeployment(this, 'DockerImageDeployment', {
      source: imagedeploy.Source.directory(projectRoot),
      destination: imagedeploy.Destination.ecr(repository, {
        tag: version,
      }),
    })

Dockerfile は Next.js の公式サンプルWith Docker を参考に(というかほぼそのまま流用)すれば大丈夫です。 ベースイメージの Node.js バージョンが古かったりするので、そこは自分たちが使うバージョンに変更しておきましょう。

AppRunner で ECR からデプロイする

こちらは前編でも触れた @aws-cdk/aws-apprunner-alpha が使えるので簡単にデプロイできます。 以下のような感じで書けば良いでしょう。ヘルスチェックを何でやるかは、いろいろだと思いますが、ここではいったん favicon にしています。 CDK で作成した ECR のリポジトリと、デプロイ対象のアプリケーションバージョンはコンストラクタの引数で渡せるようにしています。

import { Service, Source } from '@aws-cdk/aws-apprunner-alpha'

export class WebAppConstruct extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: WebAppConstructProps) {
    super(scope, id, props)

    const service = new Service(this, 'WebAppService', {
      source: Source.fromEcr({
        imageConfiguration: { port: 3000 },
        repository: props.ecrRepository,
        tagOrDigest: props.webAppVersion,
      }),
      serviceName: 'webapp-service',
      autoDeploymentsEnabled: true,
      healthCheck: HealthCheck.http({
        healthyThreshold: 2,
        interval: Duration.seconds(10),
        path: '/favicon.ico',
        timeout: Duration.seconds(10),
        unhealthyThreshold: 10,
      }),
    })

コラム: ヘルスチェックについて、いろいろと参考になった記事

実はヘルスチェックはちょっといろいろあって実際も favicon にしたんですけど、そのときに参考になった記事があるので、載せておきます

デプロイできたので動かしてみるが動かない

Error: listen EADDRNOTAVAIL: address not available 10.0.1.2:3000

何か動きません。

あれ、Dockerfile に HOSTNAME=0.0.0.0 入ってるのに何でだろう? ローカルで build したイメージ動かしたときは大丈夫だったんだけど?と思ったら、1つ罠があります。

こちらの記事がとても参考になりました。 AWS App RunnerでNext.jsのstandaloneモードを動かす時のTips(ないしは失敗談)

App Runnerの環境では環境変数HOSTNAMEに対して、暗黙的にアタッチされているENIのprivate DNSが指定されるようです。

なんですって? AppRunner が環境変数にセットしてくるの??

だから 10.0.1.2:3000 みたいな ENI の private アドレスになるんですね。Dockerfile では

ENV HOSTNAME="0.0.0.0"
CMD ["node",  "server.js"]

のように指定されていても、コンテナ起動時に HOSTNAME を指定されるので環境変数が上書きされてセットされます。

CDK で HOSTNAME を設定する

ということで、AppRunner のサービスを作るときに環境変数 HOSTNAME を指定してあげます。

    const service = new Service(this, 'WebAppService', {
      source: Source.fromEcr({
        imageConfiguration: {
            port: 3000,
            environmentVariables: {
                HOSTNAME: '0.0.0.0',
            }
        },

これでデプロイしたら無事起動しました。👏👏

少しでも AppRunner で Next.js アプリを動かす人の役にたてばと思います。

AppRunner へのデプロイは cdk でサクッとできるのか?

AWS App Runner は、ソースコードまたはコンテナイメージから AWS クラウドのスケーラブルで安全なウェブアプリケーションに直接デプロイするための、高速でシンプル、かつ費用対効果の高い方法を提供する AWS サービスです。

公式ドキュメント に書かれています。

ソースコードからデプロイする場合は

  • GitHub
  • BitBucket

といったところのクラウドリポジトリを使っていれば簡単に連携して自動デプロイができます。

一方、仕事でオンプレのソースコードリポジトリ、たとえば GitHub Enterprise とか BitBucket Server とか、GitLabs とかをパブリッククラウドでなく使っている場合はソースコード連携できないので、ECR にイメージをデプロイして AppRunner と連携することになります。 本ブログは、この方法について、ネット上で簡単にできそうに書いてある記事をやってみたら、実際はそんなことなかったということについて記録するものです。

CDK で AppRunner + ECR でデプロイする

cdk apprunner DockerImageDeployment みたいな検索条件でググると、まぁいっぱい出てきます。

ここで DockerImageDeployment というのは、Dockerfile をビルドして ECR に push までしてくれる CDK のライブラリです。 で、その ECR を AppRunner に関連づけてというのが流れです。

ちなみに DockerImageAsset というのもあって、そちらの記事も多く見受けられます。こちらはあらかじめ ECR を作っておかなくてもいい感じに作ってくれるものですが、[AWS CDK] コンテナイメージもまとめてデプロイ!?DockerImageAssetの動作確認をしてみた という記事のとおりお試しで使う分には良いと思いますが、ちゃんと管理して使いたい場合は DockerImageDeployment を使った方が良いでしょう。

DockerImageDeployment を使って ECR にデプロイする

DockerImageDeployment の公式GitHubリポジトリ にある例どおり簡単に利用できます。

import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as imagedeploy from 'cdk-docker-image-deployment';

const repo = ecr.Repository.fromRepositoryName(this, 'MyRepository', 'myrepository');

new imagedeploy.DockerImageDeployment(this, 'ExampleImageDeploymentWithTag', {
  source: imagedeploy.Source.directory('path/to/directory'),
  destination: imagedeploy.Destination.ecr(repo, { 
    tag: 'myspecialtag',
  }),
});

AppRunner と連携でデプロイする

@aws-cdk/aws-apprunner-alpha というまだα版ですが、CDK のコンストラクタがあるので、これを利用します。

import * as ecr from 'aws-cdk-lib/aws-ecr';

new apprunner.Service(this, 'Service', {
  source: apprunner.Source.fromEcr({
    imageConfiguration: { port: 80 },
    repository: ecr.Repository.fromRepositoryName(this, 'NginxRepository', 'nginx'),
    tagOrDigest: 'latest',
  }),
});

あとは CDK でいい感じにつなぎこんで、みたいな感じです。

やってみたがエラーになる

はい、エラーになりました。

The deployment will wait until the CodeBuild Project completes successfully before finishing.

というメッセージが出て失敗します。 メッセージどおり受け取ると、ECR のデプロイが終わってないので AppRunner にデプロイできないということです。

どうやって解決したか

実はアプリケーションのソースコードリポジトリと、AWSのリソース構成をデプロイするインフラリポジトリは分けていたので、アプリケーションコード側の CI/CD で DockerImageDeployment を使って ECR までデプロイ。 ECR までデプロイされている状況で、インフラリポジトリ側の CI/CD で ECR と AppRunner の関連付けをやるようにしました。

では最初はなんで両方を一緒にやっていたかというと、アプリケーションが外部サービスに連携しているため、開発環境ではモックサーバーを使っているのですが、それは Dockerfile 1つだけなんでそのファイルをインフラリポジトリ側において AppRunner にデプロイしようとしていたという感じでした。

他の解決策としては、カスタムリソースを使って ECR へのデプロイを待ち合わせてデプロイするという方法があります。モノレポなどを使っているときにアプリケーションとインフラを同時にデプロイしたいときなどは有効な方法だと思います。 カスタムリソースも Lambda を作らないといけないというわけではなく、 AWS の API を実行する程度なら Lambda が不要なので、そういった選択肢も検討できます。

参考記事: [AWS CDK] APIを呼び出すだけのカスタムリソースならLambda関数は不要な件

さいごに

AppRunner を cdk を使ってデプロイしたい、というときに参考になれば幸いです。 @aws-cdk/aws-apprunner-alpha がαじゃなくなるときには、もう少し便利に(ちゃんと待ち合わせてくれるみたいな)ことができるようになるのかもしれないので期待はしたいですね。

ちなみにモックサーバーは Mockoon を使ってます。とても便利で助かる。

PHP カンファレンス福岡2024に参加した

PHP カンファレンス福岡2024に参加しました。

2019年以来の PHP カンファレンス福岡。 そのときの記事がPHPカンファレンス福岡2019で、 標準インターフェースを使ったアプリケーション開発について発表してきましたです。

昨年はチケットを購入できず、先に予約していたフライトやホテルをキャンセルするという形になってしまったので、今年はちゃんとチケット購入してから、フライトとホテルを予約しました。昨年の反省が活かされている。 昨年の盛り上がりは X で確認していて、うらやましーと思ってました。そこから今年の月刊 PHP con が始まったと言っても過言ではないわけで、とても楽しみにしていました。

ちなみにプロポーザルはブログ記事にもしたGAE gen1 で動いている PHP5.5 で作った個人開発サービスを gen2 PHP8.2 へ移行した1年記APPSYNC_JS (AppSync JavaScript) で始める GraphQL API サーバーについて出したのですが、どちらも落選しました。 プロポーザルの倍率がやばすぎる(僕が普段の仕事 PHP じゃないので、旬な話題じゃないというのは否定できない事実ですがw)。

2019年が3年ぶり2回目だったので、今回は5年ぶり3回目ということになります。

前日入り

今回も前日入りしましたが、いきなり飛行機が遅延するというトラブルに。

この時点では 9:00 発が 9:40 になっただけだったけど、さらに 15分遅延となって 9:55 発に。 梅雨時期だし雨の影響もあるのでしかたがないですね。前日入りでよかったとポジティブに捉えましょう。

福岡に到着したら WeWork 大名へ。永和システムマネジメントでは東京支社がWeWork京橋内になったため、今回のように福岡に前日入りしてもWeWork拠点があれば作業できるのでとても便利。夕方からは袋詰めボランティアに申し込んでいたので、それまで作業します。

お昼はWeWork近くにあったウェストへ。

夕方ぐらいになったらWeWorkに知っている人たちが続々来て??ってなったんですが【非公式】PHPカンファレンス福岡2024・前日Meetup があったようです。気が付いてなかった….

そして袋詰めをやってきました。

そのあとは特に予定もなかったので、エールズへ。

野生のPHPerにも出会えたり、帰ろうかなーと思ったら店長から市川さん来るよ、って教えてもらったのでビールを追加して合流。 そのあとは Rummy 行って美味しいラムをいただいて、締めにやまちゃんでラーメンとビールを(3時ちょいに寝ました)。

当日

ちゃんと朝起きれて会場へ移動していたのですが、2日続けて朝にトラブルが…

100円均一で売ってるこの手の定期入れを使ってたんですが、初めて中身だけ落ちました。

祇園駅で駅員さんに天神駅へ連絡してもらって確認したら、それっぽいのがあるということでUターンして無事戻ってきました。 日本でよかった。改札通過してすぐポケットに入れた(いつもそう)ので、改札近くに落ちたのではないかと思います。 ギリギリ開始時間には間に合いました。

今回は午前中はメイン会場でセッションに参加してました。PHP養分がたくさん補充されました。

お昼は福岡の人と食べたいなーと思って、@nojimageさんに声をかけてご一緒させてもらいました。

午後はアンカンファレンス会場、スポンサーブース、廊下で交流してました。

16:00からはアンカンファレンス会場で、会社で今月2回Webアクセシビリティのワークショップをやったのを発表しました。

専門家じゃなくても良い教材があるので「みんなで勉強会てきにやってみると良いよ」といったメッセージが伝わったら良いなと思っています。

そのあとは懇親会(当日チケットあって嬉しかった)→ 非公式2次会 → やまちゃん の流れでした。 初めましての方、お久しぶりの方とたくさん話せました。楽しかった!

後日

帰京までの時間は6/23(日)「(非公式)PHP Conference Fukuoka After Hack!!」に参加して、このブログ記事を書いています。

2019年の記事の最後は

PHPカンファレンス福岡、来年も参加できると良いなー。

で終えていました。 しかし翌年それが当たり前の光景でないことがわかったのです。当時はそんなことになると思っていなかったですよね。 もちろんそういったことだけでなく、当たり前にカンファレンスがある訳でなく、多くの人の努力によって開催されているわけで感謝です。

あとはアンケートとフィードバックを入れてから帰ります。そしてまた参加できるように願っています。

APPSYNC_JS (AppSync JavaScript) で始める GraphQL API サーバー

AppSync は、AWS上で GraphQL API をサーバーレスに構築できるサービスです。

Amplify で利用されていることでも有名ですね。 Amplify から DynamoDB にアクセスするときや、通常の CRUD だけでなくクエリ条件を指定したい場合などのカスタマイズをするときは、自動生成されたリゾルバーを修正することになります。 このとき従来は VTL というテンプレート言語を学ぶ必要がありましたが、今日ではJavaScript(TypeScript)を利用できます。

最新の Amplify Gen2 でも TypeScript でリゾルバを指定できます。Amplify Gen2でNextJSのアプリケーション作成まで というクラメソの記事が参考になります。

公式ドキュメントで学ぶ

AWS のデベロッパーガイドにリゾルバーチュートリアル (JavaScript)があるので、これを読めば理解が進みます。 特にチュートリアル: DynamoDB JavaScript リゾルバーを読むと、DynamoDB にアクセスする GraphQL API の作り方が理解できるようになるでしょう。

TypeScript を使う

Amplify Gen2 を使っている場合は、すべて TypeScript で記述していて Amplify プロジェクトで管理されている Lint ルールなどを利用しているので、問題ないと思います。 一方で AppSync を直接使う(たとえば Terraform や CDK などを使って AppSync の API をデプロイする)場合は、JavaScript リゾルバーの概要の中にあるバンドル、TypeScript、ソースマップを読むと良いです。

ここを読むと、以下の理解が深まります。

  • esbuild を使って TypeScript ファイルをバンドルして 1 つの JavaScript ファイルにする方法
  • リゾルバや関数を作るときの TypeScript 設定や記述方法(主に型定義)
  • GraphQL スキーマ情報から TypeScript 型定義を生成する方法
  • Linter の設定

特に Linter の設定は重要になります。 詳しくはユーティリティeslint プラグインの設定 で確認できます。

APPSYNC_JS の制約

リゾルバや関数を JavaScript で記述できるようになったのは(VTLを書くことに比べ)嬉しいことなのですが、あくまでも JavaScript の文法が使える程度と思っていた方が良いです。もちろん単体テストコードが書きやすいなどのメリットはありつつも、制約が非常に多いことを理解しなくてはなりません。

  • コードサイズ: 32,000文字
  • 外部モジュール: npm でインストールされるようなものは、開発ツールを除いてほぼ使えないと思っていたほうが良い
  • ネットワークアクセス: できない。すべてのリソースへのアクセスはデータソースを使って行う
  • ファイルシステムアクセス: できない

どのようなものが向いているかは、データソースへの直接アクセスと Lambda データソース経由のプロキシのどちらかを選択するに書かれています。

ランタイム制約

次の制約を理解するとき読むべきはサポートされているランタイム機能です。

さきほど

あくまでも JavaScript の文法が使える程度と思っていた方が良い

と書きましたが、それがこのランタイム機能を理解した上での感想です。

サポートされていない機能は以下のとおりです。

ステートメント
  • try / catch / finally
  • throw
    • 代わりに util.error() 関数を利用する
  • continue
  • do-while
  • for
    • ただし for-in および for-of 式は例外でサポートされている。
  • while
  • ラベル付きステートメント
数学
  • 次の Math 関数はサポートされていません。
    • Math.random()
    • Math.min()
    • Math.max()
    • Math.round()
    • Math.floor()
    • Math.ceil()
関数
  • apply、bind call メソッドはサポートされていません。
  • 関数コンストラクタはサポートされていません。
  • 関数を引数として渡すことはサポートされていません。
  • 再帰的な関数呼び出しはサポートされていません。
promise

非同期プロセスはサポートされておらず、Promise もサポートされていません。

制約を理解した上で進める

と途中まででも読んだ段階で、これは JavaScript の文法が使える程度 になるということが理解できたでしょう。 外部ライブラリにこれらの記述を使っていないかというと、大抵どれか1つぐらいは該当してしまいます。 つまり実質的にテストコードを除いてバンドルするコードには、外部モジュールを使うという選択肢はなくなります。 ただ実際にはそれでは困ることが多いので、組み込むユーティリティや、ヘルパー関数などが揃っています。

リゾルバーおよび関数の JavaScript runtime 機能のページから、それらのユーティリティやヘルパーへのドキュメントページに遷移できるので、確認してください。

ただ一部 VTL のドキュメントには書いてあるユーティリティやヘルパーのうち、 JavaScript のリファレンスには記述されていないけど、利用可能なものもあります。 これらはドキュメントバグと思いますが、TypeScript で作っている場合はインポートで利用する型定義ファイルと、VTLのリファレンスを突き合わせてみると良いでしょう。

たとえば多くの Math 関数がサポートされていない代わりに、mathヘルパーがあるのですが、これは JavaScript 側のドキュメントからは漏れています。もちろん利用可能なので、どのような機能があるかは VTL 側の$util.math の math ヘルパーを読むことになります。

Lambda データソースを使うという選択

もちろん、これは AppSync のリゾルバや関数で JavaScript を利用する場合の制約であって、AppSync のリゾルバから Lambda データソースを呼び出してそちらで処理する分には制約はありません。

AppSync のリゾルバや関数の実行に関する料金が AppSync に全て含まれているのに対し、Lambda データソースで実行される計算リソースは別途 Lambda の料金がかかるので、Lambda でないとできない場合だけとしておくとコストを抑えることができるでしょう。

困っていること

AppSync の JavaScript ランタイムには様々な制約があるので、AWS のコンソールからソースコードを書いているとリアルタイムで制約違反を教えてくれます。もちろん手元のエディタを使っていても ESLint のルールでチェックできるようになっています。 とくに TypeScript で記述すると、より厳密に ESLint のルールで確認できるようになっています。

多くのシンプルなリゾルバや関数では、ここで書く困りごとには遭遇しないかもしれません。 しかし運良く?その事象を引き当てたのです。

サンプルコード

例として(実際のケースとほぼ一緒ですが)、DynamoDB データソースに対して、複数のテーブルに跨った TransactWriteItem や BatchWriteItem を実行したいと仮定します。

サンプルコードとしてAmazon DynamoDB Item Taggingという、aws-samples オーガナイゼーションにあるリポジトリを利用します。

DynamoDBに保存するコード例 は Lambda の実装ですが、ここでは書き込みのパターンについて注目してください。 このコードは、タスクを保存するときにタグ付けされていた場合は、タグの WriteRequest を配列に追加しています(L88)。

特に何の変哲も無い、DynamoDB で TransactWriteItem や BatchWriteItem を使うときに書きそうなコードですが、ここに落とし穴があります。

このコードは TypeScript で、配列に追加するとき WriteRequest 型を指定しています。タグ付けするときも追加する型は WriteRequest なので、配列に追加される型が一致するためエラーにはなりません。

同じようなコードを AppSync のリゾルバや関数で書くと TS2322 になる

では同じようなコードを AppSync のリゾルバや関数で書いたとします。 TypeScript で書いていて、 AppSync が指定する ESLint のルールなどがあっても問題なく通過するとします。 では結果を esbuild でバンドルしてデプロイしてみましょう。

なんと、デプロイすると TS2322 エラーになります。

えっ、AppSync って JavaScript はサポートしているけど、 TypeScript はサポートしてないよね?

というのが、最初の驚きです。 またまたー、と思って先頭行に // @ts-nocheck を書くと、デプロイできるようになります。

ふぁぁっ!何だと、 tsc が実行されているのか?!という疑惑が生まれてきます。

いずれにせよ、このままではデプロイできないので、神様/AWSサポート様に問い合わせを行いました。 この問い合わせ、めっちゃ最終回答まで時間かかったのです…..

で、現時点これの回避策は、先頭行に // @ts-nocheck を書く、が正式回答になります。まじかー….

現時点 AppSync では JavaScript の制約をチェックするため、デプロイ時に JavaScript ファイルを TypeScript コードとしてチェックしているそうです。まじかー….

先ほどのコードを再度確認してみてください。TypeScript のコードをコンパイルして型情報を取り除くと、以下のようなコードになります。

    const taskDbItem = {
      PutRequest: {
        Item: {
          // we set the pk and sk to the item id. we prefix both with `task#` to allow filtering by task items
          pk: `task#${item.id}`,
          sk: `task#${item.id}`,
          // we are using a gsi to allow listing all items of a certain type, which in this case is task items
          // task: GSI key sharding
          siKey1: 'task',
          name: item.name,
          description: item.description,
          // tags are duplicated here to simplify retrieval
          tags: item.tags
        }
      }
    };
    params.RequestItems[this.tableName].push(taskDbItem);

    // next we write all the tags as separate DynamoDB items. We use the tag name as the partition key, and the tag value and the TaskItem id as a composite sort key.
    if (item.tags) {
      Object.entries(item.tags).forEach(([tagName, tagValue]) => {
        const tagDbItem = {
          PutRequest: {
            Item: {
              pk: `tag#${tagName}`,
              sk: `${tagValue}#task#${item.id}`,
            }
          }
        };
        params.RequestItems[this.tableName].push(tagDbItem);
      });
    }

いうて DocumentClient.WriteRequest という型情報が消える程度ですが、これが落とし穴です。 型情報がなくなった params.RequestItems[this.tableName] の配列は、TypeScript の型推論が働くために、最初の taskDbItem 構造の型の要素を持つことが期待されます。

そこに item.tags があったときに構造が異なる taskDbItem を配列に追加しようとしたら、どうなるかわかりますね。 TS2322 です。

おそらく Amplify Gen2 でリゾルバを上書きして書いたときも、同じようなコードを書くとエラーになると推測されます(CDKがesbuildした結果をデプロイするので)。

回避策を入れる

回避策の、先頭行に // @ts-nocheck を入れる方法として、esbuild の banner オプションを利用します。 バンドルのリンティングで紹介されているサンプルコードに設定を追加します。

/* eslint-disable */
import { build } from 'esbuild'
import eslint from 'esbuild-plugin-eslint'
import glob from 'glob'
const files = await glob('src/**/*.ts')

await build({
  format: 'esm',
  target: 'esnext',
  platform: 'node',
  external: ['@aws-appsync/utils'],
  outdir: 'dist/',
  entryPoints: files,
  bundle: true,
  plugins: [eslint({ useEslintrc: true })],
  banner: {
    js: '// @ts-nocheck'
  }
})

この例のままだと、全部のファイルに // @ts-nocheck が入ってしまうので、特定のファイルだけにしたい場合は、esbuild を2つに分割して特定のリゾルバや関数だけに追加されるようにすると良いでしょう。

なお、AWSサポートへは TypeScript かつデベロッパーガイドに書いてあるとおり eslint プラグインの設定 をしてあれば、同じ効果を期待できるそうなので、全ファイルに入っていたとしても大きな問題はないのかな?とも思います。 // @ts-nocheck を入れたAPIについては念入りに動作確認をするようにコメントが入っていたこともお伝えしておきます。

まとめ

AppSync のリゾルバーや関数を JavaScript (TypeScript) で書けるようになって、VTL を書くよりも生産性があがったり、ユニットテストが書けるようになって品質を維持しやすくなるといった効果が期待できます。

一方で TS2322 に遭遇するといった落とし穴もあったりするので、注意は必要ですね。

APPSYNC_JS でそういったトラブルになった記事だったり StackOverflow の投稿だったりはまだ少ないので、問題や回避策がわかったら積極的に記事にしておくと良いかな?というのが今回ブログ記事にするきっかけにもなりました。

僕は JavaScript で書けるようになってすごく嬉しいので、今後も使っていきたいなと思っています。

CodeCommit で Renovate を使う

Renovateは依存関係の更新を自動化(PRを生成してくれたり、マージしてくれたり) するツールで、dependabot などと並んで有名なツールです。

CodeCommit で依存関係の更新を公式にサポートしているツールを探していたのですが、Renovate しか見つからなかったので、選択肢を検討することもなく導入してみることにしました。

導入方法

CodeCommit に導入するための公式ドキュメントがあります。

前回のブログCodeCommit と CodeBuild を GitHub と Actions の組み合わせのように使うで CodeCommit と CodeBuild の環境は設定済みで、かつ PR に対してビルド結果が連携できるようにしてあります。

Renovate を導入するときもこの手順は重要で、ライブラリをアップデートするときにビルドが通過するかわからないものはマージできないので、まず導入前に連携できるように設定しておきましょう。

AWS 環境の設定

ドキュメントどおり IAM や Role の設定を行います。 ここはプロジェクトによってやり方がいろいろあると思いますが、結果として CodeBuild から CodeCommit に PR が出せる様な設定になっていれば良いです。 必要なポリシーなども列挙されているので、ドキュメントをよく読んで進めれば大丈夫です。

AWS 環境ではできないこと

これもドキュメントに明記されています。

  • PR への担当アサイン
  • PRの自動マージ
  • rebaseLabel オプションの有効化

環境設定ファイルを記述する

ドキュメントでは config.js で書かれていますが、 renovate.json でも大丈夫です。 このあたりはお好きな設定ファイル形式を利用すると良いでしょう。

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

スキーマ定義やパッケージルールなどを設定しておきます。 Renovate の対象リポジトリごとにリポジトリルートにファイルを設置します。

CodeBuild の設定

導入方法の選択

Renovate を導入するときの単位として、2つの選択肢があります。

  • Renovate を複数のリポジトリに対して実行する管理リポジトリを1つと、対になる CodeBuild プロジェクトを作成する
  • Renovate の対象リポジトリごとに CodeBuild のプロジェクトを作成する

前者の良いところは、Renovate の対象リポジトリが多数あるとき、管理リポジトリに設定している CodeBuild の buildspec ファイルでリポジトリ名を列挙していき、対象リポジトリ側は、リポジトリルートに設定ファイルを入れておくだけで良いことです。 ただしリポジトリごとに実行スケジュールを変更するといったことはできないので、すべての対象リポジトリに対して順番に PR が作成されていきます。

後者の良いところは、リポジトリごとに実行スケジュールを柔軟に変更できることです。ただしリポジトリ数ぶんの CodeBuild プロジェクトが必要になるので対象リポジトリ数が多くなると面倒に感じるかもしれません(IaC で構築していればそうでもないかも?)。

buildspec ファイルの設置

今回は導入リポジトリ数も少なかったのと、実行スケジュールをリポジトリごとに変更したいという要望があったので、後者の方式で導入してみました。

どちらの方法でも CodeCommit / CodeBuild では実現可能です。

前回の記事で build.yml や deploy.yml を作っているので、そこに renovate.yml といった名前で buildspec ファイルを記述します。

ドキュメントでは Docker を使う方法と CLI を使う方法が紹介されていますが、私たちは CLI の方式を採用しました。

version: 0.2
env:
  shell: bash
  git-credential-helper: yes
  variables:
    RENOVATE_PLATFORM: 'codecommit'
    RENOVATE_REPOSITORIES: '["repoName1"]'
    RENOVATE_CONFIG: '{"extends":["config:recommended"]}'
    LOG_LEVEL: 'debug'
    AWS_REGION: 'ap-northeast-1'
phases:
  install:
    runtime-version:
      nodejs: 20
  build:
    on-failure: CONTINUE
    commands:
      - npm install -g renovate
      - renovate

RENOVATE_REPOSITORIES が違うだけで、各リポジトリに入れておきます。

CodeBuild プロジェクトの作成して実行スケジュールを設定する

CodeBuild プロジェクトを新規作成してリポジトリを紐づけておきます。 あとは実行スケジュールを EventBridge に設定して、作成した CodeBuild プロジェクトを実行 ( StartBuild ) するように設定します。

ここまでで実行スケジュールに応じて PR が生成できるようになります。

セキュリティアップデートに対応する

Renovate は標準では CVE などに対応するパッチの PR は生成されません。 この機能を有効にするためのオプションは2つあります。

前者は GitHub のみサポートしており CodeCommit では利用できません。 後者は実験的な機能という位置付けのようですが、すべてのプラットフォームで動作する様になっています。

osvVulnerabilityAlerts を有効にする

環境設定ファイル(この記事では renovate.json)に設定を追加します。

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "osvVulnerabilityAlerts": true
}

実は GitHub が必要なんです

ドキュメントを読むとこれだけで良いのかと思っていたのですが、実際に Renovate を実行しても CVE の PR は生成されません。

おや?っと思ってログを確認したりしたのですが、原因がわからず Renovate のソースコード自体を確認してみました。

すると、なんと OSV のデータソースが GitHub に固定してハードコードされてるじゃないですか。

該当のコード

  private async initialize(): Promise<void> {
    // hard-coded logic to use authentication for github.com based on the githubToken for api.github.com
    const token = findGithubToken(
      find({
        hostType: 'github',
        url: 'https://api.github.com/',
      }),
    );

    this.osvOffline = await OsvOffline.create(token);
  }

ドキュメントからリンクされているディスカッションには、違うデータソースを使う場合の書き方があって、OSVの任意のデータソースを利用する場合は独自にzipファイルをダウンロードしてやるのも良いでしょう。

で、そのまま GitHub のデータソースを利用する場合は、GitHub の PAT を生成して(権限は public_repo ぐらいでok) renovate.yml に設定を追加します。

env:
  secrets-manager:
    GITHUB_COM_TOKEN: "[シークレットの名前]:[シークレットのキー]"

CodeBuild の buildspec では環境変数に設定する値を SecretsManager から取得できるので、GitHub で生成した PAT はそこに格納しておいて、CodeBuild から参照できるようにしておきましょう。

プロジェクト都合で GitHub 使えないから CodeCommit 使っているのに!という声はごもっともだと思うので、そういう場合は zip ファイルをダウンロードして実行する手順を試してみることをお勧めします。

さいごに

できれば CodeCommit も GitHub と Dependabot の関係のように標準で脆弱性の PR 対応して欲しいですよね。 ビルド結果の通知とか脆弱性の対応とか、Code兄弟の機能として不足しているなーという部分の強化に期待しましょう。