Hono app with Docker, Kubernetes

目次

    こちらは Hono Advent Calendar 2023 3日目の記事です。

    はじめに

    Honoと聞くと、Cloudflareなどのサーバーレスなサービスでアプリを動かす印象が強いですが、コンテナ系のサービスでも全然開発出来るよーというユースケースの紹介です。

    アプリを実装する

    HonoでAPIを、Reactでクライアントを実装し、簡単なTodoアプリを作ってみます。

    全体は以下のようなモノレポになっています。

    .
    ├── README.md
    ├── apps
    │   ├── api/
    │   └── web/
    ├── okteto.yaml
    ├── package-lock.json
    └── package.json

    API

    構成はMVCです。

    app/
    ├── Dockerfile
    ├── dist/
    ├── k8s_api.yaml
    ├── package.json
    ├── src
    │   ├── controller
    │   │   ├── index.ts
    │   │   └── todo-controller.ts
    │   ├── index.tsx
    │   ├── infrastructure
    │   │   ├── index.ts
    │   │   ├── todo-database.ts
    │   │   └── todo.db
    │   ├── lib
    │   │   ├── index.ts
    │   │   ├── result.ts
    │   │   └── sqlite3.ts
    │   └── model
    │       ├── index.ts
    │       └── todo-model.ts
    ├── tsconfig.json
    └── tsup.config.ts

    DBは以下で初期化しておきます、

    import { sqlite3 } from "@/lib";
    import { Database } from "sqlite3";
    import path from "path";
    
    export const TODO_DATABASE_PATH = path.join(__dirname, "todo.db");
    
    export interface TodoInterface {
      uid: string;
      title: string;
      completed: 0 | 1;
    }
    
    export class TodoDatabase {
      db: Database;
    
      constructor() {
        this.db = new sqlite3.Database(TODO_DATABASE_PATH);
        this.#creataTable();
      }
    
      close() {
        this.db.close();
      }
    
      #creataTable() {
        const sql = `
          CREATE TABLE IF NOT EXISTS todo (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            uid TEXT,
            title TEXT,
            completed INTEGER
          )`;
        this.db.run(sql);
      }
    }

    CRUDをそれぞれ書いてみます。

    Model

    import { TodoInterface, TodoDatabase } from "@/infrastructure";
    import { Result } from "@/lib";
    
    export class TodoModel {
      #todo: TodoDatabase;
    
      constructor() {
        this.#todo = new TodoDatabase();
      }
    
      read({ uid, title, completed }: Partial<TodoInterface>) {
        return new Promise<Result<TodoInterface[]>>((resolve) => {
          let sql = "SELECT * FROM todo";
          const params: TodoInterface[keyof TodoInterface][] = [];
          if (uid) {
            sql += " WHERE uid = ?";
            params.push(uid);
          }
          if (title) {
            sql += " WHERE title = ?";
            params.push(title);
          }
          if (completed) {
            sql += " WHERE completed = ?";
            params.push(completed);
          }
          this.#todo.db.all<TodoInterface>(sql, params, (error, rows) => {
            if (error) resolve({ status: "error", error });
            resolve({ status: "ok", data: rows });
          });
        });
      }
    
      create({ uid, title }: Pick<TodoInterface, "title" | "uid">) {
        return new Promise<Result<TodoInterface[]>>((resolve) => {
          const sql = "INSERT INTO todo (uid, title, completed) VALUES (?, ?, ?)";
          this.#todo.db.run(sql, [uid, title, 0], (error) => {
            if (error) resolve({ status: "error", error });
            resolve({ status: "ok", data: [{ uid, title, completed: 0 }] });
          });
        });
      }
    
      update({ uid, title, completed }: TodoInterface) {
        return new Promise<Result<TodoInterface[]>>((resolve) => {
          const sql = "UPDATE todo SET title = ?, completed = ? WHERE uid = ?";
          this.#todo.db.run(sql, [title, completed, uid], (error) => {
            if (error) resolve({ status: "error", error });
            resolve({ status: "ok", data: [{ uid, title, completed }] });
          });
        });
      }
    
      delete({ uid }: Pick<TodoInterface, "uid">) {
        return new Promise<Result<TodoInterface[]>>((resolve) => {
          const sql = "DELETE FROM todo WHERE uid = ?";
          this.#todo.db.run(sql, [uid], (error) => {
            if (error) resolve({ status: "error", error });
            resolve({ status: "ok", data: [] });
          });
        });
      }
    }

    Controller

    import { TodoModel } from "@/model";
    
    export class TodoController {
      #todo: TodoModel;
    
      constructor() {
        this.#todo = new TodoModel();
      }
    
      async read(params: Parameters<TodoModel["read"]>[0]) {
        return await this.#todo.read(params);
      }
    
      async create(params: Parameters<TodoModel["create"]>[0]) {
        return await this.#todo.create(params);
      }
    
      async update(param: Parameters<TodoModel["update"]>[0]) {
        return await this.#todo.update(param);
      }
    
      async delete(param: Parameters<TodoModel["delete"]>[0]) {
        return await this.#todo.delete(param);
      }
    }
    
    export const todoController = new TodoController();

    今回、HonoはAPIサーバーとして、JSONを返すものとします。ブラウザからAPIを叩くのでHonoのCORS Middlewareも入れておきます。

    import { serve } from "@hono/node-server";
    import { Hono } from "hono";
    import { cors } from "hono/cors";
    import { todoController } from "@/controller/todo-controller";
    
    const app = new Hono();
    
    app.use(
      "/api/*",
      cors({
        origin: ["http://localhost:8080", "https://web-shuta13.cloud.okteto.net"],
        allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
        allowHeaders: ["Content-Type", "Authorization"],
      })
    );
    
    app.get("/", (c) => {
      return c.text("Health OK");
    });
    
    app.get("/api/todo", async (c) => {
      const todos = await todoController.read({});
      if (todos.status === "ok") {
        return c.json(todos.data);
      } else {
        c.status(500);
        return c.json(todos.error);
      }
    });
    
    app.post("/api/todo", async (c) => {
      const data = await c.req.json();
      const uid = crypto.randomUUID();
      const result = await todoController.create({
        uid,
        ...data,
      });
      if (result.status === "ok") {
        return c.json(result.data);
      } else {
        c.status(500);
        return c.json(result.error);
      }
    });
    
    app.put("/api/todo/:uid", async (c) => {
      const uid = c.req.param("uid");
      const data = await c.req.json();
      const result = await todoController.update({ uid, ...data });
      if (result.status === "ok") {
        return c.json(result.data);
      } else {
        c.status(500);
        return c.json(result.error);
      }
    });
    
    app.delete("/api/todo/:uid", async (c) => {
      const uid = c.req.param("uid");
      const result = await todoController.delete({ uid });
      if (result.status === "ok") {
        return c.json(result.data);
      } else {
        c.status(500);
        return c.json(result.error);
      }
    });
    
    serve(app, (info) => {
      console.log(`Api server is running on ${info.port}`);
    });

    こういうライブラリでCRUD書いて試すときに、Todoアプリだと書きやすので本当に例題として優れていますね。

    Web

    vite の react-ts のテンプレから生成したものをほぼそのまま使います。

    web/
    ├── Dockerfile
    ├── dist/
    ├── index.html
    ├── k8s_web.yaml
    ├── package.json
    ├── public/
    ├── server.js
    ├── src
    │   ├── components
    │   │   └── App.tsx
    │   ├── main.tsx
    │   └── vite-env.d.ts
    ├── tsconfig.json
    ├── tsconfig.node.json
    └── vite.config.ts

    App.tsx にTodoリストと、Todo作成用のフォームを追加します。

    import { useState, useEffect } from "react";
    import { clsx } from "clsx";
    
    interface Todo {
      uid: string;
      title: string;
      completed: 0 | 1;
    }
    
    const API_URL = import.meta.env.PROD
      ? "https://api-shuta13.cloud.okteto.net"
      : "http://localhost:3000";
    
    function App() {
      const [todos, setTodos] = useState<Todo[]>([]);
      const [title, setTitle] = useState("");
    
      useEffect(() => {
        fetch(API_URL + "/api/todo")
          .then((res) => res.json())
          .then((data) => setTodos(data));
      }, []);
    
      return (
        <div className="space-y-4 mx-auto max-w-[768px] px-4">
          <h1 className="text-2xl font-bold">hono-docker-k8s-example</h1>
          <div className="space-y-2">
            <h2 className="text-xl font-bold">Todo</h2>
            <ul className="space-y-2">
              {todos.map((todo) => (
                <li
                  key={todo.uid}
                  className="flex place-items-center place-content-between bg-gray-100 p-2 rounded-md"
                >
                  <div className="flex gap-x-2">
                    <input
                      type="checkbox"
                      checked={todo.completed === 1}
                      onChange={async () => {
                        await fetch(API_URL + "/api/todo/" + todo.uid, {
                          method: "PUT",
                          body: JSON.stringify({
                            title: todo.title,
                            completed: todo.completed === 1 ? 0 : 1,
                          }),
                        });
                        const res = await fetch(API_URL + "/api/todo");
                        const data = await res.json();
                        setTodos(data);
                      }}
                    />
                    <span
                      className={clsx({
                        "line-through": todo.completed === 1,
                      })}
                    >
                      {todo.title}
                    </span>
                  </div>
                  <button
                    type="button"
                    onClick={async () => {
                      await fetch(API_URL + "/api/todo/" + todo.uid, {
                        method: "DELETE",
                      });
                      const res = await fetch(API_URL + "/api/todo");
                      const data = await res.json();
                      setTodos(data);
                    }}
                  >
                    Delete
                  </button>
                </li>
              ))}
            </ul>
          </div>
          <div className="space-y-2">
            <h2 className="text-xl font-bold">Add a new task</h2>
            <form
              className="space-y-2"
              onSubmit={async (e) => {
                e.preventDefault();
                await fetch(API_URL + "/api/todo", {
                  method: "POST",
                  body: JSON.stringify({
                    title,
                    completed: 0,
                  }),
                });
                setTitle("");
                const res = await fetch(API_URL + "/api/todo");
                const data = await res.json();
                setTodos(data);
              }}
            >
              <label htmlFor="title">Type a title</label>
              <input
                name="title"
                type="text"
                className="border w-full px-2"
                onChange={(e) => {
                  setTitle(e.target.value);
                }}
                value={title}
              />
              <div className="flex place-content-end">
                <button type="submit" className="bg-green-200 px-4 py-1">
                  Add
                </button>
              </div>
            </form>
          </div>
        </div>
      );
    }
    
    export default App;

    ちなみに Web の production server は Hono で立ち上げています。 server.js がこれの実装です。

    "use strict";
    
    import { Hono } from "hono";
    import { serve } from "@hono/node-server";
    import { serveStatic } from "@hono/node-server/serve-static";
    
    const app = new Hono();
    
    app.use("/assets/*", serveStatic({ root: "./dist" }));
    app.use("/vite.svg", serveStatic({ path: "./dist/vite.svg" }));
    app.use("/", serveStatic({ path: "./dist/index.html" }));
    
    serve({ fetch: app.fetch, port: 8080 }, (info) => {
      console.log(`Web server is running on ${info.port}`);
    });
    Honoでのフロントエンド関連の話

    今回、ReactでSSRはしていません。全部CSRです。

    ただ、Reactだと事前にビルドする手間があるので、Hono x htmxの事例を見つつ検討してみたり、esm.sh を使って script に埋め込む方法も検討しました。

    が、結局 vite でバンドルする形に落ち着いてます。

    Hono使うにあたってここらへんは何が最適なんでしょうかね…

    k8sでwebとapiのpod分けず、honoから直接 HTML 返すのはアリだと思いますが、フロントの規模がデカくなると大変になりそうです。

    JSX Middlewareは Hydration までカバーしないので、そのままReactを使っていくのは少し手間がかかりそうです。

    なのでHonoでReactのSSRする場合、Next.jsでも良いかなーとは思います。サンプルもあって良さそうです。

    デプロイしてみる

    Dockerfileとk8sのマニフェストを書いて、これまでに実装した API と Web をそれぞれpodで動かしてみます。

    今回はk8sを用いた開発やデプロイ簡略化のために、Oktetoというサービスを使っています。

    Oktetoについて

    Oktetoについて軽く紹介します(別に回し者ではないです)

    OktetoとはSaaS、あるいはKaaS(Kubernetes as a Service)の一種です。

    k8sにあまり慣れてなくても扱いやすく、無料枠の制限がそこまで厳しくないので個人開発や零細なサービス開発には向いているような気がします。

    …と、まあ個人サービス開発でk8s使いたいってなる場面が正直あまり想像出来ません。

    k8sの素振りで使う程度にはちょうど良いかなと思います。

    ただ、リクエストをしないとPodが起動しないので、初回のレスポンスは遅くなりがちです。

    また、 okteto up でリモートへのファイルのSyncが動かないことが多く、変更は手動かDeploy Pipelineで反映する形が安定しそうです。

    SyncはIOの監視の上限を上げればどうにかなるそうですが、筆者の環境では解消しませんでした(manifestのマウントの from:to も間違ってなかったので謎)

    詳細はドキュメントの他、日本語だと https://zenn.dev/aoi/articles/9ff83fe3c2e58d によくまとまっていたので、参考にしてみてください。

    Dockerfile

    HonoはNode.jsの環境があれば動くので、こんな感じで書けば良いです。

    FROM okteto.dev/node:18
    
    COPY package.json ./
    COPY package-lock.json ./
    COPY apps/api ./apps/api
    RUN npm ci .
    
    COPY . .
    
    RUN npm run build --prefix apps/api
    
    ENV PORT 3000
    EXPOSE 3000
    ENTRYPOINT ["npm", "run", "--prefix", "apps/api"]
    CMD ["start"]

    なお、nodeのイメージはoktetoのレジストリに上がっているものを使うので、ローカルで動かす際は適当なものに書き換えてください。

    Reactの向けのものは以下のようになります。ほぼ一緒です。

    FROM okteto.dev/node:18
    
    COPY package.json ./
    COPY package-lock.json ./
    COPY apps/web ./apps/web
    RUN npm ci .
    
    COPY . .
    
    RUN npm run build --prefix apps/web
    
    ENV PORT 8080
    EXPOSE 8080
    ENTRYPOINT ["npm", "run", "--prefix", "apps/web"]
    CMD ["start"]

    それぞれ、モノレポのWorkspaceのルートに配置しておきます。

    Manifests

    Oktetoとk8sのmanifestを書いてみます。

    今回は、API向けの k8s_api.yaml とWeb向けの k8s_web.yaml に分けて、Okteto CLIでそれぞれを kubectl apply -f で実行してデプロイします。

    Web 向けの k8s manifest は以下のようになります。

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: web
    spec:
      selector:
        matchLabels:
          app: web
      template:
        metadata:
          labels:
            app: web
        spec:
          containers:
            - image: okteto.dev/node:18
              name: web
              command: ["npm", "run", "--prefix", "apps/web", "start"]
    
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: web
    spec:
      type: ClusterIP
      ports:
        - protocol: TCP
          name: "web"
          port: 8080
      selector:
        app: web
    
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: web
      annotations:
        dev.okteto.com/generate-host: web
    spec:
      rules:
        - http:
            paths:
              - backend:
                  service:
                    name: web
                    port:
                      number: 8080
                path: /
                pathType: ImplementationSpecific

    ちなみにIngressは別に書かなくても Okteto 側で適当なものを割り当て られます。

    API 向けの k8s manifest は以下のようになります。

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: api
    spec:
      selector:
        matchLabels:
          app: api
      template:
        metadata:
          labels:
            app: api
        spec:
          containers:
            - image: okteto.dev/node:18
              name: api
              command: ["npm", "run", "--prefix", "apps/api", "start"]
    
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: api
    spec:
      type: ClusterIP
      ports:
        - protocol: TCP
          name: "api"
          port: 3000
      selector:
        app: api
    
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: api
      annotations:
        dev.okteto.com/generate-host: api
        nginx.ingress.kubernetes.io/enable-cors: "true"
        nginx.ingress.kubernetes.io/cors-allow-origin: "https://web-shuta13.cloud.okteto.net"
        nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
        nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type,Authorization"
    spec:
      rules:
        - http:
            paths:
              - backend:
                  service:
                    name: api
                    port:
                      number: 3000
                path: /
                pathType: ImplementationSpecific

    ほぼWebと変わりませんが、Ingressの部分でCORS向けの設定を追加しています。

    Hono側でCORSは設定しているのですが、これを追記しないと web の k8s のサービス名のオリジン以外は全部弾かれます。

    次にOkteto manifestです。

    build:
      api:
        image: okteto.dev/node:18
        context: .
        dockerfile: apps/api/Dockerfile
      web:
        image: okteto.dev/node:18
        context: .
        dockerfile: apps/web/Dockerfile
    deploy:
      - kubectl apply -f ./apps/web/k8s_web.yaml
      - kubectl apply -f ./apps/api/k8s_api.yaml
    dev:
      api:
        command: bash
        forward:
          - 3000:3000
        sync:
          - ./apps/api:/apps/api
      web:
        command: bash
        forward:
          - 8080:8080
        sync:
          - ./apps/web:/apps/web

    一応 dev も書いてます。

    (これでローカルでpod間の通信を見たいときはデプロイ後に okteto up <サービス名> で、podを立ち上げて確認できます)

    では、イメージをビルドし、Okteto CloudにデプロイしてPod上でアプリを動かしてみます。

    Okteto CLI をインストールし、以下でOkteto Cloudにデプロイします。

    okteto login
    okteto deploy --build

    Okteto CloudにAPIとWebでそれぞれpodが起動しています。

    Web の URL を開くと、アプリも動いてていい感じですね

    おわりに

    Dockerとk8s、KaaSを使用してHono製のアプリを開発してみました。

    あまりHonoには触れたことがなかったんですが、Expressを書く感覚で書け、エコシステムやサンプルも充実しており、ビギナーにも優しい印象でした。

    基本Honoを使う際は、サーバーレスで事足りるとは思いますが、コンテナやk8sが必要になった場面で何かの参考になれば幸いです。

    今回の記事に登場したソースコードはこちらからご覧になれます:https://github.com/shuta13/hono-docker-k8s-example

    ご精読ありがとうございました。

    次回は4日目 @watany さんの「helmetっぽいミドルウェアの話」 です!

    この記事を共有する