maybe daily dev notes

私の開発日誌

AWS CDKでWebフロントエンドをデプロイする3つの方法

CDKでWebフロントエンドのSPAをデプロイするときの方法まとめ。下図のありがちなSPA構成を想定している。

このような場合、フロントエンドアプリからAPI GatewayのエンドポイントURLやCognitoのUser Pool IDなどの値を参照する必要があって、それらの値をどのように解決するか、どうデプロイするかなどに選択肢がある。早速、それらの方法を見ていこう。

方法

以下では、フロントエンドが参照すべきリソースを 依存するリソース と呼ぶ。(上図では API Gateway や Cognito User Pool のこと。)

方法1. 依存するリソースをデプロイ後、フロントエンドをデプロイする

依存するリソースをまず先にデプロイし、参照したい値 (エンドポイントURLなど) を確定させる。これらの値をフロントエンドのコードにハードコードするか、ビルド時の環境変数として注入することで、フロントエンドから参照できるようにする。

コードは単純で、BucketDeployment でビルドした静的ファイルをS3にコピーするだけ。

// これをデプロイする前に以下を完了させる
//    1. 依存するリソースのデプロイ
//    2. 依存するリソースの値 (エンドポイントURL) などをフロントエンドに埋め込み
//    3. フロントエンドのビルド
// ビルド生成物が frontend/dist に出力されたものとする
    new BucketDeployment(this, 'Frontend', {
      sources: [
        // frontendのビルド生成物のディレクトリを指定
        Source.asset('frontend/dist'),
      ],
      destinationBucket,
      distribution,
    });

割と直感的なので、よく使われている方法と思う。しかし、欠点がいくつかある:

  • 依存するリソースとフロントエンドのデプロイを1つのスタックにまとめることができない
    • 依存するリソースがデプロイされるまではフロントエンドのビルドができないので、デプロイの単位を分ける必要がある
    • もちろん、依存するリソースをデプロイした後に同スタックにフロントエンドを追加すれば1スタックにできるが、そうするとスタックの作り直しや環境の追加などメンテが大変になるので、やらない前提
  • 値を注入するために、CDK外で何らかの手作業やコードが必要になる
    • 例えばデプロイされたREST APIのエンドポイントを手作業でフロントエンドのコードにコピーしたり
      • 環境が増えてきたときや、環境を頻繁に作り直す場合は、手間になる
    • 例えば依存するリソースを含むスタックの Stack output から値をCLIで取ってきて、ファイルに書き出すなど
      • CDKの事情がフロントエンド側に漏れてしまっている感はある
      • ちなみに s3-assets.AssetProps.bundling を使うと、CDK synth時にこのコマンドを自動で実行させるようなことは可能

方法2. S3Deployment の機能で値をファイルとして書き出す

方法1の欠点を意識してか、数ヶ月前に追加されたのがこの機能。以下のようなコードで、依存するリソースの値をCDKのデプロイ中にファイルとしてS3に書き出すことができる。

const appConfig = {
  topic_arn: topic.topicArn,
  base_url: 'https://my-endpoint',
};

new s3deploy.BucketDeployment(this, 'BucketDeployment', {
  sources: [s3deploy.Source.jsonData('config.json', config)],
  destinationBucket: destinationBucket,
});

この書き出されたファイルをフロントエンドから動的にロードすることで、フロントエンドは動的に値を解決することができる。 実際にこの仕組みを利用した例が ↓のリポジトリ

github.com

書き出したファイルを、Reactコンテキストの初期化時に取得することで、必要な値を注入している。

const fetcher = async () => {
  const res = await fetch('/env.json')
  return res.json()
}

