Oracle Object Storage REST API に PUT する時はリクエストヘッダを一部省略できた

以前、Oracle Object Storage の REST API を操作する Node.js スクリプトについて、公式のサンプルコードを日本語圏向けに修正した。

日本語を含むテキストファイルの末尾が欠落する問題は、上の記事のとおり Content-Length の指定を修正することで対処でき、しばらくは無事に使っていた。

しかし最近、サイズが大きいファイルを PUT 送信する時に、どうも送信前の処理に時間がかかっていることを見つけた。今回はコレを解決する。

目次

Object Storage REST API のおさらい

Oracle Object Storage の REST API リファレンスは以下にある。

この中でよく使うと思われるのは、Object (≒ ファイル) のダウンロードやアップロードだろう。

今回、処理に時間がかかっていると分かったのは、この PutObject をコールしようとした時だった。

さて、Node.js 向けの公式サンプルコードは以下にある。Version 1.0.1 と打たれているコードだ。

この中の

request.setHeader("Content-Length", options.body.length);

部分を

request.setHeader('Content-Length', Buffer.byteLength(options.body, 'utf8'));

このように直すことで、日本語を含むファイルの末尾欠落を回避したのが、前回の記事だった。

処理に時間がかかっている場所

さて、PutObject を叩く時に処理に時間がかかっている場所はどこなのか。

コードの各行に console.log() を仕込むという、昔ながらの「プリント・デバッグ」で調べたところ、以下の1行が実行されるのに、2・3秒かかっていることが分かった。

shaObj.update(options.body);

コレが何なのか示すために、もう少し周辺のコードを抜粋する。

var methodsThatRequireExtraHeaders = ["POST", "PUT"];
if(methodsThatRequireExtraHeaders.indexOf(request.method.toUpperCase()) !== -1) {
  options.body = options.body || "";
  
  var shaObj = new jsSHA("SHA-256", "TEXT");
  shaObj.update(options.body);  // ← この行の処理に時間がかかってる
  
  request.setHeader("Content-Length", options.body.length);  // ← Buffer.byteLength(options.body, 'utf8') に変更すべき箇所
  request.setHeader("x-content-sha256", shaObj.getHash('B64'));
  
  headersToSign = headersToSign.concat([
    "content-type",
    "content-length",
    "x-content-sha256"
  ]);
}

このコードは、Oracle Cloud の REST API の内、POST と PUT リクエストを叩く時に必要となるリクエストヘッダを設定している。Content-Type の他、前回の記事で修正した Content-Length と、リクエスト情報のハッシュ値を X-Content-SHA256 というヘッダ名で付与している。

問題となっている shaObj.update() メソッドは、jssha という npm パッケージが提供するモノだ。直前の行で、jssha のインスタンスを生成していることが分かるだろう。後の行で shaObj.getHash('B64') とハッシュ値を取得するための準備として jssha.update() を叩くのだが、コレに時間がかかっていた。

ハッシュ値を作るには、一旦コンテンツの全量を読み込んで処理する必要がある。10MB とか 20MB とか、サイズの大きいファイルを送ろうとすると、それだけのテキストを jssha に渡して処理させることになる。この処理は同期的に行われ、CPU を使うために、シングルスレッドで動作する Node.js においてはブロッキングが発生する。ちなみに、CPU にコストのかかる処理のことは「CPU バウンドな処理」などと表現したりもする。

シングルスレッドであるために、CPU バウンドな処理でブロッキングが発生する現象は、大規模な JSON データを JSON.parse() したり JSON.stringify() したりする時にも発生する。今回はまさにコレと同じ原因だったのだ。

CPU バウンドな処理を別プロセスに分ける…?

さて、原因の箇所が分かったので、なんとかしたい。

Object Storage への PUT 送信以外にも、並列処理したいことがいくつかある。それなのに jssha.update() の実行に CPU を使われてしまうと、他の処理が非同期であっても、その間は処理がせき止められてしまう。

