Skip to content

Instantly share code, notes, and snippets.

@podhmo
Last active June 16, 2025 23:32
Show Gist options
  • Save podhmo/9b3c3c583b34d12ab0ab01ba6c1c4fa3 to your computer and use it in GitHub Desktop.
Save podhmo/9b3c3c583b34d12ab0ab01ba6c1c4fa3 to your computer and use it in GitHub Desktop.
ocamlでopeapiのあれそれにはdreamというのがあるらしい

対話履歴

ユーザー: ocamlでopeapiに対応したweb api serverの例を教えてください。代替があれば後に補足情報として教えてください。


AI: OCamlでOpenAPIに対応したWeb APIサーバーを構築する例として、Toapi ライブラリと Dream Webフレームワークを組み合わせる方法を紹介します。このアプローチは「コードファースト」であり、OCamlの型定義からOpenAPI仕様を生成します。

推奨アプローチ: Toapi + Dream

  • Toapi: OCamlの型定義とルート定義からOpenAPI 3.0の仕様(JSON形式)を生成するライブラリです。
  • Dream: OCamlのためのモダンで使いやすいWebフレームワークです。

この組み合わせにより、OCamlで型安全なAPIを記述し、その定義からAPIドキュメントを自動生成できます。

1. プロジェクトのセットアップ

まず、dune プロジェクトを作成し、必要なライブラリを dune ファイルに追加します。

(executable
 (name main)
 (modules main user) ; userモジュールを追加
 (libraries dream toapi yojson ppx_yojson_conv_lib)
 (preprocess (pps ppx_yojson_conv)))

2. 型定義 (user.ml)

APIで扱うデータの型を定義します。ここでは例として User 型を定義します。 ppx_yojson_conv を使ってJSONシリアライズ/デシリアライズ用の関数を自動生成します。

(* user.ml *)
type t = {
  id : int;
  name : string;
  email : string option; (* optional field *)
} [@@deriving yojson, show] (* show for debugging *)

(* Constructor for convenience *)
let make ~id ~name ~email = { id; name; email }

(* For OpenAPI schema generation, Toapi needs explicit yojson converters *)
let to_yojson = yojson_of_t
let of_yojson = t_of_yojson

3. ToapiによるAPI仕様定義 (main.ml の一部)

Toapi を使ってAPIのエンドポイント、リクエスト/レスポンスのスキーマなどを定義します。

(* main.mlの一部 *)
module Api_spec = struct
  open Toapi.Service
  open Toapi.Path
  open Toapi.Method
  open Toapi.Endpoint
  open Toapi.Schema

  (* Userスキーマ定義 *)
  (* 1. 'objekt_representation' を作成 *)
  let user_schema_repr : User.t Schema.objekt_representation =
    Schema.objekt_of_yojson User.to_yojson User.of_yojson (* Use functions from User module *)
      ~example:(User.make ~id:1 ~name:"Example User" ~email:(Some "[email protected]"))
      ()
  (* 2. 'objekt_representation' から 'Schema.t' を作成 *)
  let user_schema : User.t Schema.t = Schema.objekt user_schema_repr

  (* Userのリストのスキーマ *)
  let user_list_schema : User.t list Schema.t = Schema.array user_schema

  (* パスパラメータ定義 *)
  let user_id_param = Param.int ~name:"user_id" ~description:"User ID" In_path

  (* エンドポイント定義 *)
  let list_users_endpoint =
    Endpoint.make
      ~path:(root / "users")
      ~meth:Get
      ~summary:"List all users"
      ~responses:[
        Response.make ~status:200 ~description:"A list of users" (Content.json user_list_schema);
      ]
      ()

  let get_user_endpoint =
    Endpoint.make
      ~path:(root / "users" /: user_id_param)
      ~meth:Get
      ~summary:"Get a user by ID"
      ~responses:[
        Response.make ~status:200 ~description:"User found" (Content.json user_schema);
        Response.make ~status:404 ~description:"User not found" (Content.json Schema.string_s); (* string_s は事前定義された文字列スキーマ *)
      ]
      ()

  let create_user_endpoint =
    Endpoint.make
      ~path:(root / "users")
      ~meth:Post
      ~summary:"Create a new user"
      ~request_body:(Request_body.make (Content.json user_schema))
      (* 注意: この例では作成時もUser.t全体を送る想定。
         実際にはIDなしの型(例: User_payload.t)を定義することが多い。*)
      ~responses:[
        Response.make ~status:201 ~description:"User created" (Content.json user_schema);
        Response.make ~status:400 ~description:"Invalid input" (Content.json Schema.string_s);
      ]
      ()

  (* サービス全体定義 *)
  let service_spec =
    Service.make
      ~title:"User API"
      ~version:"1.0.0"
      ~endpoints:[list_users_endpoint; get_user_endpoint; create_user_endpoint]
      ()