export default function useEnv() {
  const { data: env } = useSWR<Env>(dev ? null : '/env.json', fetcher, {
    revalidateOnFocus: false,
    revalidateOnReconnect: false,
  })
// https://github.com/aws-samples/nextjs-authentication-ui-using-amplify-ui-with-cognito/blob/main/app/lib/useEnv.ts

この方法では、フロントエンドをビルドする段階では値を解決しなくて良い。このため、方法1とは異なり、フロントエンドと依存するリソースを1スタックでまとめてデプロイすることができるのは利点。

欠点としては、以下が考えられる:

  • 現状クロススタックで値を解決できないバグがあるため、方法1とは逆にスタックを分割するのが困難
    • 元の実装はCDKの創造主である eladb によるものだが、かなりハッキーに見えるので、根本解決には時間を要すると(完全外野からの)予想
    • 一応、SSM Parameterを経由することで値を解決するWorkaroundはある
  • フロントエンド側で上記のような特別な実装が必要
    • エンドポイントURLをネットワークから動的に読み込むのは一般的とは言えない
    • これもCDKの事情がある種リークした結果で、あまり好ましくはない

方法3. CDKデプロイ中にフロントエンドをビルドする

この記事で推したい方法。そもそもこれらの問題が起きる原因は、依存するリソースの値はそれらをデプロイするまで確定せず、またフロントエンドアプリはそれらの値が確定するまでビルドできないという点が主だった。このために、フロントエンドを別にデプロイしたり、リソースの値を動的に取得するような措置をしていた。

もう一つ簡単な解決方法があって、それがデプロイ中にフロントエンドをビルドするというもの。CloudFormationのカスタムリソースの中でフロントエンドをビルドすれば、依存するリソースの値も同スタック内で解決できるし、フロントエンドのビルドの中に静的に値を埋め込むことができる。 リポジトリはこちら:

github.com

これを使うと、以下のインターフェースでフロントエンドアプリをデプロイできる。*1

import { NodejsBuild } from 'deploy-time-build';

declare const api: apigateway.RestApi;
declare const destinationBucket: s3.IBucket;
declare const distribution: cloudfront.IDistribution;
new NodejsBuild(this, 'ExampleBuild', {
    assets: [
        {
            // フロントエンドのソースコードがあるローカルのディレクトリ
            path: 'example-app',
            exclude: ['dist', 'node_modules'],
        },
    ],
    destinationBucket,
    distribution,
    // Build artrifactが出力されるディレクトリ
    outputSourceDirectory: 'dist',
    // Buildに使うコマンド
    buildCommands: ['npm ci', 'npm run build'],
    // ビルド環境に注入する環境変数 (deploy-time valueも利用可能)
    buildEnvironment: {
        VITE_API_ENDPOINT: api.url,
    },
});

これなら1スタック構成でもマルチスタック構成でも同じように対応できる。また、フロントエンド側にCDKの知識は必要なく、よくある環境変数を埋め込む方法を利用できる。方法1や2の欠点が解消されたことがわかる。

一方で欠点もあり、

  • ビルドという割と複雑な作業を、汎用的なConstructに実行させている
    • 環境によってビルドにまつわる事情は無数にあると思われるので、もしかするとこの汎用Constructでは対応できないユースケースもあるかもしれない
  • CDKデプロイのたびに (フロントエンドのコードに変更があれば) フロントエンドがビルドされるので、デプロイの時間が長くなりがち
    • 例えばバックエンドのフロントエンドを1スタックにまとめて、 cdk watch しているような場合、開発効率が悪化するかもしれない
    • このため、開発環境においてはフロントを別スタックに隔離する、アセットハッシュを固定するなどの措置が必要かも

とはいえ、現状趣味プロジェクトで使っている分には、今のところバッチリ動作しているし、特に不都合もないので満足している。

まとめ

CDKでWebフロントエンドをデプロイする方法を3つ紹介した。それぞれPros Consがあるので、ぜひ一度お試しください。

*1:deploy-time valueとはCDK用語で、デプロイ時に確定する値のこと。リソースのARNやREST APIのエンドポイントなど。

npm install は package-lock.json を変更しないことがほとんどだった

昨日までずっと npm install の挙動について勘違いしていたので、改めて整理する。

なお、引数付きの npm install <package_name> コマンドではなく、 npm install 単体で打つときの話。

npm install の仕様

npm 5.4.2 以後 (つまり、現代ではほとんどの環境がこちら)

  1. はじめに、既存の package-lock.jsonpackage.json に齟齬がないか確認する。
  2. 齟齬がなければ、 package-lock.json のとおりにインストールする。この時、 npm ci とは異なり、既存の node_modules ディレクトリは削除されない。
  3. 齟齬がある場合、 package.json に記載のバージョンに適合するように、 package-lock.json が変更される。

ここで齟齬と言っているのは、package.json に書かれたバージョン指定が実際にインストールされているバージョンと整合しないことを指す。 例えば "web3": "^1.7.3"package.json に書かれているのに対して、 package-lock.json では "web3": "1.7.2" がインストールされている場合など。

このため、一度 npm install して package-lock.json を生成したら、 package.json を変更しない限りは何度 npm install の結果はdeterministicであることが保証されている。

この仕様は、GitHubのIssueコメント欄にnpmメンテナが書いたものが由来なので、それなりに信ぴょう性がある。なぜかドキュメントには明記されていないのでやや不安はあるが。

github.com

ただし、

If you do run into a case where npm@^5.4.2 mutates a package-lock.json that was otherwise compatible with the paired package.json please open a new issue. This sort of thing would constitute a high priority bug.

というのが曲者で、 要は package-lock.json が (本来変更されないはずの場合でも) 変更されてしまうようなバグはありえるということ。

実際、2021/3にこのようなIssueが立っていて、現状は不明のまま。

github.com

とはいえ、仕様がこうなっているということは、いずれはバグも修正されて仕様どおりの挙動になるのだろう。 このため、本番用のビルドなどクリティカルな用途以外では、仕様を信じて npm install を使っても良さそう。

npm 5.4.2 より前 (つまり、現代では無関係)

先のIssueを見るかぎり、npm install はまったく package-lock.json を無視していたらしい。 (そもそもnpm@4以前は package-lock.json すらなかった模様) このStackoverflowも参考になる:

stackoverflow.com

5.4.2 がリリースされたのが 2017/9 頃 らしいので、その以前から npm を使っていた人は誤った認識を持っているかもしれない。

自分の勘違い

但し書き: この節に書いてあることはすべて勘違いで誤っているので注意。

  • npm install は常に package-lock.json を無視してパッケージをインストールするコマンドである。npm update との違いはよくわからない
  • package-lock.json のとおりにインストールするコマンドは npm ci のみだ
  • npm ci は実行のたびに node_modules ディレクトリを削除して全てのパッケージを取得し直すので、なんて非効率なことか、このIssue を未だに放置している現状は異常としか言いようがない
  • package-lock.json のとおりにかつ既存のnode_modulesディレクトリを残しながらパッケージをインストールするには、 yarn install --frozen-lockfile しかない
  • この点だけで npm を捨てて yarn に移行するまである!

上記の仕様理解が正しいとすれば、これらは完全に勘違いだったことになる。 古い誤った知識をアップデートできずただただ文句を言うだけだった自分が恥ずかしい。懺悔します。

言い訳

勘違いし続けたのは、ちまたの言説で「CIサーバーでは必ず npm ci を使え」と言われすぎているからだと思う。

実際は上記の仕様なのであれば、 npm install を使っても依存関係のインストールは deterministic になるので、実用上の問題があるとは思えない。 むしろ、 npm installpackage-lock.json が更新されるということは package.jsonpackage-lock.json に不整合がある状態ということである。 この状態を早めに検知するために npm install を使うほうが良いのではとすら思う。

ただし一点不安なのは、npm のバグにより 意図せず package-lock.json が更新されてしまう場合。 実際自分もこれまでそのようなことがあった気がするのである。 これが起きるとするなら、npm install を使う CI/CD が生成するアーティファクトを信用できなくなる。 それなら、リスク0の npm ci を使うという選択は妥当だろう。

「CIサーバーでは必ず npm ci を使え」と唱える人は、背景をそこまで説明してほしかったなという気持ちになる。

まとめ

まとまらないが、自分は npm install の仕様を信じて、開発時は基本 npm install 一本で行きたいと思う。

AWS CDKでアプリをデプロイしていると、 npm ci の挙動がデプロイ速度の律速になることがまれにあるため。 これで yarn を導入せずに済むな。

AWS Lambdaだけでアンケートフォームを作れる

Lambda function URL活用案件として、Lambdaだけを使ってアンケートフォームを作ってみた。 実際は回答の閲覧用にSlackも使ってるが、ほかは本当にLambdaだけ。

アーキテクチャ

コード

冗長な部分もあるので、要点だけ抜粋。フルのコードはこちらに載せた

これをLambdaのマネコンでデプロイし、Function URLを設定すれば良い。 また、 SLACK_WEBHOOK_URL 環境変数を設定すれば、アンケート結果をSlackに送信できる。

const https = require('https')

exports.handler = async (event) => {
  console.log(event)

  const req = event.requestContext.http;
  const sourceIp = req.sourceIp;
  // can perform IP address restriction here
  // e.g. if (sourceIp != "11.4.51.4") throw new Error()

  if (req.method == 'GET') {
    if (!(req.path == '/')) {
      return {
        statusCode: 404,
        body: 'Not found',
      };
    }
    return {
      statusCode: 200,
      body: getHTML(),
      headers: {
        'content-type': 'text/html'
      }
    };
  }
  else if (req.method == 'POST') {
    const response = JSON.parse(event.body);
    await processResponse(response);
    return {
      statusCode: 200,
      body: 'success',
    };
  }
  return {
    statusCode: 400,
    body: 'Bad Request',
  }
};

const processResponse = async (response) => {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;
  await post(webhookUrl, response);
};

const post = async (url, data) => {
  // c.f. https://stackoverflow.com/questions/40537749/how-do-i-make-a-https-post-in-node-js-without-any-third-party-module
}

const getHTML = () => {
  return `
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  </head>
  <body>
    <div class="flex container mx-auto py-10">
      <div class="grow">
        <form id="myForm">
          <div class="mb-6">
            <label for="q1" class="block mb-2 text-sm">
              Q1. 名前を教えて下さい。</label>
            <input
              type="text"
              id="q1"
              name="q1"
              placeholder="もつ太郎"
              required
            />
          </div>
          <div>
            <button
              type="submit"
            >
              Submit
            </button>
          </div>
        </form>
      </div>
    </div>

    <script>
      $('#myForm').submit(function (event) {
        event.preventDefault();
        var $form = $(this);
        var $button = $form.find('button');
        $.ajax({
          url: '/',
          type: 'POST',
          data: JSON.stringify(
            $form.serializeArray().reduce((json, { name, value }) => {
              json[name] = value;
              return json;
            }, {}),
          ),
          headers: {
            'Content-Type': 'application/json',
          },
        });
      });
    </script>
  </body>
</html>
`;
}

Function URLをブラウザで開くと、以下のようなアンケートフォームが表示される。

送信すると、Slackに連携される。

基本的な機能が揃っていることはお分かりいただけただろうか。

ポイント

1. ホスティングが単一のLambdaで完結する

今回はLambdaをHTTPアクセス可能にするために、Lambda Function URLと用いている。Amazon API Gatewayは使っていないので、複雑なAWSリソースの設定は不要。この程度の要件であればLambda単体で十分サービス提供可能である。

このため、Lambdaを1つだけマネコンでデプロイすれば完結する単純さが利点となる。 何らかの事情でGoogleフォームなどサードパーティー製ツールを利用できない方は、検討してみてはいかが。

ちなみに今は結果をSlackに連携しているが、上記の processResponse の実装を変えれば、任意の連携を実現可能。 例えばDynamoDBに保存したり、Amazon SNSに送信したりといったように。

2. index.js 1つで完結させるために

今回は完全手作業でのデプロイを想定し、可能な限りLambdaの構成をシンプルにしている。

例えば、ファイルが複数になると急激に手作業でのデプロイが面倒になる。 HTMLなどは別ファイルに分けたほうが本来見通しが良いが、getHTML 関数に押し込めている。

また、JavaScriptのライブラリを使うとバンドルが必要になり、これも単純な手作業ではデプロイできなくなる原因。 このため、バックエンドの実装はライブラリを一切使っていない。

フロントエンドについては CDN経由でライブラリの読み込みが可能なので、 DOM操作のために jQuery (初めて触った!) だけ使っている。jQuery、もう死んだものだと思ってたが、ここ1年リリースがなくたしかに死んでそうだった。

ReactなどモダンなフレームワークはWebpackに類するツールの利用を前提としている感があり、案外超Lightweightな用途では使いづらい気がした (preactもつらそう。) jQueryだけだと古風すぎるので、HTMLの色付け用にTailwindを使っている。

この方法はフォーム画面を作るためにHTML/CSSの知識が必須なので、若干ハードルの高い方法ではある。

3. セキュリティ面

Lambda Function URLは現状ビルトインの認証が IAM 認証しかないが、IAM認証 はブラウザからのアクセスでは使いづらい。

今回は認証なしでPublicアクセスを前提としている。 一応イベントからクライアントのIPアドレスを取れるので、IPアドレス制限程度であれば容易に実現できる。

exports.handler = async (event) => {
  const req = event.requestContext.http;
  const sourceIp = req.sourceIp;
  // 簡易なIPアドレス制限
  if (sourceIp != "11.4.51.4") throw new Error()

Basic認証を実装したら便利そうだと思ったが、なぜか現状は使えないようだった。 (www-authenticate ヘッダーが自動でDropされるような挙動を示す)

まとめ

Lambdaだけでアンケートフォームを作ってみた。

メリットは、

  • インフラは管理不要
  • 極限まで簡単な構築手順
  • 画面は無限にカスタマイズ可能

何らかの理由でこの手のSaaSが使えないという方は、検討してみても良いかも。

もつ名チートシート

もつをよく食べるのだが、部位の名前が異常に覚えられない。おそらく別称がたくさんあるため混乱するのだと思われる。

ここでは自分が食べたことのあるものに限って簡単な早見表をつくる。

全般

  • もつ: 臓物(ぞうもつ)、内臓のこと
  • ホルモン: 放る(捨てる)もん(もの) モツの別称

ぼんじりや軟骨などは内臓ではないため厳密にはモツとは呼べない気がするが、現実のモツ屋では混用されている。

牛は腸とか胃が多い。

  • ミノ: 第1の胃。普通に焼肉屋にも置いてあるメジャー選手。
  • ハチノス: 第2の胃。蜂の巣状の構造が見られることから。
  • センマイ: 第3の胃。見た目は灰色でトゲトゲしている。コリコリしてうまい。たまに臭みあり。
  • シマチョウ(テッチャン): 大腸。噛み切れない。
  • マルチョウ(コテッチャン): 小腸。別称がかわいい。
  • ハラミ: 横隔膜周辺の肉。もはやモツ感のないほぼ普通の肉。

豚は牛よりも多くの部位が食用される。

  • シロ: 大腸。これが豚ホルモンの代表格。
  • カシラ: 顎の筋肉。ほぼ肉。たまに臭みあり。
  • ガツ: 胃。あまり印象がない。
  • コブクロ: 子宮。クセがなくてコリコリしてる。うまい。
  • テッポウ: 直腸。やわらかい。たまに臭いのでタレ推奨。
  • ハラミ: 横隔膜周辺の肉。ほぼ普通の肉。
  • ハツ: 心臓。これもほぼ肉。
  • ノドブエ: 声帯。もつ屋で軟骨といったらだいたいこれがでてくる印象。

鳥はほぼ余すところなく食用される。

  • キンカン: 黄卵の前身。今度食べる。
  • レバー: 肝臓。日本で最も有名なもつ。
  • ぼんじり: 鳥が羽に付ける油がでてくるお尻のパーツ。脂っこい。
  • ハツ: 心臓。 レバーもどきな印象。
  • 砂肝: 胃の一種(砂嚢)。鳥はここで食べ物をすりつぶすらしい。うまい。
  • やげん軟骨: 胸骨の先端についている軟骨。
  • せせり (ネック、こにく): 首周りの筋肉。ほぼ肉。

まとめ

だいたいこれくらい覚えておけば9割方カバーできそう。 調べると他にも内臓の数だけ部位名がでてくるが、実際の店で見るものは少ない。

また、動物の種類が違っても呼び方が同じ部位がある (これが混乱を招く) ので、店で頼む際は注意したい。

ちなみにカバー写真のもつはただのイメージで、これほど高級なもつは食べたことがない。おすすめの店はここ

参考資料

NodejsFunctionのビルド時に依存関係を解決する3つの方法

AWS CDK TIpsシリーズの記事。

タイトルの通り、AWS CDKのNodejsFunctionを使う時に依存関係を解決する方法が数パターンあるので比較してみる。

今回は、以下のTypeScriptのLambdaをデプロイするケースを考える。

// よくあるAPI GatewayのLambda Authorizer
import { APIGatewayRequestAuthorizerHandler } from 'aws-lambda';
import { verify, decode } from 'jsonwebtoken';

export const handler: APIGatewayRequestAuthorizerHandler = async (event, context) => {
  const token = event.headers?.Authorization;
  if (token == null) return denyPolicy;
  if (verifyToken(token)) {
    return allowPolicy;
  }
  return denyPolicy;
};

const verifyToken = (token: string) => {
  const decoded = decode(token, { complete: true });
  return true;  // WIP
};

const allowPolicy =  {  /* WIP */ };
const denyPolicy = {  /* WIP */ };

また、フォルダ構造は以下のようになっているとする:

.                               # CDKプロジェクトのルートディレクトリ
├── bin
│   └── cdk.ts
├── backend
│   └── authorizer              # Lambda関数のコード
│       ├── index.ts            # LambdaのTypeScriptコード
│       ├── package.json        # ① Lambda用のpackage.json 
│       └── package-lock.json
└── lib
│   └── stack.ts                # CDKのスタック定義
├── cdk.json
├── package.json                # ② CDKのpackage.json
└── package-lock.json

上のLambdaが依存するnpmパッケージのうち @types/aws-lambda はただの型定義なので良いとして、jsonwebtoken ライブラリはビルド時に解決される必要がある。

方法の候補

依存関係をビルド時に解決する方法は、以下の3つがある。

方法1. rootのpackage.jsonに書く方法

上のツリーで②の package.json に必要な依存関係 (ここでは jsonwebtoken )を書く方法 。 CDK側のコードは以下で、最もシンプルに済む。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
    }

また、ルートディレクトリで一度 npm ci を実行すれば必要なインストールがすべて完了するのも良い点。

一方明らかなデメリットは、すべての依存関係が一つの package.json に集約されるので、各Lambdaが実際にどのライブラリに依存しているのか不明瞭になること。

例えば3つのLambdaがある場合に、それらの依存関係を一つの package.json で管理すると、以下のような状態になる。これでは誰が何のライブラリを使っているのかわからず、ライブラリの更新・削除などが大変になることが予想できる。

{
  "name": "cdk",
  "bin": {
    "cdk": "bin/cdk.js"
  },
  "scripts": {
    ...
  },
  "devDependencies": {
    ...
  },
  "dependencies": {
    "@types/aws-lambda": "^8.10.93",
    "aws-cdk-lib": "2.20.0",
    "constructs": "^10.0.0",
    "jsonwebtoken": "^8.5.1",  // Lambda 1の依存関係
    "extract-zip": "^2.0.1", // Lambda 2の依存関係
    "node-fetch": "^3.2.4" // Lambda 3の依存関係
  }
}

また、Lambdaごとに異なるバージョンのライブラリを使うことが難しいのも欠点になる場合があるだろう。 とはいえ、あまり依存関係がないLambdaや全Lambdaで共通の依存関係がある場合は、この方法でも十分機能する。

方法2a. 個別のpackage.jsonに書く方法

方法1のデメリットを解消したい場合は、Lambdaごとにpackage.json を作成(上のツリーで①のファイル)して、そこに依存関係を書けば良い。

CDK側の定義は方法1と同じでOK。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
    },