コレは Node.js がシングルプロセス、シングルスレッドで動作するためにブロッキングが発生する、と話した。それでは、この重たい処理だけ、別プロセスで実行させたら、スレッドを分割できるのではないか。

コレは確かに有効な策だった。PUT 送信をコールする部分から child_process.fork() で別プロセスに切り出して処理させたところ、別プロセスで jssha.update() に時間がかかっている最中でも、親プロセス側の並列処理はキレイに動いてくれた。ブロッキングは回避できたのである。

どのファイルを PUT 送信するか、というパラメータは ChildProcess.send() で送れるし、送信完了の通知が欲しければ、親プロセスで ChildProcess.on('message') イベントを予約しておき、子プロセスから process.send() を送信すれば受け取れる。

ということで、ブロッキングを回避するのはコレでも良かった。

そもそもそのリクエストヘッダが要らなかった

改めて Oracle Cloud の REST API に関するリファレンスを見ていると、驚愕の事実が発覚した。

必須ヘッダー

GET および DELETE リクエスト (リクエスト本文にコンテンツがない場合) の場合、署名文字列には少なくとも次のヘッダーが含まれている必要があります:

  • (request-target) (draft-cavage-http-signatures-08 で説明されているように)
  • host
  • date または x-date (両方が含まれている場合、Oracle は x-date を使用します)

ココまでは良い。GET の時は jssha を使った if 文のブロックに入らないが、上の3つのリクエストヘッダはその手前で設定されている。

PUT リクエストと POST リクエスト (リクエスト本文にコンテンツがある場合) の場合、署名文字列には少なくとも次のヘッダーが含まれている必要があります:

  • (request-target)
  • host
  • date または x-date (両方が含まれている場合、Oracle は x-date を使用します)
  • x-content-sha256 (オブジェクト・ストレージ PUT リクエストを除く、次のセクションを参照)
  • content-type
  • content-length
  • 警告
    • PUT および POST リクエストの場合、クライアントは x-content-sha256 をコンピュートし、本文が空の文字列であってもリクエストおよび署名文字列にそれを組み込む必要があります。
      また、本文が空であっても、リクエストと署名の文字列には常に content-length が必要です。
      一部の HTTP クライアントは、本文が空の場合は content-length を送信しないため、クライアントが明示的に送信するようにする必要があります。
      datex-date の両方が含まれている場合、Oracle は x-date を使用します。x-date は、リクエストの署名部分の再利用 (リプレイ攻撃) から保護するために使用されます。
  • 1つの例外は、オブジェクトに対するオブジェクト・ストレージ PUT リクエストです (次のセクションを参照)

途中までそのとおり〜これらのヘッダが要るよね〜と思って読んでいたのだが、x-content-sha256 のところに「オブジェクト・ストレージ PUT リクエストを除く」と書いてあったのだ。

どういうことかと思い、続きを読んでみる。

オブジェクト・ストレージ PUT の特別な手順

オブジェクト・ストレージ PutObject と UploadPart PUT リクエストの場合、署名文字列には少なくとも次のヘッダーが含まれている必要があります:

  • (request-target)
  • host
  • date または x-date (両方が含まれている場合、Oracle は x-date を使用します)

リクエストにも PUT リクエスト (通常は上記のリストを参照) に必要な他のヘッダーも含まれている場合は、これらのヘッダーも署名文字列に含める必要があります。

通常 Oracle Cloud の REST API の内、POST や PUT リクエストを叩く時は、x-content-sha256content-length の指定が必須だが、Object Storage への PUT 送信のみはコレが不要だということが分かった。

公式のサンプルコードは、Object Storage に限らず他の Oracle Cloud REST API にも汎用的に対応するため、POST と PUT のリクエスト時は必ずこれらのヘッダを付けるように実装されていた。しかし、Object Storage PUT の場合だけは、この計算コストを省略できるワケだ。

自分はこのコードを Object Storage API にしか使っていなかったので、jssha を使う箇所をガッツリ削除することで、CPU バウンドな処理を削って処理を高速化できた。

