😎

Docker(App Runner)+Honoなら、BunでもDenoでもNode.jsでも動く

2023/10/21に公開3

モチベーション

  • Edge環境やLambda周りでの利用事例はあるが、意外と Hono on Dockerな事例が見つからなかったので試してみる。
  • App RunnerがSSE(Server-Sent Events)に対応していると聞いて

App Runner

Webサービスを簡単に立てられるAWSのサービス。DockerImageをカジュアルにホスティングする。

ちなみに組み込みランタイムはNode v16までしか対応していない(しっかりしてくれ!)ので割愛。

https://docs.aws.amazon.com/apprunner/latest/dg/service-source-code-nodejs-releases.html

Docker + Hono

それではDockerで試してみよう。

1. Bun

以下のコマンドでbun用のフォーマットを選び、hono-bunという名前のWorkspaceを作る。

npm create hono@latest hono-bun

Code

Hono v3.8から採用されたSSEで、時刻が返ってくる。

src/index.ts
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();

let id = 0;

app.get('/', (c) => c.text('Hono on Bun'));

app.get("/sse", async (c) => {
  return streamSSE(c, async (stream) => {
    while (true) {
      const message = `It is ${new Date().toISOString()}`;
      await stream.writeSSE({
        data: message,
        event: "time-update",
        id: String(id++),
      });
      await stream.sleep(1000);
    }
  });
});

export default app;

Dockerfile

distrolessを選ぶと200MBが100MB程度に下がるので、頑張ってマルチステージビルドする。

Dockerfile
# build
FROM oven/bun:latest AS build

WORKDIR /app

COPY package.json bun.lockb ./

RUN bun install --production

# runtime
FROM oven/bun:distroless AS runtime

COPY --from=build --chown=nonroot:nonroot /app/node_modules /app/node_modules
COPY src ./src

USER nonroot
EXPOSE 3000

CMD ["run", "--hot", "src/index.ts"]

2. Deno

以下のコマンドでDeno用のフォーマットを選び、hono-denoという名前のWorkspaceを作る。

npm create hono@latest hono-deno

Code

Healthcheck相当の/を除くと、import文とエントリポイントを変えれば同じコードで動く。

hello.ts
import { Hono } from "https://deno.land/x/hono@v3.8.1/mod.ts";
import { streamSSE } from "https://deno.land/x/hono@v3.8.1/helper.ts";

const app = new Hono();

let id = 0;

app.get('/', (c) => c.text('Hono on Deno'));

app.get("/sse", async (c) => {
  return streamSSE(c, async (stream) => {
    while (true) {
      const message = `It is ${new Date().toISOString()}`;
      await stream.writeSSE({
        data: message,
        event: "time-update",
        id: String(id++),
      });
      await stream.sleep(1000);
    }
  });
});

Deno.serve(app.fetch);

Dockerfile

nonrootの権限周りでハマってちょっと汚い。

Dockerfile
# build
FROM denoland/deno:debian AS build

WORKDIR /app

COPY hello.ts .

RUN mkdir -p /deno-dir 
RUN deno cache hello.ts

# runtime
FROM denoland/deno:distroless AS runtime

COPY --from=build --chown=nonroot:nonroot /app /app
COPY --from=build --chown=nonroot:nonroot /deno-dir /deno-dir
ENV DENO_DIR=/deno-dir

USER nonroot
EXPOSE 8000

CMD ["run", "--allow-net", "app/hello.ts"]

3. Node

以下のコマンドでNodejs用のフォーマットを選び、hono-nodeという名前のWorkspaceを作る。

npm create hono@latest hono-node

Code

こちらもimportとエントリポイント(と/)を変更する。node-serverを追加するのがポイント。

src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();
let id = 0;

app.get('/', (c) => c.text('Hono on Nodejs'));

app.get("/sse", async (c) => {
  return streamSSE(c, async (stream) => {
    while (true) {
      const message = `It is ${new Date().toISOString()}`;
      await stream.writeSSE({
        data: message,
        event: "time-update",
        id: String(id++),
      });
      await stream.sleep(1000);
    }
  });
});