個別の package.json で依存関係を管理するので、方法1のデメリットは解消される。

一方デメリットは、各Lambdaのディレクトリで npm ci などを実行して node_modules がローカルにインストールされた状態でなければ、CDKのsynthesizeが失敗してしまうこと。 例えば以下のようなエラーを目にしたことのある方も多いのではないだろうか。

[ERROR] Could not resolve "jsonwebtoken"
    index.ts:4:20:
      4 │ import { verify, decode } from 'jsonwebtoken';
        ╵                                 ~~~~~~~~~~~~~
  You can mark the path "jsonwebtoken" as external to exclude it from the bundle, which will remove this error.

このあたりを読むとわかるが、CDK は esbuild の bundling 前に自動で npm ci を実行してくれない。このため、esbuild は必要な依存関係を解決できずに失敗する。

このデメリットは、複数人で1プロジェクトを開発しているときに顕著になる。 例えば以下のようなプロジェクト構成の場合、開発者1が cdk deploy するためには、lambda1, 2, 3 すべてのディレクトリで npm ci を実行する必要がある。

.                               # CDKプロジェクトのルートディレクトリ
├── bin
│   └── cdk.ts
├── backend
│   ├── lambda1                 # 主に開発者1が担当
│   │   ├── index.ts
│   │   ├── package.json
│   │   └── package-lock.json
│   ├── lambda2                 # 主に開発者2が担当
│   │   ├── index.ts
│   │   ├── package.json
│   │   └── package-lock.json
│   └── lambda3                 # 主に開発者3が担当
│       ├── index.ts
│       ├── package.json
│       └── package-lock.json
├── lib
│   └── stack.ts
├── cdk.json
├── package.json
└── package-lock.json

これは人によっては面倒に感じるだろうし、環境構築の手間が増える意味でも好ましくはないだろう。

とはいえ一度インストールしてしまえばその後気にする必要はないので、この方法を採っている人も多いと思われる。

方法2b. 個別のpackage.jsonに書く方法 (自動インストール)

方法2aの問題は、CDK内で解決することができる。以下のコードを使えば良い。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
      depsLockFilePath: 'backend/authorizer/package-lock.json',
      bundling: {
        commandHooks: {
          beforeBundling: (i, o) => [`cd ${i} && npm ci`],
          afterBundling: (i, o) => [],
          beforeInstall: (i, o) => [],
        },
      },

細かいところは割愛するが、基本的には commandHooks.beforeBundling 内で npm ci を実行し依存関係をインストールしている。 beforeBundling の処理は esbuild の実行前に実行されるので、これで esbuild はエラーなく実行できる。

depsLockFilePath も必要なプロパティだが、とりあえずはおまじない的に捉えても大丈夫だろう。 NodeJsFunction のImplementation detailが漏れ出ている箇所なので、気になる方はコードを読んでみると良い。

CDK内でインストールが完結するので、利用者は何も考えずに cdk deploy を実行すればデプロイできるのがメリット。

一方で、少しCDKのコードが複雑になる点と、synthesizeのたびに npm ci が走るため遅いのがデメリット。 npm ci は実行のたびにすべての依存関係をダウンロードするので、実用的な開発のためには npm install を使うか、yarn install --frozen-lockfile を使うよう検討したいところ。そもそもnpmにも --forzen-lockfile と同等のフラグが実装されればよいのだが……

