AWS LambdaとAsanaで稼働するプロジェクト審査システム

## 自己紹介
　始めまして、CAMPFIRE社でwebサービス開発を担当しているishimizuと申します。CAMPFIRE社に入社する以前は競合サービスだったFAAVOの開発をPHPで行っておりました。
　2018年7月にFAAVOの事業譲受に伴いFAAVOと供にCAMPFIRE社にジョインし、現在はCAMPFIRE本体の開発にも関わるようになり今は旧FAAVOから引き継いだ地域案件のプラットフォームや外部企業との連携システム周りをRubyで書いています。

　趣味は旅行とお酒とアウトドア活動ですが、コロナウイルスの影響で出社できなくなり今年は一人で自宅で過ごす時間が増えて悶々としております。

　本日は弊社で運用しているAWS LambdaとAsanaを使った審査管理システムについて私の苦難と格闘の歴史を書こうと思います。

## AWS Lambdaって何？
　AWSの公式サイトによると

　AWS Lambda は、サーバーのプロビジョニングや管理の必要なしにコードを実行できるコンピューティングサービスです。AWS Lambda は必要時にのみコードを実行し、1 日あたり数個のリクエストから 1 秒あたり数千のリクエストまで自動的にスケーリングします。イベントによってトリガーされる関数で構成され、（中略）サーバーレスアプリケーションを構築することもできます。

　とのこと。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html

　よく分からん。

　サーバーレスってなんぞや？

　実際はNode.js自体が起動するサーバは存在しているはずだしいくら安いと言ってもスケジューリングだけでも何らかの処理が走る限り料金も負荷も0にはなりえないと思うので特にメリットを感じず？？？？と思っていました。

言語はNode.js以外にもJavaやRubyやPython等が選べるようです。サンプルコードを登録してzipダウンロードして[apex](https://github.com/apex/apex.run "apex")でデプロイすれば快適なデプロイ＆開発環境が作れます。
今回の記事ではnode.jsを使います。

## Asanaって何？
日々の業務上のタスクを管理できるサービスで可視的にガントチャートやタスクのタイムラインを管理したりできるものです。
https://asana.com/ja
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/851627/de81df71-6630-059e-973a-b0ee5720113e.png)
もちろん開発タスク管理などにも使用しますが、
審査タスクをプロジェクトごとにキュレーターの担当にして期限やカテゴリをつけることで審査管理システムとしても使っています。