end

4. DreamによるWebサーバー実装 (main.ml の残り)

Dream を使ってHTTPサーバーを起動し、定義したAPI仕様に対応するハンドラを実装します。 /openapi.json エンドポイントで生成されたOpenAPI仕様を公開します。

(* main.ml の続き *)

(* インメモリのダミーデータベース *)
let users_db : User.t list ref = ref [
  User.make ~id:1 ~name:"Alice" ~email:(Some "[email protected]");
  User.make ~id:2 ~name:"Bob" ~email:None;
]
let next_id = ref 3

(* JSONレスポンスを返すヘルパー *)
let json_response status data =
  data |> Yojson.Safe.to_string |> Dream.json ~status

let () =
  Dream.run ~interface:"0.0.0.0" ~port:8080 (* すべてのインターフェースでリッスン *)
  @@ Dream.logger
  @@ Dream.router [

    (* OpenAPI 仕様を公開するエンドポイント *)
    Dream.get "/openapi.json" (fun _ ->
      Api_spec.service_spec
      |> Toapi.Service.to_openapi_json
      |> Yojson.Safe.to_string
      |> Dream.json
    );

    (* GET /users - 全ユーザーリストを取得 *)
    Dream.get "/users" (fun _ ->
      !users_db
      |> List.map User.to_yojson (* 各User.tをYojson.Safe.tに変換 *)
      |> (fun user_jsons -> `List user_jsons) (* Yojson.Safe.t listを `List (...) に変換 *)
      |> json_response `OK
    );

    (* GET /users/:user_id - IDでユーザーを取得 *)
    Dream.get "/users/:user_id" (fun request ->
      try
        let user_id = Dream.param request "user_id" |> int_of_string in
        match List.find_opt (fun (u:User.t) -> u.id = user_id) !users_db with
        | Some user -> User.to_yojson user |> json_response `OK
        | None -> `String "User not found" |> json_response `Not_Found
      with Failure _ (* int_of_string が失敗した場合 *) ->
        `String "Invalid user ID format" |> json_response `Bad_Request
    );

    (* POST /users - 新規ユーザー作成 *)
    Dream.post "/users" (fun request ->
      match%lwt Dream.body request with
      | body_str ->
        (try
          let user_json = Yojson.Safe.from_string body_str in
          match User.of_yojson user_json with
          | Ok partial_user_data ->
             (* IDはサーバー側で採番。リクエストボディのIDは無視するか、
                そもそもIDなしの型でリクエストボディを定義する方が良い。
                ここでは簡単のため、受信したnameとemailで新しいUser.tを作成。*)
             let new_user = User.make ~id:!next_id ~name:partial_user_data.name ~email:partial_user_data.email in
             incr next_id;
             users_db := new_user :: !users_db;
             User.to_yojson new_user |> json_response `Created
          | Error s -> `String ("Invalid user data: " ^ s) |> json_response `Bad_Request
        with
        | Yojson.Json_error msg -> `String ("JSON parsing error: " ^ msg) |> json_response `Bad_Request
        | ex -> `String ("Invalid request body: " ^ Printexc.to_string ex) |> json_response `Bad_Request
        )
      | exception _ -> `String "Failed to read request body" |> json_response `Internal_Server_Error
    );

  ]
  @@ Dream.not_found (* マッチしないルートは404 *)

5. ビルドと実行

dune build
./_build/default/main.exe

サーバーが起動したら、以下のURLにアクセスできます:

  • http://localhost:8080/openapi.json: OpenAPI仕様 (JSON形式)
  • http://localhost:8080/users: ユーザーリスト (GET)
  • http://localhost:8080/users/1: ID 1のユーザー (GET)
  • Swagger UIやRedocなどのツールに http://localhost:8080/openapi.json を読み込ませることで、APIドキュメントを閲覧・試行できます。

バリデーションについて

Toapi は主にOpenAPI仕様の生成を行いますが、実行時のリクエスト/レスポンスのバリデーション機能は直接提供していません。 仕様に基づいたバリデーションを行いたい場合は、ocaml-openapi のようなライブラリと組み合わせて、Dreamのミドルウェアとしてバリデーション層を自作するなどの対応が必要になります。 現状では、型システムと ppx_yojson_conv によるデシリアライズ時の型チェックがある程度のバリデーションの役割を果たします。

代替案 (補足情報)

  1. Tyba (仕様ファースト)

    • OpenAPI仕様ファイル (YAML/JSON) を先に記述します。
    • Tyba ツール (tyba openapi ...) を使って、仕様ファイルからOCamlの型定義と(de)serializer (yojson 対応)を生成します。
    • 生成された型定義を DreamOpium などのWebフレームワークで利用してAPIを実装します。
    • メリット: 仕様がマスターとなり、複数言語でAPIを開発する際などに一貫性を保ちやすいです。
    • デメリット: Tyba の学習コストや、生成されたコードのカスタマイズに制約がある場合があります。
  2. ocaml-openapi を直接利用

    • ocaml-openapi ライブラリはOpenAPI仕様のパーサーとバリデーターを提供します。
    • これを使って、手動でOpenAPI仕様を読み込み、リクエストやレスポンスが仕様に準拠しているかを検証するロジックをWebサーバーに組み込むことができます。
    • メリット: 非常に柔軟性が高いです。
    • デメリット: 実装の手間が大きく、定型的なコードが多くなりがちです。
  3. その他のWebフレームワーク

    • Opium: Dream 以前から存在し、実績のあるWebフレームワークです。ToapiTyba との連携も同様に可能です。

OCamlのWeb・OpenAPI関連のエコシステムは活発に開発が進んでいる分野ですので、新しいライブラリやツールが登場する可能性もあります。 上記の Toapi + Dream の組み合わせは、2023年現在のOCamlにおいて、コードファーストでOpenAPI対応サーバーを構築する一つの実践的な方法です。

(返答終了理由: STOP)


ユーザー: 特に知りたいのはフィールドにoneOfがある値の対応ですね。encode/decodeどちらの例も知りたいです


AI: OCamlでOpenAPIの oneOf に対応する値を扱う例を、ToapiDream を使って示します。oneOf は、ある値が複数の定義済みスキーマのいずれか一つに合致することを示すもので、OCamlではヴァリアント型(Sum Type)を使って表現するのが自然です。

ここでは、kind というフィールドを Discriminatorとして使用し、その値によって EventA または EventB のどちらの型であるかを判別するケースを考えます。

JSONの例:

  • EventA: {"kind": "event_a", "id": 123, "data": "some data for A"}
  • EventB: {"kind": "event_b", "timestamp": "2023-10-26T10:00:00Z", "value": 42}

1. 型定義とJSONコンバーター (event.ml)

まず、oneOf の各選択肢となる型と、それらをまとめるヴァリアント型を定義します。ppx_yojson_conv を利用しつつ、ヴァリアント型に対してはカスタムのJSONエンコーダー/デコーダーを実装します。

(* event.ml *)
type event_a = {
  kind: string; (* Should be "event_a" *)
  id: int;
  data: string;
} [@@deriving yojson { strict = false }, show] (* strict = false を使うと kind もJSONに含められる *)

type event_b = {
  kind: string; (* Should be "event_b" *)
  timestamp: string; (* ISO8601 format *)
  value: int;
} [@@deriving yojson { strict = false }, show]

type t =
  | EventA of event_a
  | EventB of event_b
  [@@deriving show]

(* カスタムエンコーダー: OCamlの event -> JSON *)
let to_yojson = function
  | EventA ea -> event_a_to_yojson { ea with kind = "event_a" } (* kindを確実に設定 *)
  | EventB eb -> event_b_to_yojson { eb with kind = "event_b" } (* kindを確実に設定 *)

(* カスタムデコーダー: JSON -> OCamlの event *)
let of_yojson (json : Yojson.Safe.t) : (t, string) result =
  match json with
  | `Assoc fields ->
    (match List.assoc_opt "kind" fields with
     | Some (`String "event_a") ->
       (match event_a_of_yojson json with
        | Ok ea -> Ok (EventA ea)
        | Error err -> Error (Printf.sprintf "Failed to parse as EventA: %s. JSON: %s" err (Yojson.Safe.to_string json)))
     | Some (`String "event_b") ->
       (match event_b_of_yojson json with
        | Ok eb -> Ok (EventB eb)
        | Error err -> Error (Printf.sprintf "Failed to parse as EventB: %s. JSON: %s" err (Yojson.Safe.to_string json)))
     | Some other_kind -> Error (Printf.sprintf "Unknown event kind: %s" (Yojson.Safe.to_string other_kind))
     | None -> Error "'kind' field is missing for discriminating event type")
  | _ -> Error "Expected a JSON object for event type"

(* Toapiのスキーマ生成のために、型名を明示的に提供する場合がある *)
let type_name = "Event"

注意点:

  • event_aevent_b の定義で kind: string を含めていますが、to_yojson でエンコード時に正しい kind の値を設定し、of_yojson でデコード時にこの kind を参照しています。
  • [@@deriving yojson { strict = false }] は、レコードに定義されていないフィールドがJSONにあってもエラーにしないオプションですが、ここでは主に kind フィールドをレコード定義とJSON表現の両方に含めるために使用しています(ppx_yojson_conv の挙動に依存)。

2. dune ファイルの更新

event モジュールを dune ファイルに追加します。

(executable
 (name main)
 (modules main user event) ; eventモジュールを追加
 (libraries dream toapi yojson ppx_yojson_conv_lib)
 (preprocess (pps ppx_yojson_conv)))

3. ToapiによるAPI仕様定義 (main.ml の一部)

Toapi.Schema.make_custom を使用して oneOfdiscriminator を持つOpenAPIスキーマを精密に定義します。

(* main.ml の Api_spec モジュール内 *)
module Api_spec = struct
  open Toapi.Service
  open Toapi.Path
  open Toapi.Method
  open Toapi.Endpoint
  open Toapi.Schema
  open Toapi.Openapi.Json_schema (* For direct manipulation of OpenAPI schema parts *)

  (* Userスキーマ定義 (前回の例から) *)
  let user_schema_repr : User.t Schema.objekt_representation =
    Schema.objekt_of_yojson User.to_yojson User.of_yojson
      ~example:(User.make ~id:1 ~name:"Example User" ~email:(Some "[email protected]"))
      ()
  let user_schema : User.t Schema.t = Schema.objekt ~name:"User" user_schema_repr (* name を指定 *)
  let user_list_schema : User.t list Schema.t = Schema.array user_schema


  (* EventA スキーマ定義 *)
  let event_a_schema_repr : Event.event_a Schema.objekt_representation =
    objekt_of_yojson Event.event_a_to_yojson Event.event_a_of_yojson
      ~example:{Event.kind="event_a"; id=1; data="Sample Event A data"}
      ()
  let event_a_schema : Event.event_a Schema.t =
    objekt ~name:"EventA" ~description:"Schema for Event Type A" event_a_schema_repr

  (* EventB スキーマ定義 *)
  let event_b_schema_repr : Event.event_b Schema.objekt_representation =
    objekt_of_yojson Event.event_b_to_yojson Event.event_b_of_yojson
      ~example:{Event.kind="event_b"; timestamp="2023-01-01T00:00:00Z"; value=100}
      ()
  let event_b_schema : Event.event_b Schema.t =
    objekt ~name:"EventB" ~description:"Schema for Event Type B" event_b_schema_repr

  (* Event (oneOf) スキーマ定義 *)
  let event_schema : Event.t Schema.t =
    let event_a_schema_ref = to_openapi_schema_ref event_a_schema in
    let event_b_schema_ref = to_openapi_schema_ref event_b_schema in

    (* Discriminatorの定義 *)
    (* マッピングのキーはdiscriminatorのプロパティの値、値は対応するスキーマへの参照 *)
    let mapping = [
      ("event_a", Schema_reference.make ~ref:(Ref.v ("#/components/schemas/" ^ Option.get (Schema.name event_a_schema))) ());
      ("event_b", Schema_reference.make ~ref:(Ref.v ("#/components/schemas/" ^ Option.get (Schema.name event_b_schema))) ());
    ] in
    let oapi_discriminator = Discriminator.make ~property_name:"kind" ~mapping () in

    (* OpenAPIの oneOf スキーマプリミティブを構築 *)
    let oapi_oneof_schema_prim =
      Openapi_schema.make (* This is Toapi.Openapi.Json_schema.Schema.make *)
        ~one_of:[
          `Schema_ref event_a_schema_ref;
          `Schema_ref event_b_schema_ref
        ]
        ~discriminator:oapi_discriminator
        ()
    in
    make_custom
      ~name:Event.type_name (* スキーマ名を "Event" にする *)
      ~description:"Represents either EventA or EventB, discriminated by the 'kind' field."
      ~example:(Event.EventA {Event.kind="event_a"; id=0; data="example A"})
      Event.to_yojson (* カスタムエンコーダー *)
      Event.of_yojson (* カスタムデコーダー *)
      oapi_oneof_schema_prim (* 手動で構築したOpenAPIスキーマ構造 *)


  (* エンドポイント定義 (既存のものに追加) *)
  let process_event_endpoint =
    Endpoint.make
      ~path:(root / "events")
      ~meth:Post
      ~summary:"Process an event (either EventA or EventB)"
      ~request_body:(Request_body.make (Content.json event_schema))
      ~responses:[
        Response.make ~status:200 ~description:"Event processed successfully" (Content.json event_schema);
        Response.make ~status:400 ~description:"Invalid event data" (Content.json string_s);
      ]
      ()

  let service_spec =
    Service.make
      ~title:"User and Event API"
      ~version:"1.1.0"
      ~endpoints:[
        list_users_endpoint;
        get_user_endpoint;
        create_user_endpoint;
        process_event_endpoint; (* 追加 *)
      ]
      ()
end

解説:

  • event_a_schemaevent_b_schemaSchema.objekt ~name:"..." ... で定義することで、OpenAPI仕様の #/components/schemas/EventA のように名前付きコンポーネントとして登録されます。
  • event_schema の定義では Toapi.Schema.make_custom を使用します。
    • event_a_schemaevent_b_schema からOpenAPIスキーマ参照 (event_a_schema_ref, event_b_schema_ref) を取得します。
    • Discriminator.makediscriminator オブジェクトを構築します。property_name に識別子となるフィールド名 ("kind") を指定し、mappingkind の値と対応するスキーマ参照を関連付けます。
    • Toapi.Openapi.Json_schema.Schema.make を使って、oneOfdiscriminator を含むOpenAPIのプリミティブなスキーマ構造 (oapi_oneof_schema_prim) を手動で構築します。
    • make_custom に、この oapi_oneof_schema_prim と、Event.t 型用のカスタムエンコーダー (Event.to_yojson)・デコーダー (Event.of_yojson) を渡します。

4. DreamによるWebサーバー実装 (main.ml の残り)

oneOf を含む Event.t 型を扱うエンドポイントのハンドラを実装します。

(* main.ml の Dream サーバー部分に追加 *)

    (* POST /events - イベントを処理 (oneOfの例) *)
    Dream.post "/events" (fun request ->
      match%lwt Dream.body request with
      | body_str ->
        (try
          let event_json = Yojson.Safe.from_string body_str in
          match Event.of_yojson event_json with (* カスタムデコーダーを使用 *)
          | Ok event_data ->
            (* ここで event_data (Event.t 型) を処理 *)
            let response_message =
              match event_data with
              | Event.EventA ea -> Printf.sprintf "Processed EventA with id %d. Data: %s" ea.id ea.data
              | Event.EventB eb -> Printf.sprintf "Processed EventB at %s with value %d" eb.timestamp eb.value
            in
            Logs.info (fun m -> m "Received and processed event: %s" (Event.show event_data));
            (* 受け取ったイベントをそのまま返す例 *)
            Event.to_yojson event_data |> json_response `OK
          | Error s ->
            Logs.err (fun m -> m "Invalid event data: %s. Body: %s" s body_str);
            `String ("Invalid event data: " ^ s) |> json_response `Bad_Request
        with
        | Yojson.Json_error msg ->
          Logs.err (fun m -> m "JSON parsing error: %s. Body: %s" msg body_str);
          `String ("JSON parsing error: " ^ msg) |> json_response `Bad_Request
        | ex ->
          Logs.err (fun m -> m "Unknown error processing event: %s. Body: %s" (Printexc.to_string ex) body_str);
          `String ("Invalid request body: " ^ Printexc.to_string ex) |> json_response `Bad_Request
        )
      | exception _ ->
        Logs.err (fun m -> m "Failed to read request body for /events");
        `String "Failed to read request body" |> json_response `Internal_Server_Error
    );

解説:

  • リクエストボディを Event.of_yojson を使ってデコードします。これにより、JSONの内容(特に kind フィールド)に基づいて Event.EventA または Event.EventB のどちらかのヴァリアントケースに正しく変換されます。
  • レスポンスを返す際には Event.to_yojson を使ってエンコードします。これにより、ヴァリアントケースに応じた正しいJSON構造(kind フィールドを含む)が生成されます。

5. ビルドと実行

dune build
./_build/default/main.exe

サーバー起動後、/openapi.json を確認すると、Event スキーマが oneOfdiscriminator を使って定義されていることが確認できます。 Swagger UIなどでこの仕様を読み込むと、Event 型のリクエストボディとして EventA または EventB のどちらかを選択して入力できるようになっているはずです。

テスト (例: curl):

  • EventAを送信:

    curl -X POST -H "Content-Type: application/json" \
         -d '{"kind": "event_a", "id": 789, "data": "Test data for A from curl"}' \
         http://localhost:8080/events

    サーバーログやレスポンスで EventA として処理されたことを確認。

  • EventBを送信:

    curl -X POST -H "Content-Type: application/json" \
         -d '{"kind": "event_b", "timestamp": "2024-03-15T12:00:00Z", "value": 999}' \
         http://localhost:8080/events

    サーバーログやレスポンスで EventB として処理されたことを確認。

  • 不正なkind:

    curl -X POST -H "Content-Type: application/json" \
         -d '{"kind": "event_c", "other_data": "unknown"}' \
         http://localhost:8080/events

    400 Bad Requestが返ってくることを確認。

この例により、OCamlのヴァリアント型、カスタムYojsonコンバーター、そして Toapi の機能を組み合わせて、OpenAPIの oneOfdiscriminator に対応した型安全なAPIを構築する方法が示されました。エンコード・デコードの両方でカスタムロジックが中心的な役割を果たします。

(返答終了理由: STOP)


ユーザー: ちなみにAWSのECSやgoogle cloudのcloud runにデプロイする用のコンテナの作成はどうなりますか?


AI: OCamlで作成したWeb APIサーバー(例: Dream ベースのサーバー)をAWS ECSやGoogle Cloud Runにデプロイするためのコンテナイメージ作成方法について説明します。主なステップは以下の通りです。

  1. Dockerfileの作成: アプリケーションをビルドし、実行環境を整えるための指示書です。
  2. コンテナイメージのビルド: Dockerfileを元にイメージを作成します。
  3. コンテナレジストリへのプッシュ: 作成したイメージをAWS ECRやGoogle Artifact Registryなどにアップロードします。
  4. ECS/Cloud Runでのデプロイ: レジストリ上のイメージを指定してサービスをデプロイします。

ここでは、Dockerfileの作成 に焦点を当てて説明します。イメージサイズを小さくし、セキュリティを高めるためにマルチステージビルドを利用するのが一般的です。

Dockerfileの例 (マルチステージビルド)

このDockerfileは、ビルド環境とランタイム環境を分離します。

  • builder ステージ: OCamlのビルドツールチェーンを含み、アプリケーションをコンパイルします。
  • runner ステージ: ビルドされた実行ファイルのみを軽量なベースイメージにコピーし、実行します。
#-------------------------------------------------------------------------------
# Stage 1: Builder
#
# このステージではOCamlのツールチェーンを使ってアプリケーションをビルドします。
# ベースイメージにはocaml/opamの公式イメージを使用すると便利です。
#-------------------------------------------------------------------------------
FROM ocaml/opam:debian-12-ocaml-5.1 AS builder
# 必要に応じてOCamlのバージョンやベースのDebianバージョンを調整してください。
# Alpineベース (例: ocaml/opam:alpine-ocaml-5.1) を使うと最終イメージをより小さくできる可能性がありますが、
# musl libc と glibc の違いに注意が必要です。

# アプリケーションのビルドに必要なシステムライブラリがあればインストールします。
# (例: Cバインディングに必要なライブラリなど)
# RUN sudo apt-get update && sudo apt-get install -y libpq-dev # PostgreSQLの場合の例

# opamユーザーに切り替えてパーミッション問題を回避
USER opam
WORKDIR /home/opam/app

# まずopam関連ファイルとdune-projectをコピーして依存関係をインストールします。
# これにより、ソースコードの変更時にも依存関係のレイヤーキャッシュが効きやすくなります。
COPY --chown=opam:opam ./*.opam ./
COPY --chown=opam:opam dune-project ./
# プロジェクト自体に .opam ファイルがない場合は、次の行をコメントアウト/削除し、
# 代わりに `opam pin add -n . . --yes` を使うことを検討してください。
RUN opam install . --deps-only --with-test --yes # プロジェクトの依存関係をインストール

# プロジェクトのソースコード全体をコピー
COPY --chown=opam:opam . .

# アプリケーションをビルド (リリースモードで最適化)
RUN opam exec -- dune build --profile=release

#-------------------------------------------------------------------------------
# Stage 2: Runner
#
# このステージでは、ビルドされた実行ファイルを軽量なベースイメージにコピーして
# 実行環境を構築します。
#-------------------------------------------------------------------------------
FROM debian:12-slim AS runner
# Alpine Linuxを使う場合: FROM alpine:latest
# Alpineの場合、一部のOCamlライブラリが必要とする共有ライブラリ (例: libev, zlib) を
# apk add でインストールする必要があるかもしれません。
# RUN apk add --no-cache libev zlib openssl # AlpineでDreamが依存する可能性のあるライブラリの例

WORKDIR /app

# アプリケーション実行用の非rootユーザーを作成
# Debian/Ubuntuの場合:
RUN groupadd -r appgroup && useradd --no-log-init -r -g appgroup appuser
# Alpineの場合:
# RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# builderステージからコンパイル済み実行ファイルをコピー
COPY --from=builder /home/opam/app/_build/default/main.exe /app/main.exe

# (オプション) 静的ファイルなど、実行に必要な他のアセットがあればコピー
# COPY --from=builder /home/opam/app/static /app/static

# ポートを指定 (アプリケーションがリッスンするポート)
# このポートはECSのタスク定義やCloud Runのサービス設定でマッピングされます。
EXPOSE 8080
# Cloud Runは環境変数 `PORT` を参照するため、アプリケーション側で対応すると良いでしょう。

# 実行ユーザーを切り替え
USER appuser

# コンテナ起動時に実行するコマンド
# /app/main.exe が前回の例の実行ファイル名だと仮定
CMD ["/app/main.exe"]

main.ml のポート設定の変更 (Cloud Run向け推奨)

Cloud Runは、アプリケーションがリッスンすべきポート番号を環境変数 PORT で渡します。Dream アプリケーションでこれを読み取るように変更すると良いでしょう。

(* main.ml の一部を修正 *)
let get_port () =
  try Sys.getenv "PORT" |> int_of_string
  with Not_found ->
    Logs.info (fun m -> m "PORT environment variable not set, defaulting to 8080.");
    8080 (* PORTが設定されていなければデフォルトで8080 *)

let () =
  Dream.initialize_log (); (* Dreamのロガーを初期化 (推奨) *)
  let port = get_port () in
  Dream.run ~interface:"0.0.0.0" ~port (* 取得したポートを使用 *)
  @@ Dream.logger
  @@ Dream.router [
    (* ... (既存のルート定義) ... *)

    (* ヘルスチェックエンドポイント (Cloud RunやECSのLBで利用) *)
    Dream.get "/health" (fun _ -> Dream.respond "OK");
  ]
  @@ Dream.not_found

ビルドとプッシュのコマンド例

  1. イメージをビルド: Dockerfileがあるディレクトリで以下のコマンドを実行します。

    • <your-image-name>: イメージ名 (例: my-ocaml-app)
    • <tag>: タグ (例: latest, v1.0)
    docker build -t <your-image-name>:<tag> .
  2. コンテナレジストリにログイン:

    • AWS ECR:
      aws ecr get-login-password --region <your-aws-region> | docker login --username AWS --password-stdin <your-aws-account-id>.dkr.ecr.<your-aws-region>.amazonaws.com
    • Google Artifact Registry:
      gcloud auth configure-docker <gcp-region>-docker.pkg.dev
  3. イメージにタグを付けてプッシュ:

    • AWS ECR:
      docker tag <your-image-name>:<tag> <your-aws-account-id>.dkr.ecr.<your-aws-region>.amazonaws.com/<your-ecr-repository-name>:<tag>
      docker push <your-aws-account-id>.dkr.ecr.<your-aws-region>.amazonaws.com/<your-ecr-repository-name>:<tag>
    • Google Artifact Registry:
      docker tag <your-image-name>:<tag> <gcp-region>-docker.pkg.dev/<your-gcp-project-id>/<your-artifact-repo-name>/<your-image-name>:<tag>
      docker push <gcp-region>-docker.pkg.dev/<your-gcp-project-id>/<your-artifact-repo-name>/<your-image-name>:<tag>

デプロイ時の考慮事項

  • ヘルスチェック: 上記Dockerfileの例や main.ml の修正例には /health エンドポイントが含まれています。ECSのタスク定義やCloud Runのサービス設定で、このパスをヘルスチェックURLとして指定します。
  • 環境変数: データベース接続情報など、設定を環境変数経由でアプリケーションに渡すことができます。
  • リソース割り当て: CPUやメモリを適切に割り当てます。
  • スケーリング: 必要に応じてオートスケーリングを設定します。
  • ログ: CloudWatch Logs (AWS) や Cloud Logging (Google Cloud) でアプリケーションログを確認できるように設定します。Dream.logger を使用していれば、標準出力/標準エラーに出力されたログが収集されます。

静的ビルドについて

OCamlの実行ファイルをできるだけ多くの依存ライブラリを含めて静的にリンクすることで、ランタイムイメージをさらに小さくできる可能性があります(特にAlpine Linuxベースの場合)。dune ファイルでリンカフラグを指定することで試みることができます。

(executable
  (name main)
  ; ... other fields
  (link_flags (-ccopt -static -cclib -static-libgcc))) ; glibc環境では完全な静的リンクが難しい場合あり

ただし、全てのケースでうまくいくとは限らず、libuv (Dreamが内部で利用) のようなCライブラリへの依存がある場合は複雑になることがあります。通常は、debian:slim のようなイメージに必要な共有ライブラリが含まれているため、動的リンクされた実行ファイルで問題ありません。

このDockerfileと手順をベースに、ご自身のプロジェクトに合わせて調整してください。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-05-06",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false
  },
  "systemInstruction": {}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment