本記事では、受信したデータチャンクをログに出力する Node.js サーバーで Cloud Run サービスを構築します。あわせて、サーバーへデータをチャンク単位で送る Node.js クライアントも作成します。
HTTP/1 の chunked transfer encoding(チャンク転送エンコーディング)を使って、クライアントからサーバーへデータをストリーミングする方法を見ていきましょう。
早く試したい方は、すべてのファイルを置いている私のGitリポジトリをこちらからご覧ください。
なぜHTTP/1なのか?
まず気になるのは「なぜ?」という点ではないでしょうか。
一般的には HTTP/2 の方が望ましい選択肢です。パフォーマンスの大幅な向上、レイテンシの低減、リソース利用効率の改善といったメリットがあります。Cloud Run はパフォーマンス向上のために End-to-End HTTP/2 をサポートしていますが、アプリケーション側も HTTP/2 リクエストを受信できる必要があります。あいにく、この要件はどんな環境でも満たせるわけではなく、デプロイによってはハードルとなる場合があります。
- HTTP/2 をサポートしていない、あるいは既知の問題を抱えている古いクライアントへの対応が必要なケース
- アプリケーションが chunked encoding を必須とするライブラリやフレームワークに依存しており、HTTP/2 と完全には互換性がない場合は、HTTP/1 を使うしかないケース
本記事で利用する Transfer-Encoding: chunked ヘッダーは HTTP/1.1 の機能で、クライアントがデータの総サイズを事前に把握していなくてもチャンク単位でサーバーへ送信できる仕組みです。データを小分けにして個別に送信し、サーバー側でそれらを再結合します。
送信開始時点でコンテンツ長が分からない場合に特に有用で、動的コンテンツやストリーミングコンテンツでよく見られるシナリオです。
なお、HTTP/2 や HTTP/3 はデータ伝送の仕組みが異なり、バイナリフレーミングと多重化により複数のリクエスト/レスポンスを同時に処理でき、パフォーマンス向上が期待できます。とはいえ、アプリケーションを HTTP/2 対応に変更できないケースもあるでしょう。
HTTP/1.1 の chunked transfer encoding という考え方は、HTTP/2 や HTTP/3 にはそのまま当てはまらない点にご注意ください。
ストリーミングの方向
サーバーからクライアントへのストリーミングと、クライアントからサーバーへのストリーミングは別の概念で、それぞれ用途や手法が異なります。
1. サーバーからクライアントへ(Server-Sent Events):サーバーが新しい情報をクライアントへプッシュしたい場面で用いられます。
たとえば、チャットアプリやスポーツのライブ配信アプリのようなリアルタイムアプリケーションでは、新しいメッセージや更新が発生した瞬間にサーバーからクライアントへ届ける必要があります。
これを実現するのが Server-Sent Events(SSE)という手法です。クライアントがサーバーへの接続を開き、サーバーはその接続を維持したまま、更新があるたびに接続経由でプッシュします。

2. クライアントからサーバーへ(HTTP Streaming または Chunked Transfer Encoding):クライアントが大量のデータをサーバーへ送る際に、すべてのデータが揃う前から送信を始めたい場面で使われます。
たとえばファイルアップロードでは、巨大なファイル全体をメモリへ読み込み終える前にアップロードを開始したい、といったケースがあります。
これは HTTP プロトコルの chunked transfer encoding という機能で実現します。クライアントがデータをチャンク単位で送信し、サーバーは到着したチャンクを順次処理していきます。
いずれの場合も、リクエスト開始時にすべてのデータが揃っていなくてもよいようにし、データを段階的に送受信・処理できるようにすることがねらいです。これにより、特に大量データを扱う際にパフォーマンスの向上とメモリ使用量の削減が期待できます。
サーバー → クライアントとクライアント → サーバーのどちらを選ぶかは、アプリケーションの要件次第です。本記事ではクライアント → サーバーのストリーミングのみを取り上げます。gRPC を使ったサーバー → クライアントのストリーミングが気になる方は、こちらの Google ブログ記事をご覧ください。
サーバーのセットアップ
まずはサーバーを作りましょう。定番の Node.js フレームワークである Express を使用します。サーバーは /upload エンドポイントへの POST リクエストを待ち受け、受信したデータチャンクをログに出力します。
package.json を作成します。
{
"name": "stream-test",
"version": "1.0.0",
"description": "",
"main": "client.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2"
}
}
続いてサーバー側の server.js です。
// server.js
const express = require('express');
const app = express();
app.use(express.raw({ type: '*/*', limit: '5mb' }));
app.post('/upload', (req, res) => {
req.on('data', chunk => {
console.log(`Received chunk: ${chunk}`);
});
req.on('end', () => {
res.send('Upload complete');
});
});
app.listen(3000, () => console.log('Server listening on port 3000'));
このサーバーは Docker でコンテナ化できます。シンプルな Dockerfile は以下のとおりです。
# Dockerfile.server
FROM node:14
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
CMD [ "node", "server.js" ]
Cloud Run サーバー
では、コンテナイメージをビルドして GCP の Artifact Registry にプッシュし、Cloud Run サービスへデプロイしましょう。事前に AR リポジトリの作成が必要な場合はこちらを参照してください。
REPOSITORY=us-central1-docker.pkg.dev/<GCP_PROJECT>/<REPO>
docker build -t $REPOSITORY/stream-server:1.0 -f Dockerfile.server .
サーバーをデプロイします。
gcloud run deploy stream-server --image $REPOSITORY/stream-server:1.0
デプロイが完了すると、次のようなログが確認できるはずです。