// 削除したコードをコメントアウトで表現

var methodsThatRequireExtraHeaders = ["POST", "PUT"];
if(methodsThatRequireExtraHeaders.indexOf(request.method.toUpperCase()) !== -1) {
  options.body = options.body || "";
  
  // var shaObj = new jsSHA("SHA-256", "TEXT");
  // shaObj.update(options.body);
  
  // request.setHeader("Content-Length", options.body.length);
  // request.setHeader("x-content-sha256", shaObj.getHash('B64'));
  
  headersToSign = headersToSign.concat([
    "content-type",
    // "content-length",
    // "x-content-sha256"
  ]);
}

headersToSign に追加するヘッダ名もちゃんと削っておく。それ以外の細部も色々と最適化できそうではあるが、今回の問題点と効果を明らかにするため、本当に不要なところを削るだけに留めた。

x-content-sha256 のために使っていた jssha は使用箇所がなくなったので、依存する npm パッケージからも削れた。

Content-Length に関しては、Buffer.byteLength() で計算した値を指定せずに送信して、文字列の欠落は発生しないのか?と心配になったが、Content-Length ヘッダが未指定でもテキストファイルの欠落は発生しないようだった。一安心。ちなみにこの Buffer.byteLength() も、若干の計算コストが掛かっていたところだったので、コレも削れるとさらに処理速度が速まる。

あまりないとは思うが、Object Storage 以外の REST API もコールする場合は、上述のようにコードを削るだけではダメだ。今度はそれらの API をコールする際に x-content-sha256 ヘッダが不足してしまいエラーになるからだ。そのため、何らかの方法でコールする API を見極めて、PutObject の時だけヘッダ付与を回避してやらないといけないだろう。公式のコードでいくと、function sign() に Boolean 型の第3引数を追加してハンドリングするのがてっとり早いだろうか。

// 第3引数に isPutObject を追加
function sign(request, options, isPutObject) {
  var apiKeyId = options.tenancyId + "/" + options.userId + "/" + options.keyFingerprint;
  var headersToSign = [
    "host",
    "date",
    "(request-target)"
  ];
  
  var methodsThatRequireExtraHeaders = ["POST", "PUT"];
  if(methodsThatRequireExtraHeaders.indexOf(request.method.toUpperCase()) !== -1) {
    options.body = options.body || "";
    headersToSign = headersToSign.concat([
      "content-type"
    ]);
    
    // PutObject 以外のコールの場合は以下のヘッダも付与する (= PutObject の場合は以下のヘッダを付けない)
    if(!isPutObject) {
      var shaObj = new jsSHA("SHA-256", "TEXT");
      shaObj.update(options.body);
      request.setHeader("Content-Length", options.body.length);
      request.setHeader("x-content-sha256", shaObj.getHash('B64'));
      headersToSign = headersToSign.concat([
        "content-length",
        "x-content-sha256"
      ]);
    }
  }
  
  httpSignature.sign(request, {
    key: options.privateKey,
    keyId: apiKeyId,
    headers: headersToSign
  });
  
  var newAuthHeaderValue = request.getHeader("Authorization").replace("Signature ", "Signature version=\"1\",");
  request.setHeader("Authorization", newAuthHeaderValue);
}

…こんな感じでハンドリングすれば良さそう。

以上

公式のサンプルコードそのままでもとりあえずは動いたが、ドキュメントをよく見ると無駄があることが分かった。パフォーマンスを向上するには、無駄は極力省く必要がある。

今回はボトルネックになっている箇所が x-content-sha256 リクエストヘッダの生成処理部分だと分かり、ドキュメントで仕様を確認すると、このリクエストヘッダが不要だと分かった。そこで x-content-sha256content-length を削り、パフォーマンスを向上できた。

安易にサンプルコードをパクったつもりはなかったが、それが本当に必要な処理なのかどうかは、ホントに1行ずつ検証していかないといけないな、と思った。