📦

Cloudflare Containers を使ってみたメモ

に公開

Cloudflare Containers は、Cloudflare が新しく提供しているエッジネットワーク上のコンテナ実行基盤です。
現在はオープンベータの段階で、Workers Paid プランに加入していれば、追加料金なしで試すことができます。

Containers の仕組み

Cloudflare Containers では linux/amd64 向けのステートレスなコンテナを実行できます(と個人的には解釈しました)。これらのコンテナは Durable Objects にアタッチする形で管理され、SQL ストレージなどの他の機能を利用することによりステートフルな実行も可能です。

他のコンテナプラットフォームと違い、Containers はそのものが独立して動いているわけではなく、あくまでもマシンへのアクセスを提供するための API だと個人的には思っています。あくまでも発表直後の深夜にちょこっと使ってみてそう思っただけなので解釈違いかも知れません。

仕組みとしては以下のような流れです。

  • Docker イメージを Cloudflare 管理のレジストリ (registry.cloudflare.com) に push
  • CDN 経由で Cloudflare のエッジネットワーク全体にイメージが配布
  • リクエストを受けた Worker が、自身に最も近い場所でコンテナーを起動・実行

Durable Objects とは固定のデータロケーションでステートフルに実行できる Workers の永続化オブジェクトのことです。以下の投稿者さんの記事がわかりやすかったです。

https://zenn.dev/mizchi/articles/5130b02c5b490e4f871a

使ってみる

まずは CLI の create-cloudflare から公式のテンプレートを使ってプロジェクトを作成します。パッケージマネージャーとして pnpm を使用します。

pnpm create cloudflare --template cloudflare/templates/containers

そのままプロンプトに従って Would you like to deploy now?Yes を選ぶと、すぐにデプロイ出来ます。
デプロイ後、表示された <プロジェクト名>.<アカウント名>.workers.dev のような URL にアクセスすると動作を確認できます。テンプレートのままだと / ではコンテナへのアクセスは発生しませんので、/singleton にアクセスしてみます。

初めてコンテナをデプロイする際はプロビジョニングが終わるまで待つ必要がある模様です。ダッシュボードからステータスを確認できます。自分は1分も立たないうちに利用可能のステータスになりました。

There is no Container instance available at this time.
This is likely because you have reached your max concurrent instance count (set in wrangler config) or are you currently provisioning the Container.
If you are deploying your Container for the first time, check your dashboard to see provisioning status, this may take a few minutes.

テンプレートの Hono のルートでは env にある Durable Objects のネームスペースから、固有の名前の Durable Object を呼び出しています。

Durable Objects を理解していなくても、env.MY_CONTAINER には実行中のコンテナを管理する Key-Value のマップが入っている、と解釈することもできると思います。

const container = getContainer(c.env.MY_CONTAINER);
return await container.fetch(c.req.raw);

呼び出される Durable Object 自体は Container クラスを継承しており、内部で this.ctx.container を通じてアタッチされたコンテナに fetch() しています (このクラスについては後述)。

export default class extends Container<Env> {
    defaultPort = 80
}

...

export class Container<Env> extends DurableObject<Env> {
    async fetch(request: Request): Promise<Response> {
        return this.containerFetch(request);
    }
}

コンテナイメージについて

wrangler.jsonc では、以下のようにコンテナを構成できます。

{
    "containers": [
        {
            // コンテナがアタッチされる Durable Object のエントリーポイント。
            "class_name": "MyContainer",
            // コンテナが使用するイメージ。Dockerfile のパスを指定することもできます。
            // Docker Hub などの外部のイメージレジストリはまだ利用できない模様。
            "image": "registry.cloudflare.com/<scope>/<image-name>",
            // コンテナの名前。これは別に必要ではないと思われる。
            // デフォルトでは <worker-name>.<version>.<container> のような文字列になっていた
            "name": "name-of-the-container",
            // エッジ上で実行できるコンテナの数。これを超える数を Durable Object から this.ctx.container.start() しようとするとエラーになる。
            "max_instances": 2
        }
    ],
    
    "durable_objects": {
        "bindings": [
            {
                "class_name": "MyContainer",
                "name": "MY_CONTAINER"
            }
        ]
    }
}

現在は registry.cloudflare.com に push したイメージのみ指定することができます。
このレジストリは R2 (Cloudflare のオブジェクトストレージ) の上に構築されており、GitHub からソースコードを閲覧することができます(自分のWorkerでも実行可能)。

他のレジストリ (Docker Hub など) のイメージも、いったん docker pull してから Cloudflare に wrangler containers images push することで利用できます。

将来的には外部のイメージレジストリを直接指定できるようになり、それを registry.cloudflare.com に認証やなんやらした後にキャッシュするような仕組みを実装するようです。

Container クラスと this.ctx.container

Durable Object の this.ctxcontainer という新しいプロパティが追加されています。これにより、コンテナを起動したり、信号を送ったりといった制御が可能になります。

これらの制御は Workerd (Workers のランタイム) によって、Docker Engine の API を介して行われます。

// /containers/create?name=:内部名称
// /containers/:内部名称/start
// $ docker run
void this.ctx.container.start(); // コンテナを起動

// /containers/:内部名称/kill?signal=:文字列に変換された信号 (e.g. SIGTERM)
// $ docker container kill
void this.ctx.container.signal(); // コンテナに POSIX シグナルを送る

// /containers/:内部名称?force=true
// $ docker container rm --force
void this.ctx.container.destroy(); // コンテナを削除

// /containers/:内部名称/wait
// $ docker container wait
this.ctx.container.monitor() // コンテナが停止するまで待機
    .then((exitCode) => console.log(`Container exited with code: ${exitCode}`))
    .catch((reason) => console.log(`Container errored: ${reason}`));

// この時点では Docker Engine への通信は発生しない。
const port = this.ctx.container.getTcpPort(80); // :80 のポートを取得

// /containers/:内部名称/json
// $ docker container inspect --json | jq '.NetworkSettings.Ports['8080/tcp'].HostPort'
using response = await port.fetch('http://container.internal', {
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ 'greeting': 'Hello, world' })
}).catch((reason) => {
    if (reason instanceof Error) {
        if (reason.message.includes('Container port not found')) {
            // コンテナでポートが正常にマッピングされていないか、通常通りに起動していません。
            // 一度 Workers を介さずに docker run で Dockerfile を起動してみて正常に起動できるかどうか、又はポートが正常にマッピングされているか確認しましょう。
        } else if (reason.message.includes('Network connection lost')) {
            // マッピングされたポートへのアクセスは常に `localhost` にて発生します。
            // DevContainers で Docker-outside-of-Docker などを使ってホストのマシンからコンテナを作成していて、尚且つネットワークが断絶されている場合は注意が必要です。
        }
    }
    return reason;
});

これらのライフサイクル管理を自前で書くのはやや面倒なので、Cloudflare が提供している Container クラスを継承して使用すると、ある程度抽象化できます。
テンプレートでは既にこのクラスが使用されており、Cloudflare はこのクラスを使うことを推奨しているようです。

export default class extends Container<Env> {
    /**
     * コンテナの `fetch()` がデフォルトで使用するポート。
     * `containerFetch()` でポートを指定しながらリクエストを転送することもできます。
     */
    defaultPort = 80
    /**
     * コンテナの開始時に利用可能かどうか確認するポート。利用不可だとエラーになります。
     */
    requiredPorts = [8787]
    /**
     * コンテナが未使用の時にどれぐらい生かすか。
     * 数字に単位 's' (秒), 'm' (分), 'h' (時間) を指定します。
     * たぶん 1m20s のようにまとめて指定はできません。
     */
    sleepAfter = '10m' // `${number}${'m' | 'h' | 'd'}`
    /**
     * 手動で this.start() からコンテナを開始するかどうか。
     * デフォルトでは Durable Object の作成時に自動でコンテナが起動します。
     */
    manualStart = false
    
    // これらは this.ctx.container.start() に直接指定するプロパティ。
    
    /**
     * コンテナの起動時に設定する環境変数。
     */
    envVars = {
        'MY_ENV': (new Date()).toString()
    }
    /**
     * コンテナの起動時に実行するコマンド。
     */
    entrypoint = ['some-executable-path', '--that-needs-an-arg']
    /**
     * コンテナでインターネット接続を有効にする(IPアドレスを割り当てる)。要検証: containers.<プロジェクト名>.<アカウント名>.workers.dev からアクセスができるとかできないとか
     */
    enableInternet = true

    constructor(...[ctx, env]: ConstructorParameters<typeof Container>) {
        super(ctx, env, {
            // コンストラクタにプロパティを渡すこともできます。
            enableInternet: true
        });
    }
    
    /**
     * ライフサイクル: コンテナが開始した時。
     * `requiredPorts` が指定されている場合は利用可能になるまで待った後?
     */
    override async onStart(): Promise<void> {
        console.log('Container started');

        // ポートを指定してリクエストを転送
        using response = await this.containerFetch('http://container.internal:8787/', 8787);
        
        // コンテナを停止。これはあくまでどんな API が利用できるのかを試しているだけで、別に即座に停止する必要はありません。
        await this.stop();
    }
    /**
     * ライフサイクル: コンテナが停止した時。
     */
    override async onStop(): Promise<void> {
        console.log('Container shut down');
        console.assert(!this.ctx.container!.running);
    }
    /**
     * ライフサイクル: コンテナでエラーで落ちた時。
     * `this.destroy()` や起動に失敗した場合にエラーをキャッチできる。
     * 
     * @throws {any} このメソッドは空の try catch の中で実行されているのでエラーを投げても特に意味はない。
     */
    override async onError(error: unknown): Promise<void> {
        console.error('Container is throwing an error:' + error);
    }

    // これらは Container によって定義されているメソッド

    /**
     * コンテナにポートを指定してリクエストを転送する。
     * 
     * @param port 転送するポート。指定されていない場合は `this.defaultPort` を使用する。`this.defaultPort` も指定されていない場合はエラーが発生する。
     */
    containerFetch(request: Request, port: number | undefined = this.defaultPort): Promise<Response>
    /**
     * コンテナを手動で起動する。`this.manualStart` が有効になっている場合、コンテナは Durable Object の作成時に自動で起動します。既に起動していてもエラーは発生しません。
     */
    start(): Promise<void>
    /**
     * コンテナを手動で起動し、ポートが利用可能になるまで待つ。
     * 
     * @param ports 利用可能になるまで待つポート。指定されていない場合は `this.requiredPorts` を使用する。`this.defaultPort` と同じ感じで `this.requiredPorts` も指定されていない場合はエラーが発生する。
     */
    startAndWaitForPorts(ports: number[] | undefined = this.requiredPorts, maxTries?: number): Promise<void>
    // コンテナを停止する。
    stop(reason?: number): Promise<void>
}

Containernpm:@cloudflare/containers からインポートされますが、他にもコンテナの管理に役立つようなツールを提供しています。

import { Container, getRandom, getContainer } from '@cloudflare/containers';

// @deprecated wrangler.jsonc で指定して wrangler types を実行すれば自動的にこれは生成されます。
declare module global /* worker-configuration.d.ts */ {
    declare interface Env {
        MY_CONTAINER_NAMESPACE: DurableObjectNamespace<Container>
    }
}

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
        // コンテナ (DO) の名前空間の二つのうちどれかを取得する。
        // instance-<number> のようなキーが使用されます。
        // なぜ 'await' する必要があるのかはわかりませんが、将来的にはオートスケーリングを実装するとしているので、Promise である必要があるのかも。
        const stub = await getRandom(env.MY_CONTAINER_NAMESPACE, 2);
        
        return stub.fetch(request);
    },

    async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
        // 一つの固有のコンテナを取得する。これは必要ない気がする。
        const stub = getContainer(env.MY_CONTAINER_NAMESPACE);
        // これと同じ。
        const stub = env.MY_CONTAINER_NAMESPACE.get(
            env.MY_CONTAINER_NAMESPACE.idFromName('cf-singleton-container')
        );
        
        await stub.call();
    }
}

料金について

オープンベータ中は、Workers Paid プランに加入していれば追加料金なしで Containers を使えますが、Workers と Durable Objects は通常通りの課金が発生します。(最初のうちいくらかは無料だったかと思います)

正式リリース後の価格は以下を予定しているようですが、未定の部分もあるようなので、Cloudflare の公式サイトを確認することをおすすめします(Free の料金表があるけど、まさかね。たぶんミスでしょう)。

Memory CPU Disk
Free N/A N/A
Workers Paid 25 GiB-hours/month included
+$0.0000025 per additional GiB-second
375 vCPU-minutes/month
+ $0.000020 per additional vCPU-second
200 GB-hours/month
+$0.00000007 per additional GB-second
Region Price per GB Included Allotment per month
North America & Europe $0.025 1 TB
Oceania, Korea, Taiwan $0.05 500 GB
Everywhere Else $0.04 500 GB

使ってみた感想

「Cloudflare のエッジネットワーク上でコンテナを動かせる」という仕組みはかなり興味深く、今後のユースケースも増えそうです。

コンテナの初回起動に時間がかかることがあったので(あとで見返してみたらデータセンターがなぜかめちゃくちゃ遠かった。東京のもあるし他のもある)、Cron Trigger + Workflow らへんで使ってみるのが今の個人的な使い方です。

あと @cloudflare/containers のエラー周りがちょっとかゆいです。個人的にはここで Response を返す必要はなく、代わりに throw してほしかったです。そのためのランタイムのエラーを判断するためのヘルパーみたいなのがめちゃくちゃあります。ベータ期間中なので改善されることを祈りますが、ステータスコードでレスポンスを判断している場合もあるので、改善の余地がありそうならコードベースが落ち着いた頃にPRを開きたいぐらい気になります。

https://github.com/cloudflare/containers/blob/770d3c491e80fcc51d67def98a1c40072f17bbf5/src/lib/container.ts#L775-L780

https://github.com/cloudflare/containers/blob/770d3c491e80fcc51d67def98a1c40072f17bbf5/src/lib/container.ts#L693-L708

https://github.com/cloudflare/containers/blob/770d3c491e80fcc51d67def98a1c40072f17bbf5/src/lib/container.ts#L66-L79

それでも、実装が予定されているオートスケーリングなどは今のところかなり要望が強い機能だと思いますし、個人的にはものすごく期待しています。
まだ未定ですが Containerautoscaletrue に設定することにより有効化できるようになる模様です。たぶん this.ctx.container!.start() にも渡せるようになるはず。

export class MyContainer extends Container<Env> {
    autoscale = true
}

Discussion