Cloud Run 上でサーバーが稼働したので、Cloud Run の URL を取得しましょう。
export SERVER_URL=$(gcloud run services describe stream-server --region us-central1 --format 'value(status.url)')
クライアントのセットアップ
続いてクライアントを作成します。クライアントは起動時にサーバーへデータチャンクを送信します。hostname フィールドの <SERVER_URL> は、実際のサーバー URL に置き換えてください。
クライアント側の client.js は以下のとおりです。
// client.js
const https = require('https');
const options = {
hostname: '<SERVER_URL>',
port: 443,
path: '/upload',
method: 'POST',
headers: {
'Transfer-Encoding': 'chunked'
}
};
const req = https.request(options, (res) => {
res.on('data', (chunk) => {
console.log(`Response: ${chunk}`);
});
});
// write chunks of data to the request
req.write('chunk1');
req.write('chunk2');
req.write('chunk3');
req.end();
こちらのクライアントも Docker でコンテナ化できます。シンプルな Dockerfile は以下のとおりです。
# Dockerfile.client
FROM node:14
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
CMD [ "node", "client.js" ]
では、クライアントイメージをビルドして実行してみましょう。
docker build -t stream-client:1.0 -f Dockerfile.client .
docker run --rm stream-client:1.0
サーバー側の Cloud Run のログを確認して、動作をチェックしてみましょう。

あれ? チャンクが分かれてログに出ていないようです。原因は、HTTP プロトコルでは req.write() の各呼び出しがサーバー側で個別の「data」イベントになるとは限らない、という点にあります。
送信しているチャンクは小さく、しかも短い間隔で連続送信されているため、サーバー側で1つの「data」イベントにまとめて受信されている可能性が高いのです。
ストリーミングが機能していることを確かめるには、単一の TCP パケットに収まらないほど大きなデータを送ってみましょう。これによりデータが複数のチャンクに分割され、サーバー側で複数の「data」イベントが発火するはずです。
client.js にデータを書き込む「for ループ」を追加し、コンテナを再ビルドしてもう一度試してみましょう。
// client.js
const https = require('https');
const options = {
hostname: 'stream-server-kdfodunfwq-uc.a.run.app',
port: 443,
path: '/upload',
method: 'POST',
headers: {
'Transfer-Encoding': 'chunked'
}
};
const req = https.request(options, (res) => {
res.on('data', (chunk) => {
console.log(`Response: ${chunk}`);
});
});
// write a large amount of data to the request
for (let i = 0; i < 1e6; i++) {
req.write(`This is chunk number ${i}\n`);
}
req.end();
docker build -t stream-client:2.0 -f Dockerfile.client .
docker run --rm stream-client:2.0
今度は問題なく動いているようですね!

本記事では、Node.js を用いて2つの Cloud Run インスタンス間でデータをストリーミングする方法をご紹介しました。この構成により、クライアントからサーバーへデータをチャンク単位で送信し、サーバー側で到着するたびに各チャンクをログに出力できます。
注意点として、HTTP プロトコルでは req.write() の各呼び出しがサーバー側で個別の「data」イベントになるとは限りません。最初に送っていたチャンクは非常に小さく、しかも連続して送信されたため、サーバー側で1つの「data」イベントにまとめて受信されていました。
単一の TCP パケットに収まらないサイズのデータを送ったことで、ストリーミングが正しく機能していることを確認できました。これによりデータが複数のチャンクに分割され、サーバー側で複数の「data」イベントが発火するようになりました。
繰り返しになりますが、一般的には HTTP/2 が推奨されます。可能であれば HTTP/2 をご利用ください。
本記事がお役に立てば幸いです。ご質問があれば、ぜひ下のコメント欄にお寄せください。