Azure Function App に sharp で 画像リサイズする API をデプロイする

## ローカルで動くから何？

画像の base64 エンコードデータを受け取って、`sharp` モジュールを使ってリサイズし、base64 エンコードされたデータを返す API を作りたい。今回はプラットフォームが Azure なので、Function App を利用する。なお、App Service では、リリースは可能だがメモリ不足で動かないので、Function App に切り出した。

ローカルではあっさり動いたが、デプロイはすんなり行かなかった。

- デプロイは成功したが、成功してなかった
- デプロイは成功したが、動かなかった

GitHub Actions を使いまくっていたら、容量オーバーと言われたので、今回は全ての操作を Azure CLI で行う。Azure Portal でやっても良いのだが、なんとなく全部コマンドにしたかった。

## 開発環境

- Chip: Apple M2 Max
- macOS: 14.6.1
- node: v20.17.0
- npm: 10.8.2
- az: 2.64.0
- func: 4.0.6280

## Azure のセットアップ

基本的には、「[クイックスタート: コマンド ラインから Azure に TypeScript 関数を作成する](https://learn.microsoft.com/ja-jp/azure/azure-functions/create-first-function-cli-typescript?tabs=macos%2Cazure-cli%2Cbrowser&pivots=nodejs-model-v4)」に従う。

```zsh
az login
```

### リソースグループの作成

```zsh
az group create --name SharpResize-rg --location southeastasia
```

`location` は、私がベトナム向けに作ったアプリケーションで利用するので、southeastasia になっている。

### ストレージアカウントの作成

Function App のデプロイで使うため、作成が必要。

```zsh
az storage account create \
  --name sharpresizesa \
  --location southeastasia \
  --resource-group SharpResize-rg \
  --sku Standard_LRS \
  --allow-blob-public-access false
```
ストレージアカウント名には、大文字や記号は使えない。

sku は、一番料金がかからないものにした。他には以下のようなものがあるそうだが、デプロイにしか使わないので、最低限で良い。

- **LRS**: 最も安価。標準のストレージオプションであり、1GBあたり数セント程度。
- **ZRS**: LRSよりも少し高価ですが、データセンター障害に耐える可用性を提供します。
- **GRS/RA-GRS**: LRSの約2倍のコスト。地理的な冗長性を持つために高額です。
- **GZRS/RA-GZRS**: GRS/RA-GRSよりさらに高額で、最も高い冗長性と可用性を提供します。

### Function App の作成

```zsh
az function app create \
  --resource-group SharpResize-rg \
  --comsumption-plan-location southeastasia \
  --runtime node \
  --runtime-version 20 \
  --functions-version 4 \
  --name sharp-resize \
  --storage-account sharpresizesa
```

`--os-type linux` とすると、OS が Linux になり気分が良いが、あれこれ使えず Debug が大変なので、無指定で Windows で動かすことにした。

node でサポートされているバージョンは、18 と 20 のようだが、積極的に18を選ぶ理由はないかと。


これで、Azure の設定は、後で環境変数を設定するが、いったんおしまい。

Azure Portal で確認してみると、Function App, Storage Account 以外に Action Group, Application Insights, App Service Plan が作成されている。全部必要なので、消さないように。

## API の作成

```zsh
func init --typescript
func new --name resize --template "HTTP trigger" --authlevel "anonymous"
```

これが終わると、

```
├── host.json
├── local.settings.json
├── node_modules
├── package-lock.json
├── package.json
├── src
│   └── functions
│       └── resize.ts
└── tsconfig.json
```

こんなような感じになる。以前は、

```
└── resize
    ├── index.ts
    └── function.json
```

というような形になっていたが、function.json は、いまは必要ない。うまく行かないときに Chat GPT に聞くと、function.json がないせいだと言われるが、必要ない。

`sharp` をインストールする。

```zsh
npm install sharp
```

コマンドで出来上がったテンプレートを元に GitHub Copilot に sharp を使った resize を問答を繰り返し書かせる。

```ts:src/functions/resize.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
const sharp = require('sharp');

export async function resize(
  request: HttpRequest,
  context: InvocationContext
): Promise<HttpResponseInit> {
  context.log(`Http function processed request for url "${request.url}"`);

  const quality: number = 90;
  const width: number = 1920;
  const fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside' | 'containe' =
    'inside';
  const withoutEnlargement: boolean = true;

  // get imageData from post params
  let imageData: any;
  try {
    const body: any = await request.json();
    imageData = body.imageData;
  } catch (error) {
    context.log('Error parsing JSON body', error);
    return {
      status: 400,
      body: 'Invalid JSON body',
    };
  }

  const blob = atob(imageData.replace(/^.*,/, ''));
  let buffer = new Uint8Array(blob.length);
  for (let i = 0; i < blob.length; i++) {
    buffer[i] = blob.charCodeAt(i);
  }

  // check buffer
  const imageBuffer = Buffer.from(buffer);
  if (!Buffer.isBuffer(imageBuffer)) {
    return {
      status: 500,
      body: 'Expected image buffer to be a Buffer.',
    };
  }

  const resizedImageBuffer = await sharp(imageBuffer)
    .resize({
      width,
      fit,
      withoutEnlargement,
    })
    .webp({ quality })
    .toBuffer();

  return {
    status: 200,
    body: JSON.stringify({
      imageData:
        'data:image/webp;base64,' + resizedImageBuffer.toString('base64'),
    }),
    headers: {
      'Content-Type': 'application/json',
    },
  };
}

app.http('resize', {
  methods: ['POST'],
  authLevel: 'anonymous',
  handler: resize,
});
```

良さそうである。quality, width, fit, withoutEnlargement などはリクエストパラメータとして受け取った方が汎用的だが、今回は変える必要がないので、固定値にした。出力は、webp になっている。用途に合わせ好きに設定すれば良い。

```zsh
npm start
```

とすると、build が走った後、動く。

```zsh
[2024-09-17T10:36:14.398Z] Worker process started and initialized.

Functions:

	resize: [POST] http://localhost:7071/api/resize
```

正しく動作することが確認できたので、デプロイする。

```zsh
npm run build
func azure functionapp publish sharp-resize
```

こうすると、デプロイはすぐに完了するが…

```zsh
...
Deployment successful.
Remote build succeeded!
Syncing triggers...
Functions in sharp-resize:
```

あれ？ resize がいない。Azure Portal で確認してもない。URL を叩いてみても当然 404。
正しくデプロイされると、

```zsh
Functions in sharp-resize:
    resize - [httpTrigger]
        Invoke url: https://sharp-resize.azurewebsites.net/api/resize
```
こうなるハズである。何故なのか？

## API を Azure 上で動くようにする

### Function を認識させる

ローカルでは動くし、デプロイも成功するが、デプロイ先にモノがないので、ログもない。ただ動かない。Debug 難易度の高い案件だ。Chat GPT や GitHub Copilot に助けを求めると、ログ見ろ、function.json がないからだなどと言われるが、どれも的外れ。

途方に暮れるが、可能性をひとつずつ潰していくしかない。

まずは、`func new --name resize --template "HTTP trigger" --authlevel "anonymous"` で生成される Hello, world! な関数だけにしてデプロイしてみる。

なんの問題もなくデプロイされる。なので、`function.json` が〜…な線は消えた。ということは、ソースコードに問題があるハズ。

処理の塊ごとに、コメントアウトしながら、デプロイを繰り返し丁寧に確認していく。

ようやくたどり着いた犯人は、`const sharp = require('sharp');` だった。

```ts:src/functions/resize.ts
import {...} from '@azure/functions';
const sharp = require('sharp');

export async function resize(...) {
  ...
  const resizedImageBuffer = await sharp(imageBuffer)...
  ...
}
...
```

この `sharp` が関数内で認識できず、不良関数として落ちていた。

```ts:src/functions/resize.ts
import {...} from '@azure/functions';

export async function resize(...) {
  ...
  const sharp = require('sharp');
  const resizedImageBuffer = await sharp(imageBuffer)...
  ...
}
...
```
結局、こうすることで、デプロイしたときに関数として認識されるようになった。もちろん、ローカルでも動く。めでたし。

デプロイした API を叩いてみると、

```
500 Internal Server Error
```

めでたくなかった。だがしかし、今回はログがある！

### sharp のせいだった

今回、500 エラーを意図的に吐き出しているのは、Buffer のチェック部分だけだ。リクエストが何かダメだった？と思いつつ、ログを見てみる。

そもそも API が呼び出されると、

```ts
  context.log(`Http function processed request for url "${request.url}"`);
```

によって、呼び出されたことが吐き出されるのだが、いない！！なんでだよ…

API が呼び出されたが、処理に入る前に 500 エラー。API を認識させるのに、かなり手間取ったので、ようやく解決できた後にこれは絶望感が深い。

だが、ログは出るはずだ。落ち着け。

ログを監視しながら、500 エラーを起こし続けると、犯人が判明した。

```zsh
Could not load the "sharp" module using the win32-ia32 runtimePossible solutions:
- Ensure optional dependencies can be installed:
    npm install --include=optional sharp
- Ensure your package manager supports multi-platform installation:
    See https://sharp.pixelplumbing.com/install#cross-platform
- Add platform-specific dependencies:
    npm install --os=win32 --cpu=ia32 sharp
- Consult the installation documentation:
    See https://sharp.pixelplumbing.com/installStack
```

あぁ、あなたですか、sharp。確かに画像をいじるんですもんね。プラットフォームに依存しますよね。Windows で開発してたらもしかしたら遭遇しなかったかも知れないですね。

```zsh
npm remove sharp
npm install --os=win32 --cpu=ia32 sharp
npm run build
func azure functionapp publish sharp-resize
```

これで、関数は認識され、正しく動くようになった。今度こそ、めでたし。

win32-ia32 は、`az function app create ...` した時に、`--os-type linux` とした場合は、異なるので、自分のプラットフォームに合わせたものを指定する必要がある。なお、私のローカル（Apple シリコンの Mac）の場合、darwin-arm64 になる。

### だが不便である

正しくデプロイできるようにはなったが、ローカルではもちろん動かない。いちいち覚えてられないし、`package.json` に忘れないように書いておく。

```json:package.json
{
  "scripts": {
    "build": "echo \"run localbuild or prodbuild instead.\" && npm run localbuild",
    "localbuild": "npm run clean && npm remove sharp && npm install sharp && tsc",
    "prodbuild": "npm run clean && npm remove sharp && npm install --os=win32 --cpu=ia32 sharp && tsc"
    ...
  },
}
```

`localbuild`, `prodbuild` を足した。`build` は、`start` などで呼び出されるため、なんらか build する必要があるため、localbuild を呼ぶことにした。今回の流れでは、ローカルで build するが、そうでない場合は、その環境に依存したパッケージがインストールされることだろう。注意喚起のため、メッセージも出るようにしておいた。

なお、試していないが、GitHub Actions でデプロイする場合、build は GitHub の Ubuntu 上で行われるので、build のステップで、prodbuild するか、まぁ何か工夫が必要になると思われる。

## クリーンアップ

ただ手を動かして試してみたかったという人は、下記コマンドで今回つくったものを一括で削除できる。

```zsh
az group delete --name SharpResize-rg
```

## まとめ

`sharp` 注意！

例えば、あなたがリリース担当で、メンバーが開発を行っているような場合、今回のようなケースは、解消が難しそうだ。ローカルでは動く！と何度も耳にしそう。役割分担で言えば、インフラ構築を担当した人が、OS は Windows だよと申し送りするべきなのかも知れないが、一度困らないと気をつけられない。AI が指摘してくれると良いなぁ。こんなに頻繁に行われそうな内容なのに、ドンピシャなドキュメントも見当たらなかったので、備忘録として。