github.com

方法3. NodejsFunctionnodeModules に書く方法

もう一つ方法があって、 nodeModules プロパティ に必要な依存関係を書くこともできる。

    new NodejsFunction(this, 'AuthHandler', {
      entry: 'backend/authorizer/index.ts',
      depsLockFilePath: 'backend/authorizer/package-lock.json',
      bundling: {
        nodeModules: ["jsonwebtoken"],
      },

この方法なら、方法2aのように明示的に npm ci を実行する必要はないし、方法2bよりも仕組みはシンプルになる。

nodeModules に指定した場合、esbuildはこのライブラリをバンドルせずに external なライブラリとしてimportを残す。このため、バンドルのタイミングでライブラリの実体がなくてもエラーは起きない。バンドルが完了した後、NodeJsFunction内の処理で自動で npm install される

パッケージのバージョンは depsLockFilePath で指定されたバージョンになるので、depsLockFilePath を明示的に指定する必要がある。ここで指定する lockFileは、必要なライブラリのバージョンが明記されてさえいればよく、場所は①でも②でも良い。

明らかなデメリットは、依存関係の名前が二重管理になる点。パッケージを追加するたびに package.jsonnodeModules の両方を編集する必要がある。

ちなみに、そもそも Native extension が含まれるライブラリは esbuild でバンドルできないので、方法1〜3のどれを使うにせよ bundling.nodeModules にライブラリ名を明記する必要がある。この辺りの詳細はesbuildのドキュメントを参照

まとめ

NodejsFunctionのビルド時に依存関係を解決する方法を3通り紹介した。 いずれの方法も一長一短なので、それぞれのメリットデメリットを把握した上で使い分けるのが良いと思われる。

私自身は、単純なLambdaは方法1、複雑なものも方法2aで妥協して (npm scriptでnpm ciをラップするなど) 使う場合が多い。

Appendix

方法1が動作する理由

depsLockFilePath プロパティを指定しない場合、NodeJsFunctionをバンドルする際のrootディレクトリはCDKのプロジェクトディレクトリ(正確にはcdkプロセスのworking directory。気になる人はこの辺を掘るとわかる。)になる。ルートの node_modules ディレクトリに必要な依存関係がインストールされているので、NodeJsFunctionをビルドする際にはそれが参照される。

番外編: NodejsFunctionではなくDockerImageFunctionを使う

そもそもNodejsFunctionではなく、Lambdaのコンテナランタイムで動くDockerImageFunctionを使う方法もある。

以下のようなDockerfileを使い、コンテナイメージを作る。 esbuildを直接呼び出し、NodeJsFunctionに相当するバンドル処理を実行している。パラメータはこちらも参照

FROM public.ecr.aws/lambda/nodejs:18 as build
WORKDIR /build
COPY package-lock.json package.json ./
COPY prisma ./prisma
RUN npm ci
COPY . .
RUN npx esbuild handler.ts --bundle --outdir=dist --platform=node

FROM public.ecr.aws/lambda/nodejs:18
WORKDIR ${LAMBDA_TASK_ROOT}

COPY --from=build /build/dist .

CMD ["handler.handler"]

このDockerfileをDockerImageFunctionでビルド・デプロイする。

    new DockerImageFunction(this, "Handler", {
      // ./lambda はDockerfileのあるフォルダ
      code: DockerImageCode.fromImageAsset("./lambda", { platform: Platform.LINUX_AMD64 }),
    });

この方法の利点は npm ci がDocker側でキャッシュされるので、package*.json を変更しない限りはビルドが高速に終わること。 cdk watch のように頻繁にビルドが走る状況において、NodejsFunctionと比べると大きな差になることもある。

ただし、コールドスタートにかかる時間はコンテナランタイムよりNode.jsランタイムのほうが若干良い (-100~200ms程度) 印象なので注意。 個人的には大した差ではないように思う (コールドスタートは通常全呼び出しの1%程度という統計もある) ので、最近は大きめのLambdaに関してはもっぱらこの方法を使っている。

実際のコードの例はこちらも参照 (方法3と合わせて実装例を示している):

github.com

小さめのデスクトップPCを組んだ #JONSBO T8

最近新型デスクトップPCを組んだので、顛末を記録しておく。

要件

顧客からの要望は以下の通り。

  1. 予算10万円程度 (OS/周辺機器含まず)
  2. 最新ゲームがそこそこできる
  3. かさばらない (部屋においてもじゃまにならない)

3がなにげに厄介。 というのも私はこれまで大は小を兼ねるの思想で、大型のデスクトップしか組んだことがなかったからだ。今も自分用は Define XL R2 というクソデカケースを使っている。 ここを満たすためには小型・低発熱・静音なパーツ選定をする必要があり、ちょっとしたパラダイムシフトを味わうことができた。

パーツ選定

上記要件を満たすような選定をする。

ケース

今回はPCの外見が最重視されたため、ケースから選ぶことにした。 TSUKUMOで見つけたこいつがお眼鏡にかない、決定。

JONSBO T8 Silver というケースだが、実際かなり小さくて驚いた。小さめのゴミ箱くらいのサイズで、ちょうど部屋の隅に収まるのである。下の写真でコンセントと比較するとその小ささがわかるだろう。

すっぽり!

ちなみにこれ、TSUKUMOでは14800円だったがAliExpressだと送料込11700円で見つかった。中国から輸送してなおその値段は安すぎる。ただし到着まで2週間ほど要した。

ケースが決まったので、これに合うパーツを選んでいく。

CPU

ゲーム用ということでまずはCore iかRyzenシリーズかで迷う。調べてみると、今回のターゲットとなる価格帯1万円台のCPUはIntelの方が選択肢多い印象だった。 というか今価格comを確認しても、Ryzenの低価格帯モデルは全然売ってない。品切れなのか…?

そんな理由でIntelに当たりをつけてしばらく中古市場 (a.k.a. フリマアプリ)を漁っていたところ、Intel core i3 10320 を14000円で入手できた。(さらにクーポンで700円引き!)

用途的にはもっと弱いi3でも良かったが、巷の情報によればこれが少し珍しいモデルらしいので惹かれてしまった。

【自作PC】世界で5人くらいしか持ってなさそうなCPUの入手に成功。「Core i3 10320」 - YouTube

あと、1個下のi3 10105も差額2000円安い程度だったので、あえて下げなくても良い感は強かった。単一コアの性能は同世代のi5よりも高いので、割りとコスパ良かったんではと思っている。その他の細かな比較は脚注に書いた。*1

CPUは Intel i3 10320 で決まったので、これに合うマザーボードを選んでいく。

マザーボード

ケースのJONSBO T8はMini-ITX規格のみ搭載可能なので、その中から選ぶ。 といっても、今回必要な次の条件

  • Mini-ITX
  • LGA 1200搭載可能
  • 10000円台

を満たすマザーボードは選択肢が少なく、価格コム調べで3モデルしかない。

結局MSIの H510I PRO WIFI にした。3つの間に今回のユースケースで意味のある機能差はほとんどなかったのだが、あえて理由を挙げるなら以下:

マザーボードWi-Fiモジュールが内蔵されているのには驚いた。最近のマザーボードにはアンテナを生やせる。

これでベースは整った。あとは合うパーツを適当に選んでいく。

GPU

中古のGTX 1060 Mini 3GBを21500円で購入(更にクーポンで1000円引き!)。特にこだわりなく、中古市場にある中でコスパ良いものを選んだ。 ケースの都合でボードが短いものしか選択できず、また高発熱のものは排熱しきれない・うるさくなる可能性がある。GTX1060はその辺りの懸念とのバランスも良い。

RAM/ストレージ

RAMは2枚しか挿せないので、8GB x 2で16GB。 ストレージはとりあえずM.2 SSDの1TBとした。 ケースの都合上拡張性に乏しいので、ここは顧客とよく相談して決める必要がある。

CPUクーラー

当初標準クーラーを使う予定だったが、実際動かしてみると高負荷時に思ったよりうるさい(ファン径が小さいので甲高い音がでる)ので、変更することにした。要件が後から追加されるのは世の理。

ただしT8に入るクーラーは高さ66mmまでと、かなり選択肢が限られる。 結局レビューの評価が良いNoctua NH-L9iに決定。中古で黒いバージョンを5000円で入手できた*2。これはかなり高級感があり見た目もかっこ良くて満足度高い。ファン径も92mmと、標準クーラーより1.5倍ほど大きくなったので、かなり静音化もされた :tada:。

電源

JONSBO T8はATXSFX電源の2規格に対応しているが、ATX電源を入れると非常に狭くなるので、より小さいSFXの方を選びたい。 また電源容量的には、上記パーツの最大消費電力を合計すると約230Wなので、300Wもあれば十分。

上記条件で市場を探ると、Corsair SF450が見つかった。6500円で購入。

実物を見ると、SFXはATXよりかなり小さくてビビる。今までATXしか使ってなかったが、低容量電源ならもうこっちでいいんじゃないか。

その他

T8はデフォルトで140mmのケースファンが1つ付属するのだが、これが起動中は虹色に輝くので視覚的にやかましい。顧客からのクレームがきたので、光らないファンを代わりに用意した。960円。

これにて全てのパーツの選定・購入が完了したので、粛々と組み立てていく。

組み立て編

購入経路がさまざまのため、各パーツが異なる時期にバラバラと送られてくる。

できるだけ組み立てを迅速に行うため、パーツが届くごとに順次動作確認することにした。(特に今回は多くが中古パーツなので重要)

マザボ・CPU・RAM・電源があれば最低限動作するので、そこから検証していく。

仮組みの様子

ちなみに上の写真では乱雑にビニール袋の上にマザーボードを置いているが、この状態では動作がめちゃくちゃ不安定だった。少し触れたらフリーズするなど。最低限箱の上に置くなどして基板を浮かせたほうが良さそう。

結果的には、全てのパーツが問題なく動作した。フリマアプリ万歳。

完成形がこれ。実はこれはクーラーをNoctuaに交換する前の写真なのだが。ケーブルが硬いので、配線の整理はこれが限界だった。

小さめケースで組むのは初めてだったが、やはり狭くて作業しづらい欠点があるものの、完成後の満足感は高いという発見があった。密度高いことでムダのない感じがあって良い。

合計金額

カテゴリ 名前 値段 [円]
電源 Corsair SF450 CP-9020104-JP (中古) 6500
ケース JONSBO T8 Silver 11706
マザーボード MSI H510I PRO WIFI 13463
ストレージ Crucial CT1000P2SSD8JP 9685
メモリ KLEVV DDR4 2666 PC4-21300 8GB x 2枚 5933
CPU Intel Core i3 10320 BOX (中古) 13300
CPUクーラー Noctua NH-L9i chromax.black (中古) 5000
ケースファン Scythe KAZE FLEX 140 KF1425FD12S-P 960
GPU NVIDIA GTX 1060 mini 3GB (中古) 20500
合計 87047

(デザイン費含まず)

なんということでしょう。当初の予算 (10万円)からお釣りが出るくらいになった。OSと周辺機器含めても10万強程度。こうして顧客(妻)のゲーミングもといネットサーフィングマシンが完成したのであった。

まとめ

小型PC楽しい。次はもっと小さいのもやってみたい。

あと、フリマアプリはたまに掘り出し物が出品されるので、常に監視したくなる。メルカリ→ラクマ→Paypayフリマ→メルカリ…の無限ループ完成である。

しかし、これらを頻繁に巡回してると割とメンタルを削られる (株のチャートから目を離せなくなる心理と似ている) ので、妥協できる値段のものがあればさっさと買ってしまうのが良いように感じた。 また買った後はもうその商品は検索しないのが良い。より安いのが出品されたら凹むので。

ということで色々学びのあったPC組み立てでした。

*1:また、最近のCPUには通常モデルからグラフィック機能を削ったF付きのモデルがあり(i5 10400Fなど)、これは通常モデルより3000円ほど安くなる。実際今回は別にGPUを付けるのでFモデルでも十分なのだが、GPUの調子がおかしいときの原因調査の際、結局CPUの内蔵グラフィックを活用した経験もあったので今回も避けた。予備のGPUを常に確保している人には、F付きモデルが良さそう。 第8,9世代のCPUも見てみたが、中古の割にあまり安いものがなく、またマザーボードの入手性も下がっているので、当時の最新世代から選ぶことにした。

*2:ヒートシンクはともかくファンの中古はやや抵抗ある。今回に限っては元の持ち主が短期間のみの使用ということだったのでそのまま使っている。

Hardhat/Truffleを使わずにSolidityをコンパイルする

Hardhat/Truffle を使わずに、Solidityのスマートコントラクトをコンパイルしたい。そういった望みを持つこともあると思う。 そんなときに使える方法を紹介する。

solc-js

solc-js という solc (Solidityのコンパイラ) をJavaScriptからプログラマブルに呼び出せるようラップしたライブラリがある。 今回はこれを使ってSolidityのスマートコントラクトをコンパイルする。

github.com

インストール

solc-jsのインストールはnpmだと次のコマンドでできる:

npm i solc

なお、solc-jsには現状TypeScript用の型定義ファイルが同梱されていない。Type Definition for solc-js ? · Issue #578 · ethereum/solc-js · GitHub

このため、このままでは TypeScriptの記法で import することはできない。これを回避するにはいくつかの方法がある (以下を参照) ので、都合の良いものを採用しよう。

stackoverflow.com

以下では、JSの記法 const solc = require('solc') でインポートするようにする。将来solc-jsのTS対応が完了すれば型定義も同梱されるようなので、その時に直せば良いだろう。

使い方

ここまでできたら、次のようなコードでSolidityをコンパイルできる。

import * as fs from 'fs';
const solc = require('solc');

const compile = async (fileName: string, contractName: string) => {
  const file = fs.readFileSync(fileName).toString();
  const output = 'input.sol';
  const input = {
    language: 'Solidity',
    sources: {
      [output]: {
        content: file,
      },
    },
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
      outputSelection: {
        [output]: {
          '*': ['evm.bytecode.object', 'abi', 'evm.deployedBytecode'],
        },
      },
    },
  };

  const out = JSON.parse(solc.compile(JSON.stringify(input), { import: findImports }));
  const contract = out.contracts[output][contractName];
  return { bytecode: `0x${contract.evm.bytecode.object}`, abi: contract.abi };
};

const importCache: { [key: string]: { contents: string } } = {};
const findImports = (path: string) => {
  if (importCache[path] == null) {
    const file = fs.readFileSync(`node_modules/${path}`);
    importCache[path] = {
      contents: file.toString(),
    };
  }
  return importCache[path];
};

// コンパイル実行
compile("sample.sol", "DemoContract");

基本的には solc.compile という関数に solc のStandard input jsonを渡せば良い。 上記コードでは input 変数がそれに当たり、ここを上記ドキュメントに沿って変更することで、任意の構成でコンパイルを実行できる。

ただし、実世界のSmart contractは何らかのライブラリに依存することが多い。その場合は、それぞれのimportを解決する必要がある。

importの解決のために、 { import: findImports } というオプションで解決の関数を渡す。findImports 関数は独自に実装する必要があるが、依存するファイルをローカルに用意しておけば、単にファイルを読み込んで返すだけで済む。

例えば、以下のようなimportを解決したい場合、

import "@openzeppelin/contracts/access/Ownable.sol";

事前に npm install @openzeppelin/contracts を実行すれば、 ./node_modules/@openzeppelin/contracts/contracts/access/Ownable.sol を読み込むだけでimport先のファイルを取得できる。

上記コードの findImports 関数でも、そのような処理を実行している。

コンパイルするまで依存するライブラリを予測できない場合は、findImportsの中でネットワーク越しに依存ライブラリを取得するような処理が必要になる。(けど、そんなユースケースは少なそう)

これだけでコンパイルができた。なお上のコードでは、最後にBytecodeとABIを返すようにしているが、これも standard output json を参照すれば任意の項目を返すようにカスタマイズできる。

上記のコードはまとめて以下のリポジトリに配置した。

github.com

その他

コンパイラのバージョンは以下で取得可能。基本的に solc-js 自体のバージョンと同じになる模様。

const solc = require('solc');
const version = solc.version();  // '0.8.13+commit.abaa5c0e.Emscripten.clang'

もしコンパイラのバージョンを変えたいときは、こちら: https://github.com/ethereum/solc-js#using-a-legacy-version

まとめ

Solidityのコンパイルくらいであれば、重厚なライブラリを使わずとも比較的簡単に実現できる。 ユースケースによってはこれが活きることもあるので、活用されたい。