## CAMPFIREではどのようにAWS Lambdaを使用してるか
　CAMPFIREでは日々投稿されるプロジェクトの公開の可否をキュレーターが人力で審査することでサイトの公共性を保っています。
　そのため、審査申請の上がった全プロジェクトを全て[Asana](https://asana.com/ "Asana")のタスクに登録して審査担当にタスクとして振り分け業務の効率化を図っています。
![asana.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/851627/c1ec1b78-98e2-8569-3803-2807bd7955c0.jpeg)


　このAsanaにプロジェクトを登録するのにAWS LambdaのFunctionを数分に一度起動してデータベースからプロジェクト情報を取り出し、node.jsのコードからAsanaのAPIを叩くようになっています。
　他にも[zendesk](https://www.zendesk.co.jp/ "zendesk")という問い合わせ管理サービスに起案者のメールアドレスを登録してチケットを発行するAPIも同時に叩いています。
 これは弊社の業務上かなり基幹的なものであり、どういうことかというと

**Asanaへのタスク登録が止まると審査業務が止まる**

 ということです。
## ishimizuが手探りでこの審査システムをメンテするようになった経緯
 上述の通り私は元々FAAVOというサイトのPHPしか触ったことがなくサーバサイドJSについてはド素人でした。

 方々で公言してますが、私はJavaSciptがあまり好きではないです。型やクラスの取り扱いが曖昧なのとフレームワークの流行り廃りが毎年ぐらいの勢いで変わり安定しないから進んで使おうと思えないからです。（phpやrubyはどうなのかという議論はもちろんありますが）その事は置いておいて

**CAMPFIREは万年エンジニア不足です。（おそらく開業当初から）**



 そんな中で元々この審査システムを開発した前任者のCTOから直々にFAAVO内に同様の審査システムを移植して稼働させる手順を教わりました。

 ブラウザのJSについては詳しい方でしたのでサーバサイド側のJSにも比較的馴染むのに苦はなかったです。

 その後CTOは退任され、既に弊社の基幹システムになっていたこの審査システムはメンテナがいない状態になりました。

 その結果トラブルが有る度に審査業務を止めるわけには行かないので、過去に少しかじった程度の私が呼び出されるようになり結果的に私が継続的にメンテをするようになりました。本体のrubyのコードとは違い独立したシステムでしたので、気楽にデバッグコードを仕込んだりもできますので気持ち的にも入り易かったです。

**がしかしこれが泥沼への入り口だったのです**

## 事件その1　AsanaのAPIの仕様が変わった
 nodeには[node-asana](https://github.com/Asana/node-asana "node-asana")という非常に便利なライブラリが用意されていましてAsanaへのタスク登録は非常に直感的にコード化する事ができます。
が、しかし2019年8月にとんでもない仕様変更がAsana内でしれっと実行され急にAsanaのチケットが生成されなくなるといった事件が起こりました。

### Invalid Request??
 Lambdaでログストリームを設定しておくとCloud WatchのLogGroupでconsole.logやconsole.errorの出力を見ることができます。この時もまっさきにログを確認したのですが、書いてあるのはInvalid Requestという出力だけ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/851627/c2b3dd40-223b-adfe-c023-96d0641ad443.png)
 (ﾟДﾟ)ﾊｧ?

 Asanaが何らかのAPIエラー詳細文字列を返しているはずなのだが、node-asana内でエラー詳細を握りつぶしている様子でした。元のコードが悪く例外処理もあまり書かれてませんでした。
 そこでnode-asanaを少し改造。
 grepしたらすぐに"Invalid Request"を出力してる箇所がわかりエラー詳細を吐かずに握りつぶしている仕様も見えました。
https://github.com/Asana/node-asana/blob/master/lib/errors/invalid_request.js#L5
 ここの上でこのようにvalueの値をconsole.errorしたらエラーの詳細が判明しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/851627/7e02f0d9-5cdf-d079-15d3-6ed4f3b4c9f8.png)
 他にもAPI通信等のPromiseのcatchを随所に書いてエラー詳細を出力させるようにしました。
 この時は障害が発生していたのでAsanaの情報取得側のAPIが際限なく走っていて"Rate Limit Enforced"(API叩きすぎエラー、1時間に50回以上だと出る)になっていたり、

 idの送信方法が変わったことを通知するログが出ていたり

```
2019-08-00T00:00:00.000Z    xxxxx-xxxx-xxxx-xxxx-xxxxxxxx    { errors: 
[ { message: 'workspace: Not a valid GID type: "number".',
help: 'For more information on API status codes and how to handle them, read the docs on errors: https://asana.com/developers/documentation/getting-started/errors' } ] }
```
 要約すると「今までid系をidというparam名の数値型で送ることができたのをgidというparam名に変えた上で文字列で送って更に専用のHTTPヘッダも付けて送信してね」と、

 要はAsana側のAPIの仕様が突然変わった為ということが判明しました。

 なんて変更をしてくれるねんww

 おそらくどこかに仕様変更のアナウンスは来てたのでしょうが末端社員の私のところには届いておらず、Twitterなど調べても世界中で同様の不具合に巻き込まれてる人がいたのでなかなかの不意打ちでした。

 この時はほぼ徹夜で原因究明と対応をし、id系のparam名と型を修正し、headerを追加し、事なきを得ました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/851627/c4413fbe-1c7f-d98d-3606-32951d4a6e43.png)


## 事件その2 zendesk APIの不調
 CAMPFIREではお客様とのやり取りでzendeskというメッセージ管理サービスを使用しています。上の障害の発生直後にzendeskに大量のチケットを作ってしまっていたためかnode-zendeskを使用したチケット登録がうまく行かない状況になっていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/851627/ebc7614b-3865-fee5-c6e5-0e3b9b8a8141.png)

 Lambdaからnode-zendeskを経由してチケット登録をしていたのですが、動かなくなってしまい、
 zendeskそのものは本体側の問い合わせ用のチケット登録はなぜかうまく行ってたので、アカbanやRateLimit的な制限を食らってるわけでは無さそうでした。手間はかかりますが、エラー詳細を追求するためにもライブラリを使わないで登録方法を変える事になりました。
 zendeskのチケット登録APIの仕様は公開されていて割とシンプルでしたのでこのようにNode.jsのrequestオブジェクトで記述しました。

```
exports.createTicketUsingDirectAPI = function createTicketUsingDirectAPI(project) {
  var userEmail = project.user_email
  var userName = project.user_name
  ticket = {//zendeskに登録する本文のリクエスト情報
      subject: project.name,
      comment: {
        body: project.body
      },
      external_id: project.id,
      requester: {
        name: userName,
        email: userEmail
      }
  }
  return new Promise((resolve, reject) => {
    URL = 'https://xxxxxxx-zzzzz.zendesk.com/api/v2/tickets.json';//zendesk登録APIのエンドポイント
    request.post({
      uri: URL,
      headers: { "Content-type": "application/json" },
      auth: {
        user: cred.username, //zendesk契約アカウントのログインID・pass
        password: cred.password //zendesk契約アカウントのログインID・pass
      },
        json : {ticket: ticket}
    }, (err, res, data) => {
      if (err) { //通信エラー時はreject
        reject(err)
      }
      if (data.error) { //APIエラーはdata.errorに返される（通信自体は正常なのでログだけ吐いて次へ進む）
        console.error('zendesk data.error found');
        console.error(data);
        console.error(data.details);
        console.error(ticket);
      }
      result = data.ticket;
      if (result && result.url) {
        result.web_url = result.url
        .replace('/api/v2/', '/agent/')
        .replace(/\.json$/, '') //response内容からticketのURLを整形する
      } else {
        result = {};
        result.web_url = 'zendesk ticket failed' //URLに登録できなかった旨を返して先に進ませる
      }
      resolve(result) //エラー時にrejectするとタスクが作られなくなるので、Error拾ってもPromise成功扱いにする
    })
  });
}
```
何故かこれでうまく行きました。
 zendesk API登録はシンプルなのでこのように自前で書いたほうが安定してるようです。例外処理など小回りがきいてこちらのほうが良いですね。

##事件その3 Zendeskのパスワードが変わった
　上記のZendeskの通信APIは他のwebAPIリクエストでよくあるAPIキーを使用したものでなくHTTP認証のusernameとpasswordを使用したものであるためIDとパスワードを他と共用していた場合、運用チームで人が入れ替わったりのタイミングでパスワードが変わってしまうことがあります。その度Zendeskの通信APIが止まって修正デプロイが必要になる状況でした。
　修正デプロイを何度かしています。以前はzendeskの例外発生時にAsanaのタスク生成もとまる仕様になってしまっていたため、その度に審査業務が止まってしまい審査スケジュールに迷惑がかかることもありました。
その後zendeskのエラー時もタスク生成は動くように改修しました。
（本来ならパスワード変更に影響されないようにすべきですが、この点は現在も解決しておらず今後の課題として改善必要な部分です）

## 事件その4 AsanaでRateLimitエラーが頻発

　2020年4月にコロナウイルスサポートプログラムが始まりプロジェクトの申請数が桁違いに増えました。その結果平常時でもAsanaのAPI登録数が1時間50件を軽く超えるようになってしまいRateLimitエラー(API叩きすぎエラー)が頻発するようになってしまいました。

　Asanaの契約プランを見直すという手段もあったのですが、node.js側で登録タイミングをずらすような処理を入れることで解決させましたのでその方法を解説します。

### Promiseのresolveを呼ぶタイミングをsetTimeoutでずらす
　1時間に50件以上の登録にならないようにすれば良いのだからAPIを叩く前に一定時間のsleepを入れれば良いのですが、

　JavaScriptにはsleepがない。

　そこでPromiseのresolveを呼ぶのをsetTimeoutで任意ミリ秒ずらす関数を用意しました。このようなものです。

```
function promisedSleep(ms) {
    return new Promise(function(resolve) { 
        setTimeout(function() {
        	console.log(ms + " ms sleeped");
            resolve() ;
        }, ms)
    })
}
```
　こういうものを定義してやればPromiseをreturnしていた既存の関数のレスポンスを下記のようにする事で後続の処理のタイミングを任意でずらすことができるようになります。

```
return promisedSleep(j* 400 /* 直前でj++で加算することでthenの内容を400ミリ秒ずつずらして実行*/).then(() => { /* 後続の処理*/ })
```
　このときの改修は画期的でRateLimitに触れないギリギリの頻度で最大限のAPI通信回数を図ることができ今後どのように申請が集中してもRateLimitにならないようになりました。Node.jsでsleep的なことをやりたい人は是非参考にしてみてください。
 
　これでようやくRateLimitエラーからも開放され平穏な審査システムが正常の運用軌道に乗るようになりました。

## 終わりに
　現在この私の血と涙と汗の結晶である審査システムはリプレイスが予定されていて、railsのActionCableなどを使った自前の高機能な審査管理画面に移行する予定ですが、そちらでももちろんトラブルはつきものだと思いますし、人手が足りてなく今後も誰がメンテするのかも未定です。

　CAMPFIREでは運用・開発を一緒にやってくれるメンバーを随時募集しております。
　この記事を読んで少しでも興味を持った方おられましたら応募してみませんか？
　https://www.wantedly.com/companies/campfirejp/projects


## 結局AWS Lambdaってどういう時に使うのが良いの？
　この審査システムのようにLambdaは本体システムとは別に非同期でAPIを叩く用途には適してるのかなと思っています。
　DBアクセスもLambdaの専用ユーザをMySQLに作ればAWSのVPC内で安全に接続できるし、Node.jsのrequestオブジェクトを使えばSlackやChatworkに通知を飛ばすのも簡単にできます。

　弊社では社内の統計情報等の通知系はGoogleAppScriptでやることが多く今も大量のシートがGAS内に蓄積されてます。

　が、Lambdaを使ってそれらをまとめることも比較的簡単にできそうです。

　例えば通知のスケジュールや検知用SQLと通知本文や通知先とAPIキーなどをデータベースに登録しておいて、数分に一度Lambdaから通知のスケジュールを確認してDBアクセスして、通知条件の揃ったものをリアルタイムで通知させるような仕組みですね。

　管理画面で通知ルールを管理できるようになればなおさら良いですね。