この記事は、本番環境でやらかしちゃった人 Advent Calendar 2020の18日目の記事になります。
自己紹介
バックエンドのエンジニアとしてそろそろ8年が経ちます、星光輝と申します。
守備範囲はサーバーサイドですが、アプリ・インフラ・テスト・開発環境改善あたりの経験もある、広く浅い系エンジニアです。
実務で経験した言語は、Java/C#/Ruby/PHP/Python/TypeScript/swift/kotlin...と様々です。
昨年度もこちらで参加させて頂きました。
顧客のコンテンツデータを消失させた話
案件概要
APIサーバー+スマートフォンアプリの構築案件で、私は APIサーバーの実装を担当しています。
このプロジェクトでは、サーバーレスアプリケーションで作られることが決まっており、
chalice という Python フレームワークを使って実装をしています。
私の参画時点での経験値は、
- Python: それほど実装経験なし(バッチのスクリプトで記載したことがある程度)
- サーバーレス: 全くなし
- AWS APIGateway: 初めて使う
- DynamoDB: 見たことあるー
俗にいうド素人だったわけですが、当案件における私の役割はリードエンジニアでした。
チーム構成としては、インフラ、アプリ、サーバー、テスト、PMO のチームがあり、合計15名程度の中程度の案件です。
経緯
あの日あの時あの場所でパスを間違わなかったら...
chalice は、規則に沿ってプログラムを書けば、APIGateway, Lambda などを自動で作ってくれるという、
インフラ側を詳しく知らない人にはとてもありがたいフレームワークです。
プロジェクト初期の頃、検証のために chalice を使っていて、
Authorizer で認証しない場合、どんなメッセージが返ってくるんだろう...と思い、
下記のように、authorizer をつけた状態でリクエストしてみました。
from chalice import Chalice, CognitoUserPoolAuthorizer
app = Chalice(app_name='sample')
authorizer = CognitoUserPoolAuthorizer(
'SamplePool',
header='Authorization',
provider_arns=['arn:aws:cognito-idp:ap-northeast-1....']
)
@app.route('/', authorizer=authorizer)
def index():
return {'hello': 'world'}
$ chalice deploy
Creating deployment package.
Reusing existing deployment package.
Creating IAM role: sample-dev
Creating lambda function: sample-dev
Creating Rest API
Resources deployed:
- Lambda ARN: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXX:function:sample-dev
- Rest API URL: https://q13wuyd6hi.execute-api.ap-northeast-1.amazonaws.com/api/
$ curl https://q13wuyd6hi.execute-api.ap-northeast-1.amazonaws.com/ -w '%{http_code}\n' -s
{"message":"Forbidden"}403
なるほど 403 なのか。(※注意: 正しくは https://q13wuyd6hi.execute-api.ap-northeast-1.amazonaws.com/api/)
そして、ログインユーザを認識するために、トークンからログインユーザを取る処理を書く。
Authorizer で認証情報が正しいのはわかるが、何かのエラーで取れないことはある。
そこで、デフォルト chalice の標準エラーが 403 だから...403にしよう。(※正しいパスを叩けば401)
import boto3
from chalice import Chalice, CognitoUserPoolAuthorizer, ForbiddenError
app = Chalice(app_name='sample')
authorizer = CognitoUserPoolAuthorizer(
'SamplePool',
header='Authorization',
provider_arns=['arn:aws:cognito-idp:ap-northeast-1....']
)
@app.route('/', authorizer=authorizer)
def index():
token = app.current_request.headers['Authorization']
try:
user = boto3.client('cognito-idp').get_user(AccessToken=token)
except Exception as e:
raise ForbiddenError('Forbidden')
return {'hello': 'world'}
次第になくなるコミュニケーション
chalice はプログラムコードからインフラ環境を含めて作られるため、色んな学習コストを抑えることができます。
しかし、それは同時にプログラムコードが、インフラと密に連携したシステムと言えると思います。
つまり、基本的に、chalice に従うということが暗黙の了解になりました。
その過程で、APIGateway, Lambda は chalice で作られるからAPIサーバー側で。
Cognito, DynamoDB と言ったデータストアは、業務要件が関わるためAPIサーバー側で開発の過程で増やして構わない。
...と言った感じになり、アプリ側とインフラ側が互いに関わることが少なくなりました。
———— そして、先ほどのコードが生み出された後、5ヶ月後のお話。
そして時は動き出す...
デプロイを改良すべく、CloudFormation を使ったデプロイをすることになりました。
chalice では、chalice package
コマンドで CloudFormation のファイルに変換することができます。
そして、一通り動くことを完了して AWS コンソールを確認していたときのこと。
....? なんだこれは
作っていない、ステージ(Stage)ができている!
どういうことだろうと思うと、既存のバグらしい。
解決策は、下記を OpenAPIバージョンを 3.0.2 にすること!簡単!
消えた!一応、処理確認。うん、何個か動かして動いているから問題なさそう!
(しばらくして)
「なんかアプリ使っていて、しばらくした後にアプリが全く動かなくなったんだけど??」
!!!!
原因
- chalice が作る CloudFormation テンプレートファイルは Open API 2.0 相当の記法だった。
- OpenAPI 2.0/ 3.0 で Authorizer 定義キーが異なる(securityDefinitions/securitySchemes)
- Authorizer の定義が無視されてしまった
- アプリ側は、トークンの有効期限切れをステータス401で検知している
- Authorizer でトークン期限切れを検知できず、ユーザ取得部分でエラーが出るようになり 403 が常に返ってくるようになった
惨劇はなぜおこってしまったのか
割れ窓の放置
潜在的なバグを作っていたにも関わらず、検証せず放ったらかしにしたこと。
ここがバグっていなければ、このようなケースにはならなかったはず...。
言い訳をするならば、Authorizer を外さない限りは該当部分のテストはできず、Authorizer を外すことはなさそうだったので、正常系の確認のみで確認したつもりになって放置されたのだと思います。
思い込み
動作検証時に気づけなかったこと、検証できていなかったこと。
それを生んでしまったことは、下記のような思い込みが原因だと思います。
- CloudFormation の定義が問題になるなら、エラーが出るはずと思っていた
- 仮に Authorizer がなくても、その後のユーザ取得で同じようになるから影響ないと思っていた
確認内容の把握不足
確認すべき項目をインフラ側とコミュニケーションを取り、確認すべき項目を詰めていなかったことも
今回の問題点だと思っています。これができていれば、検証段階で気づけたかもしれません。
なお、自身では直接 API を実行して、挙動確認をしていましたが、
Authorizerの設定は下記を見れば確認ができました。これは後で知りました。。。
二度と惨劇を起こさないためにどうしたのか
割れ窓を直す
ユーザの取得のエラーを正しい内容(401, Unauthorized) に変更。
念の為、Authorizer を外して動作確認して問題ないことを確認しました。
テスト仕様書の作成
今回の思い込みの部分というのは、他人から指摘されない限り変えることはできないはずです。
...なので、そのあたりを明文化して他の人に確認してもらうしかなさそうです。
今回の件を踏まえて、デプロイ実行に関する確認点をテスト仕様書として作成しました。
そして、動作確認時の確認項目に関してインフラ側にも確認して頂きました。
まとめ
今回は昔のバグを放置し続けて、別項目のバグと複合してサービスを止めてしまった話を記載しました。
CloudFormationによるデプロイ化は、自身でも経験のないタスクという認識をしており、
なるべく気をつけて作業をしていましたが、やはり、一人の力ではその精度には限界があるように思います。
また、関連しそうな人を巻き込みコミュニケーションを取れば、自身の負担も多少は軽減されていたように思います。
今回の件は、気づいた時点で即対応して、PMO側でも迅速に対応したため、顧客から厳重注意はありましたが、大ごとにはならずにすみました。
こんな風に助けられて成り立っているのだなぁということを改めて思い知らされました。
やってしまったことに関しては取り戻せませんが、それを上回るほどの貢献をして取り返そうと改めて思いました。