トップ 最新 追記

2018-10-01 [長年日記]

_ Promise.reject()で処理は止まるがそのタイミングは「期待」通りとは限らない

まとめ

止まることは止まるが、思ったタイミングで止まるかどうかは分からないので、それ前提で作る必要がある。

例えば以下は思い違い。

  • 最初の一つが rejected になったら、その一つめで必ず止まる

実際には

  • 最初の rejected が Promise.all に伝わる段階ですでにいくつも並列で処理が走っている可能性があるので、rejected 以降にエラーがバンバン発生することはあり得る

もっと言うと rejected が伝わった段階で中の複数の Promise の結果は fulfilled と rejected が混ざっている可能性もあるので、全体の rejected を retry すると fulfiled になった Promise をもう一度実行する場合もある。

ということは例えば何かの POST 処理を Promise.all で回しつつ retry を考慮している場合、すでに成功しているデータが複数回 POST される可能性がある。

もっと言うと、

そこで何かの拍子に unique 制約に引っかかって逆にエラーがあとから起きたりもする。

ということ。

非同期並列面倒くせぇ…。

何で悩んでいたのか

Promise.all(Array.map(e => Promise))

なコードがあった。これを呼び出す function foo() があった。

function foo(data) {
  Promise.all(data.map(e => Promise))
}

この foo() を直接読んでテストしている時は最初の一つが reject になった段階で止まっていた。で、

一つめが rejected になったら止まると思い込んでしまった

しかし、次に

function bar() {
  ..
  ..
  foo()
  .then(() => {
    ..
  })
  .catch((err) => {
    ..
  })
  ..
}

の bar() でテストすると、

大量にエラーが起きてしまったので、「止まらないじゃん!」と、また勘違い

「何かコードの書き方を間違えているから止まらないのでは…」とありもしない原因を探る旅に出てしまったのでした…。

正解は、Promise.all([Promise, ..]) の実行に至るまでに払っている実行コストの間で中の Promise が並列に実行を開始してしまい、結果、

呼び出し元が rejected でストップしたタイミング以降にいくつもエラーが起きて当然の状況になる

つまり

一つめの rejected で止まるのも、まったく止まっていないように見えるのも、どちらも正しい!!!

非同期並列難しい!

何が難しいって同期的な処理を期待しているシステムと繋げるのが難しい!

愚痴まじりで言うと Node.js をこれまで避けてたのはやっぱり正しかったなぁと思った。それは動作が安定しているかどうかとか、高速かどうかとか、そういうことじゃなくて動作の考え方そのものが難しいので、カジュアルな変更を前提にしたコードには向いていないなぁという意味。記法だけを見れば callback 地獄は Promise によって解決されたかのように見えるが、動作については非同期並列という特徴はそのままなので、伝統的な、同期的で排他ロック可能なコードを扱う脳との切り替えおよび整合性の確保が難しい。整合性の確保は複数のサービスの連携が前提となるマイクロサービスにおいては必須の要件になってくる。

今後 FaaS, サーバレス化を進めるに当たってはここはキモになってくるし、

  • スケール、非同期前提のインフラに載せつつ変更の少ない部分に使っていく
  • 同期的な仕組みの側も非同期並列の仕組みから繋げる際のエラーの起き方を知っている必要がある
  • モニタリングの意味も同期的な仕組みとちょっと変わってくる

かなぁということを思っている。


2018-10-07 [長年日記]

_ JavaScriptのPromiseでretryするいくつかの方法

JavaScript で Promise はそこそこ扱えるようになったけど、 retry をどうするか曖昧なままだったのでちょっと整理する。

まずダメなやつ

普通に同期的なコードの場合、やり方はいろいろあると思うけど、素朴にはこんな感じに書けると思う。

function s() {
  return Math.random() > 0.5
}

let retry  = 3
let result
while ( retry > 0 ) {
  result = s()

  if ( result ) {
    break
  } else {
    retry--
  }
}
console.log(result)

実際には retry 付きで呼ぶ function に閉じると思うけど、だいたいこんな感じかなーと思う。

で、Promise でもこれを元になんとかしてみようとすると、うまくいかない。s() を Promise を返すようにしてみたとして retry 部分を書き換えるとこんな感じになると思う。

while ( retry > 0 ) {
  result = s()

  s()
 .then(() => {
    break
  }).catch(() => {
    retry--
  })
}
console.log(result)

しかし上のコードを Node.js 6 で実行すると以下のように怒られる。

   break
   ^^^^^

SyntaxError: Illegal break statement
そりゃそうだ

Promise は callback 地獄を解消するものと思われているけど、実際には引数に対してではないけど結果に対して callback を与えて処理をチェインする仕組みであり、ということは上に書いた break の scope は Promise を呼んでいる外側とは異なる、while ループと異なるものになっている。

つまり、break するものがない場所で break しようとしている

Promiseのchainで書く特徴を生かしてシンプルに呼び出す側がretryする例

retry 回数などの抽象化を一切無視すると以下の形になる。

s().catch((err) => {
  return s()
}).catch((err) => {
  return s()
.then((r) => {
  console.log(r)
})
.catch((err) => {
  console.error(err)
})

Promise が rejected になったらもう一度同じ function を call するだけ。これで 3回試行できている。

途中 Promise をわざわざ return しているのは、こうしないと Node.js で UnhandledPromiseRejection の警告が出るからだけど、確かに返しておく方が理に適っていると思う。

cf. 【JavaScript】Promiseのリトライ処理をちゃちゃっと - エムティーアイ エンジニアブログ

呼び出される側がretry込みで実行する例

上のやり方は呼び出す側の処理に retry を入れてしまっているため、呼び出し箇所が複数あった場合に DRY でなくなってしまう。できれば呼び出される側で retry 込みで処理してほしい。

これは再帰させるとすっきり書ける。

function p(retry = 3) {
  return new Promise((resolve, reject) => {
    let r = Math.random()
    if ( r >= 0.5 ) {
      if ( retry > 0 ) {
        p(--retry).then(resolve).catch(reject)
      } else {
        return reject(false)
      }
    } else {
      resolve(r)
    }
  })
}

こうすると p() を呼ぶ側は p() を普通の Promise のように扱っているにも関わらず、中で任意の回数 retry させることができる。

※ retry 回数を引数に与えて再帰で処理していく部分の説明は割愛する。

キモは.then(resolve).catch(reject)

これがないと結果の「クチ」を「戻す」ことができない。つまり retry に入ったら resolve も reject も外からは分からないままなんとなく終了してしまう。

そう、ここで指定している resolve, reject こそが最初の return new Promise() に渡ってくるものであり、呼び出す側の

p()
.then()
.catch()

を実現するために必要なものなのだ。ということで

.then(resolve).catch(reject)

を与える必要がある。

通常の再帰は自分自身を call する際に return しながら戻ってくるのだが、Promise は渡ってくる resolve, reject を then, catch に与えることで「chainableな状態を維持する」形になるようだ。

気持ち悪いが、よく考えると納得はいく。ただこれはイディオムとして覚えてしまうのが早いように思う。

cf.

参考

Tags: JavaScript

2018-10-21 [長年日記]

_ Key Management Serviceについて勘違いしていたこと

あるいは Kanazawa.rb meetup #74 - Kanazawarb に行ってきたよ。

最近 Google Cloud Key Management Service について調べていた。

というのも、Cloud Functions で小さいツールを業務的に扱うことがたぶん今後増えるので、サービスアカウントが増える、ということはこのアカウントのキー管理が増える、ということを予想していた。

結論から言うとこの二つのうち両方を勘違いしていたのだが、その過程で面白いことが分かったのでまとめておこうと思う。

Cloud KMSは自分の管理したい鍵を直接管理してくれるものではなかった

Cloud Key Management Service | Cloud KMS | Google Cloud

Cloud KMS は確かに鍵を管理してくれるのだが、今自分が管理したいと思っている、すでに存在している鍵を収める金庫のような役割を果たしてくれるわけではなかった。

Cloud KMS は決してクラウドから取り出すことのできない鍵を生成、管理してくれるものだったのだ。

「え? 取り出せない鍵に意味あるの?」

あるんです。

「鍵に対して名前が振ってあって、その名前に対して暗号化/複合したいデータを送りつけてやる」

という使い方をすれば「取り出せないけど使える鍵」を実現できる。そしてこの方式には明確なメリットがある。

  • 鍵を利用したいレベルの秘密情報の利用の痕跡をすべてクラウド側に残せる
  • 特定の名前の鍵を破棄すれば、その鍵を利用するデータの利用も無効化できる
    • (鍵にアクセスできるユーザーなどで管理できるかどうかは未確認)

そう。単なる金庫ではなく監査が行えるし、暗号化したデータの利用も制御可能になる。

この鍵はどう使うのがよいのか?

この鍵はあくまでクラウド側で管理しやすい形をしているもので、直接何かに使えるわけではない。まぁ、暗号化/複合はデータを送りつけてやれば可能だが、

暗号化/複合のたびに通信が発生するので、ありとあらゆる暗号利用シーンに使うのは現実的でないし、なんなら暗号化/複合できるデータの容量には制限がある*1

ということで、

  • データを暗号化して保存する鍵を暗号化する
  • 何かの認証に利用する鍵を暗号化する

など、鍵を暗号化する鍵 ( Key Encryption Key ) として利用することを想定しているらしい。

そこで新たな疑問

  1. Cloud KMSへアクセスする権限の認証、認可に利用するキーの管理はどうなるのか?
  2. 暗号化したキーはどこに保存するのがよいのか?

これについては、まだ完全に解決していないが、

  • Cloud KMS へアクセスするマスターキーの管理は頑張ってね
  • Cloud Storage の読み書きの際には実は Cloud KMS の key name を利用したアクセスが可能

という辺りにヒントがありそう*2。少なくとも大量の鍵を大量のまま頑張って管理するよりは頑張らずに管理することは可能そうだ。

Cloud FunctionsからCloud Storageへのアクセスは鍵不要だった

Function の動作しているサービスアカウントの Default Credentials で同一プロジェクトの Storage は読み書き可能だった。

サーバー間での本番環境アプリケーションの認証の設定 | 認証 | Google Cloud

環境変数が設定されていない場合、ADC では、サービスで実行されているアプリケーションに応じて、Compute Engine、Kubernetes Engine、App Engine、または Cloud Functions によって提供されているデフォルトのサービス アカウントを使用します。

※ もしかしたら beta から GA になったタイミングでこの仕様は変わっているかもしれない

しかしpkgcloudはそれを許さなかった

pkgcloud/google.md at master · pkgcloud/pkgcloud

Using the Google Cloud provider requires:

  1. A project id
  2. A JSON key file

ということでFunction実行時の鍵を減らすには
  • pkgcloud に pull-req を送って default credentials を利用できるようにする
  • pkgcloud の利用を諦めて独自に Google API SDK の wrapper を作って file system へのアクセスと透過的に切り替える何かを作る
  • local の file system での開発を諦める

以上から決断が必要そう、ということが分かった。

CI/CDなどからKMSを利用する方法を確立するのが次の課題

Function 実行時に必要な鍵は(将来的に)なくせることが分かった。もう一つの鍵は CI/CD サービスから deploy 時に利用するサービスアカウントの鍵である。

これについては CI/CD サービスが KMS を提供するクラウド*3の外にあるCircleCI を今のところ使っているので、どうしても鍵を外部に保存する必要があり、その鍵自体を KMS で暗号化することが肝要になってくる。

そこで CI/CD サービスから KMS を利用して安全に暗号化/複合できるようにする手法を確立しておくことで、今後 deploy の必要な Function が増えても管理コストが極力増えないようにできるはずだ。

以上、あーなるほどね、すっきりした。

*1 2018-10-21現在でGoogle KMSは64KiB, AWS KMSは4KB

*2 Cloud StorageのドキュメントではなくSDKのコードを読んでいて気がついた。Google Cloudのドキュメントから情報を探すの難しすぎるよ。

*3 今回の私の場合は Google


2018-10-24 [長年日記]

_ Cloud KMS + Cloud Storageの利用には大きく分けて3種類の方法があった

エンベロープ暗号化や秘密情報の扱いはそもそもそんなに得意じゃないので大間違いしている可能性があります。間違ってたら指摘お願いします。

※ Cloud KMSについては Key Management Serviceについて勘違いしていたこと - あーありがち(2018-10-21) に一部メモがあります。

まとめ

Cloud KMS + Cloud Storage の組み合わせには大きく分けて以下の3つの方法がある。

  1. Bucket のデフォルト暗号化キーを KMS Key にする
  2. Object 保存時に KMS Key Name をオプションで渡す
  3. 独自に KMS Key で暗号化して通常通り保存する

基本的に Cloud KMS のドキュメント上で説明されているのは 3 の方法。

1 と 2 の方法については Cloud KMS の API を enable にして KMS と Storage に同じアカウントでアクセスできるようにする必要がある。暗号化、複合は透過的に行われ、意図的に暗号化済みのデータを取得する方法はない。KMS Key が無効化されたら Object そのものにアクセスできなくなる。

Storageの自動機能の利用には注意が必要

上の 1, 2 は Storage + KMS という Google の提供する技で、これは簡単に言うと KMS Key Name の指定さえできていれば暗号化/複合処理は完全に Google におまかせになる、つまり自動で透過的に適用されるということ*1

この場合、Object のメタデータの一つに KMS Key Name が入るので、鍵とデータの組み合わせは気にする必要がない。

ただし、利用の際には以下のような制限が生まれる。

  • Bucket の Region と Key の Location の組み合わせには制限がある(例えば asia な Bucket に global な Key を組み合わせることはできない)
  • KMS と Storage の両方に同一のアカウントで認証を要求することになるので厳密なことを言うと権限がややルーズ
  • Key が無効化されたら Object にはメタデータ含めてアクセスできない(読み取りはできるが中身が分からない、ではなく、読み取りもできない。ただし Key Name は取得できる。)

特に Bucket レベルの制限はバツンと一律アクセスできなくなってしまうので、いろいろな利用方法の混ざった暗号化キーやシークレットに対しておおもとの KMS KEK Key で有効/無効を切り替えるといった使い方は難しそう。

となると、あくまで

  • KMS へのアクセスを外部に許可するわけではなく、管理が容易
  • シークレットの利用方法は同一
  • キーのローテーションはするが無効化は原則しない

以上のような運用に向いている気がする。

例えばマルチテナントのデータをテナントごとに暗号化するキーを分けたうえでその暗号化キーの暗号化に使う KEK がローテートされる、みたいな使い方をすると開発環境の準備も楽そうだし、よさそうに思える。*2

自前で暗号化する場合は自前で対応表に基づく自動化を行うべし

Storage の自動暗号化の恩恵に与らない場合、

  • KMS Key Name
  • 暗号化されたシークレット / DEK

の間を紐づける情報は Google Cloud のどこにも残らない。あくまで KMS Key とデータを呼び出したプログラムだったり紐づけて作業を行う作業者の頭の中だったり terminal の履歴の中だけに残るものとなる。

そこでおそらく基本的にはこのデータの暗号化/複合の処理はコードで閉じ込めてしまうわないと維持が難しくなってしまうので、まずは対応表とそれを利用した暗号化/複合処理のコードの準備が必要になる。

上に挙げた例の反対、

  • KMS へアクセスするユーザー(プログラム)の場所は様々
  • シークレットの利用方法が同一でない
  • キー単位で無効化するなどシークレットの利用を制御したい

といった要件が入る場合はこのような準備が必要と考えるのがよさそうだ。

*1 Bucket レベルの場合は指定はリソース ID になるが、同じこと。その場合はバージョンまで含めたリソース ID にならないように注意

*2 データの暗号化キーが KMS と自動連携する Storage で管理されるイメージ。キーは DB に入っていない方が安心な感じもする。


2018-10-28 [長年日記]

_ Webpack 4でyaml-loaderからjs-yaml-loaderに乗り換え

単なるメモ。

Nuxt 1 + Webpack 3 なプロジェクトでは

okonet/yaml-loader: YAML loader for webpack (converts YAML to JSON)

を使ってこんな感じで、

config.module.rules.push({
  test: /\.ya?ml$/,
  use: [
    { loader: 'json-loader' },
    { loader: 'yaml-loader' }
  ]
})

で YAML を読み込んでいたんだけど、これが Nuxt 2 + Webpack 4 だと cannot find module や Module parse failed: Unexpected token エラーが出て使いものにならなかった。

調べると Webpack 4 で json-loader は不要だの CommonJS, AMD, ESModule だのの扱いが変わって七面倒くさい感じの話があり、YAML を import する方法のブログだけで様々なやり方が出てきて「あーもう、こういうとこやぞ Webpack は!」と思ってたんだけど、そういや Nuxt 1 + Webpack 3 の頃になぜかうまく動かなかったような気がする js-yaml-loader を思い出して試したらあっさり動いた。

ということで Nuxt 2 + Webpack 4 なプロジェクトでは

wwilsman/js-yaml-loader: JS-YAML loader for webpack

を使って

config.module.rules.push({
  test: /\.ya?ml$/,
  use: 'js-yaml-loader'
})

としていこうと思う。ここの変更だけで import 部分は変えずに

import yaml from <path-to-yaml-file>

で ok なので、たいへんよい感じである。

おしまい。

Webpack は機能の根幹に関わる API が変わりすぎだろ。最初からイヤな予感してたけど、やっぱり地雷感強い。