serve(app);

Config

honoのstarterだと足りないものがあったので、いろいろ追加する。

package.json
{
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc"
  },
  "dependencies": {
    "@hono/node-server": "^1.2.0",
    "hono": "^3.8.1"
  },
  "devDependencies": {
    "tsc": "^2.0.4",
    "tsx": "^3.14.0",
    "typescript": "^5.2.2"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx"
  ],
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ]
}

Dockerfile

Dockerfile
# build
FROM node:20-slim AS build

WORKDIR /app

COPY package*.json tsconfig.json ./
COPY src ./src

RUN npm ci --only=production
RUN npm install typescript --save-dev \
    npx tsc 
RUN npm prune --production

# runtime
FROM gcr.io/distroless/nodejs20-debian12 AS runtime

COPY --from=build --chown=nonroot:nonroot /app/node_modules /app/node_modules
COPY --from=build --chown=nonroot:nonroot /app/src /app/src

EXPOSE 3000
CMD ["/app/src/index.js"] 

App Runnerでの構築

App Runnerの欠点としてDeploy待ちが面倒だったりする。Imageを差し替える時間が無駄なので景気よく、3多重で作ってしまおう。CDKなら簡単。

cdk.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecrAssets from 'aws-cdk-lib/aws-ecr-assets';
import * as ecrDeploy from 'cdk-ecr-deployment';
import * as apprunner from '@aws-cdk/aws-apprunner-alpha';

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const repo = new ecr.Repository(this, "app-runner-api-repo", {
      repositoryName: 'app-runner-api-repo',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteImages: true
    });

    const images = [
      { id: 'Node', directory: '../hono-node', port: 3000, tag: 'node' },
      { id: 'Deno', directory: '../hono-deno', port: 8000, tag: 'deno' },
      { id: 'Bun', directory: '../hono-bun', port: 3000, tag: 'bun' }, 
    ];

    images.forEach(image => {
      const ecrAsset = new ecrAssets.DockerImageAsset(this, `CDKAppRunner${image.id}Image`, {
        directory: image.directory,
      });

      new ecrDeploy.ECRDeployment(this, `Deploy${image.id}ECRImage`, {
        src: new ecrDeploy.DockerImageName(ecrAsset.imageUri),
        dest: new ecrDeploy.DockerImageName(`${repo.repositoryUri}:${image.tag}`),
      });

      const apprunnerName = new apprunner.Service(this, `${image.id.toLowerCase()}Service`, {
        source: apprunner.Source.fromEcr({
          imageConfiguration: { port: image.port },
          repository: repo,
          tagOrDigest: image.tag,
        }),
      });

      new cdk.CfnOutput(this, `App-Runner-Uri-${image.id.toLowerCase()}`, {
        value: apprunnerName.serviceUrl,
      });
    });
  }
}

作ったURLにアクセスすると、いずれもいい感じにSSEイベントが流れてくることがわかる。

まとめ

改めてHonoがマルチランタイムであることを確認できた。Hono + Dockerfileの作り方は意外とWebにないので、今後動かすときのサンプルにできそうだ。

あくまで参考値だが、Docker Imageのサイズはこんな感じ。Bunは作りやすいし軽いので良い。

Discussion

五所 和哉 (MonCargo CTO)五所 和哉 (MonCargo CTO)

ありがとうございます!

COPY --from=build --chown=nonroot:nonroot /app/node_modules /app/node_modules

上記に関して、エントリーとなるファイルを bun build もしくは esbuild 等で Bundle + Minify してしまえば、イメージサイズを更に減らせますかね...?
デメリットとしては

  • ビルド手順に一手間かかる
    • 事前に Bundle しておいて、そのファイルだけを Docker 内部に COPY することも考えられそうだが、それでも手順が増える
  • WASM や Native Binding を利用したパッケージには Bundle してもちゃんと動くか不安が残る

辺りでしょうか。上記以外のデメリット等あればお伺いしたいです!