「機能は追加(修正)する」「事故も起こさない」。 「両方」やらなくっちゃあならないってのが「エンジニア」のつらいところだな。 覚悟はいいか?オレはできてる。
事故の定義
ここで述べる事故とは、ユーザのサービス利用を妨げる事象、サービスの信頼が失われる事象を指す。
事故足りうる事象に次のものが挙げられる。
- インフラ障害(ネットワーク障害、サーバ障害)
- DB障害(データ不整合、デッドロック)
- 脆弱性(情報漏えい、インジェクション、改ざん、不正アクセス)
- メール・SNSの誤送信
- 実装バグ(二重課金、サーバエラー、操作不能、意図しない動作)
事故足り得る前提として1つ目に規模の問題がある。10人が使ってるサービスの障害と100万人が使ってるサービスの障害を比較するのであれば、明らかに後者のほうが重大な事故である。次にサービス全体へ影響を与えた障害なのか、ごく一部のユーザにのみ影響を与えた障害なのか事故の規模を考慮する必要がある。
(あまりにも規模が小さいものであれば、事故が起こった原因は置いておいて対応としては示談で済むかもしれない)
2つ目にサービスが取り扱っている情報の重要度に依存する。例えば、個人情報を扱っていないサービスであれば盗まれるものが無いのでセキュリティに関してあまり考えなくてもよいし(今どきそんなサービスほぼ無いけど)、銀行のシステムであればネットワークアクセスログはもちろん全て取る必要があるし、改ざんも不正アクセスも防がないといけない。
人間はミスをする生き物なので、手動でやる限りいつか同じミスをしたり、別の人だったら同じミスを犯す。
いずれもチェックが手動だけでなく自動化できればそれに越したことがない。
事故が起こる要因として実施者の想定の範囲外で起こるケースが多々にしてあるので規模が大きいシステムを運用している場合はメンバー間で情報を共有することや修正によってどこに影響を与えるかキーワード検索したり想像力を働かせることが大事。
(例えば、修正変更がメインシステムだけでなく担当範囲外を超えたサブシステムに影響を与える場合など)
監視や通知の仕組みを作り、エラーメッセージを読むことが大事。
AWSやGCPなどのクラウドサービスでシステム構築してる際は、Datadogを使うのがおすすめ。
なお、特に言語や実行プラットフォームは何でも良いのだがPaaSクラウドを用いて構築された一般的なウェブサービスを想定して書いている。
インフラ障害
主にネットワークや認証・認可周りの設定間違いやサーバリソース不足に関して事故が起こりやすい。
一度設定すればほぼいじらないものと、機能改修によって都度チェックが必要なものと、常に監視が必要なものと分類される。
プライベートネットワークの設定間違い
AWSなどでVPC(Virtual Private Cloud)を使ってサーバ間のアクセスに仮想プライベートネットワークを構築する際、
以前まで疎通できていたサーバ間の通信が繋がらなくなるような事故。
対策としては設定したネットワークの各サーバ間、サーバからインターネットへの疎通確認をすること。
一度確認したらネットワークを再構築する際以外は起こりえない。
DNSレコードの設定間違い
AWSのRoute53のようなサービスでDNSレコードを設定している場合にCNAMEレコードやAレコード、TXTレコードなどの設定を間違えてしまい起こる事故。
ドメインを変更した際などに設定忘れしたり、疎通確認を忘れた場合に事故が起きる。
一度確認したらドメインを変更する際やレコードを変更する以外は起こりえない。
SSL証明書の期限切れ
SSL証明書の期限が切れて数年後にいきなりhttps通信できなくなり、ブラウザ警告が出る事故。
SSL証明書の自動更新設定をするか、期限が切れる前に通知するなどの対策が必要。
認証・認可の設定間違い
クラウド関連の機能追加・変更する際にクラウドサービスへのアクセス権限を間違えて変更した場合などに起こる事故。
例えば、AWSのIAMを使っている場合などにS3アクセス権限をReadの権限のみでWriteの権限を忘れたりなどに起こる。
対策は実際にプログラムや設定したユーザ権限で実行してアクセスできているか書き込みできているか確認する。
キャッシュの設定間違い(CDNの設定)
CloudFrontなどのCDNの設定で特定のURLパスに対してキャッシュを持つことができる。
キャッシュを持つことで2回目以降は高速にレスポンスを返すことができるが、
whitelistパラメータの設定漏れがある場合、パラメータがALBやサーバに渡されていない事故が発生する。
APIキャッシュを作成する際は実装パラメータを追加した際、whitelistにも追加する必要があるので注意したい。
サーバ障害
ここで指すサーバはAPIサーバ・DBサーバなどを含む。いずれも常時監視が必要。
原則、単一障害点を作らない。
(例えば、サーバが一台しかなくてサーバを落としたらシステム全体が止まるなど)
以下のサーバのリソースが問題となる
- サーバのCPU性能不足による遅延
- サーバのメモリ不足
- サーバのディスク不足
具体的には次のような対策がある
CPUスペックを上げる
無限ループなど負荷が高い処理でCPUが100%に張り付いた状態が続くと他の処理ができなくなり、パフォーマンスが落ちる。CPU稼働率とAPIレスポンス時間などを監視し、CPUリソースが足りない場合は負荷がかかっている処理を修正したり、CPUスペックを引き上げる必要がある。
また、AWSのt2インスタンスやHerokuのdynoなどはCPUリソースの上限があり、上限を超える稼働はサーバが止まるので注意が必要。CPUブーストを使ったり、課金したりする必要がある。
スワップ領域を作成する
スワップ領域を作成しておくことでメモリ不足になったときにディスク領域を一時的に使うことができる。
スワップ領域はディスク領域のため、IO処理が発生しパフォーマンスは落ちるがメモリ領域がパンクして最悪サーバが止まることはない。
(スワップ領域を使いだす前にはメモリを増やしたいが・・・)
メモリがパンクしてるとvimなどのエディタでファイルを開くこともできなくなったりするので、サーバを再起動したり、lessでファイルを開いて編集したりする。(過去の経験上lessが一番軽かった)
ログローテーションを設定する、アップロードファイルはAPIサーバ内に保存しない
ログファイルを何もしないとサーバのディスクを専有していき、いずれディスクをパンクさせる。
logrotateコマンドを使うことで古いログファイルを定期的に消すことができる。(これによりログファイルでのパンクを防げる)
ファイルのアップロードなどをサービスで扱う場合はサーバに直接アップロードするのではなく、S3などのファイルストレージサービスにアップロードする。
バックアップを残す
AWS EC2を使っているのであれば、AWSの設定画面にてEC2インスタンスまるごとバックアップを取ることができる。主にすぐに復旧できない不具合があった場合にDBのバックアップと合わせて、ロールバックする。
監視設定をする
CPU、メモリ、ディスクやヘルスチェックに対して一定時間レスポンスがないなどに関して、
Datadogで閾値を超えたら通知(メール、Slack)を飛ばすことが可能。
冗長化、オートスケールする
サーバを複数台構成にして、ロードバランサー経由でのアクセスにすれば、1台落ちても別のサーバでカバーできるため、システムを止めなくてすむ。(ロードバランサーのヘルスチェックでサーバを死活監視している)
負荷が増えてきた場合はサーバの台数を増やしてオートスケールする。
AWS LambdaやFirebase Functionsなどのサーバレスなマネージドサービスで稼働するのであれば、オートスケーリングも自動でやってくれてメモリの増設などは設定のみで済む。
また、Dockerコンテナでサーバを作成している場合はAWS Fargateを使ってDockerコンテナをオートスケーリング稼働する方法もある。
クラウド自体の障害を確認する
まれにだがクラウドサービス自体が障害を起こしている場合がある。
AWS:AWS Service Health Dashboard
GitHub:GitHub Status
長期的に復旧されない場合はビジネスインパクトが非常に大きい、対策としては一時的にリージョンを分散させて冗長化させるなどがある。
実装バグ対策
事故の原因としてはこれが一番多い。
実装者がシステム仕様や言語仕様やライブラリに関する理解が乏しくて発生する場合が多い。
また技術的負債が溜まっており、設計ミスやコードの可読性や統一性、検索容易性が失われている場合、より事故が起きやすい。
コードレビューで見るべきものに関してはコードレビュー虎の巻にまとめた。
セキュリティの事故
ユーザのシステムの信頼を失墜させる事故。個人情報の流失や最悪金銭的な損害にも発展する。
当然だが、httpだと盗聴される(というかもはやブラウザで警告出る)のでhttps通信する。
ユーザが比較的自由に入力できるフォーム入力周りはセキュリティホールになりがちなので特に注意する。
- XSS:悪意のあるユーザがブラウザで実行できる悪意のあるJSスクリプトなどをフォーム送信してDB保存する。DB保存されたデータを別のユーザがブラウザで参照した場合に悪意のあるJSスクリプトが実行され、ブラウザに保存されているログイントークンなどの情報を悪意のあるユーザに送信してしまう。対策としては入力時にエンコードしてしまう、フロントエンド側で実行コードとして表示しないなどがある。Reactなどのフレームワークを使っている場合は自動的に無害化してくれる。(dangerouslysetinnerhtmlは除く)
- SQLインジェクション:悪意のあるユーザがSQL文をフォーム送信し、DBの情報を盗み取ったり改ざんしたりする行為。直接ユーザ入力内容をクエリに埋め込むのでなく、プレイスホルダ機能を使うなどでクエリに問題がある際は実行させない方法などがある。
- セッションハイジャック:他のユーザのログイントークンを盗み出し、あるいは推測し、他のユーザとしてログインできてしまうこと。これができると他のユーザになりすましや情報取得できてしまう。ログイントークンを改ざんや流出させない、ログイントークンをユーザ別に発行せずにid=1など推測しやすい情報でセッション切り替えできてしまうなどの実装をしない。ログイントークンには改ざん不能なJsonWebTokenなどを使う。
- CSRF:APIサーバがCORFを許可している場合、外部のサイトからフォーム送信できてしまう。このためフィッシングサイトで本物サーバ側へリクエストさせ、ユーザの情報を盗む手段に使える。特にログイン情報など重要な情報を送信するフォームにはフォームを表示するたびにワンタイムトークンを埋め込み正規のフォーム送信か確認する。
- DoS攻撃:無意味な大量アクセスでサーバをダウンさせようとする攻撃。ファイアウォール機能でIPを一時的にbanするなどの対策がある。AWSだとWAFを使うなど
他にも色々あるが、パスワードは生でDB保存せずにハッシュ化する。認証必須のAPIと認証不要のAPIを切り分けるは必須。
ルーティング周りの事故
パス追加時に他のパスを上書きしてしまったりキャッシュ起因で発生する
ルーティング追加時に他のルーティングを上書きしてしまう
例えば、APIパスを追加した際に既存のルーティングを上書きしてしまい、対象のAPIにアクセスできなくなる事故。
(SPAであればReact Routerなどの疑似ルーティングのRouteを上書きして、対象のページが表示できなくなる事故もある。)
POST /api/hoge
POST /api/hoge/:id // ←追加
POST /api/hoge/:key // ←URLパラメータ的には異なるがパス的には上のAPIが優先され、到達できなくなる
追加する際は他のパスのアクセスを上書きしていないか確認し、順番を変えたり、別のパスにするのが大事。
具体的にはAPIの呼び出しテストを作成することでCIで再帰テストをすることが可能。
指定のパスに対しての呼び出しテストなので呼び出される想定の関数はモック化して良い。
キャッシュがあるのに古いAPIやページのパスを消してしまう
古いAPIやページのURLを削除してしまうとブラウザキャッシュが残っていて古いAPIやページのURLにアクセスが来てしまい問題となる。
特にSPAの場合、古いbundle.jsはCDNキャッシュ&ブラウザキャッシュが消えるまで&ブラウザリロードするまで残り続けるため、CDNキャッシュクリアと古いAPIは新しいAPIにリダイレクトする必要がある。
他にも、Google Botなどは古いパスのキャッシュを持っているため、古い方にアクセスが来る。
キャッシュを使ってる場合は古いAPIやページにアクセスが来るので新しいページに301リダイレクトする必要がある。
外部API起因の事故
外部サービスのAPIを呼び出している場合、外部APIの仕様を確認する必要がある
エラー時の処理
考慮漏れしがち。API仕様を見てもどんなエラーが返ってくるかわからない場合や通信エラーの場合でも制御する必要がある。
エラー時に後の処理を行うかの判断もしないといけない。
APIリクエスト上限
これもAPI仕様を見ていないと見落としがち、タチが悪いのはlocalだとリクエスト数少なくて問題ないが、本番環境に上げたら、大量にリクエストしてしまってエラーになる場合がある。課金などで上限を上げれる場合が多いが、代替手段がある場合はそもそも使わないか元が取れる場合に限る。上限を上げれない場合は、APIリクエスト数の上限を超えないようにキューイング(バッチ化)するか、リアルタイム性を求められる場合はエラーとして返す(ユーザに待ってもらう)方法がある。
API呼び出しアカウントのセッション切れ、セッション上限
ステートレスでない(Rest API)でないステートフルなAPIの場合、ユーザアカウントでのセッションを保つ場合がある。セッション切れした場合は再度ログインする必要があるので再ログイン処理の対応も必要となる。
外部サービスのセッションを持つAPIなどはセッション上限などを超えないようにする必要がある
メモリリーク
GC(ガベージコレクション)が無い言語(C、C++など)はもちろん動的メモリ確保した際に利用後、明示的にメモリ解放しないとメモリ領域を食いつぶす。
GCがある言語(JavaScriptなど)でもnewでメモリアロケーションしたインスタンスが循環参照してる場合はGCでもメモリ解放されずメモリリークとなる。
メモリリーク箇所の検出を行い、循環参照している場合は弱参照(WeakRef)やスマートポインタを使う方法がある。
そもそもnewを極力使わない、循環参照させないのも手である。
ちなみにJavaScriptの場合はプロポーサル段階だがWeakRefが存在しているのと
NodeJSのメモリリーク検出方法が参考になる。
パフォーマンス低下による事故
レコード数が多いテーブルはパフォーマンスに注意する。
バックエンドの処理はパフォーマンスが低下するとレスポンス時間が遅くなりシステムがハングする。
readの方はテーブルのよく検索に使われるフィールドにindexを貼る、マイグレーションスクリプトやバッチ処理での大量Writeはbulk処理をして短時間で書き込む対策が必要。
あとはマルチプロセス(cluster)でCPUコア数分サーバ起動することで暫定的な負荷分散はできる。
システム全体のパフォーマンスのボトルネックを見つけるにはframegraphを出力する。
例えば、NodeJSは0から始めるNode.jsパフォーマンスチューニングに調査方法がよくまとまっている。
データ不整合の事故
データ挿入などのデータマイグレーションスクリプトの実装間違えや途中エラーはデータの不整合を起こすので、トランザクションで実装し、問題がある場合はロールバックする。
さらには実行後に問題があった場合に備えて、実行前にはデータのバックアップも取っておく。
また、課金周りなど複数テーブルへの書き込みが必要な重要な処理はトランザクションして不整合を防ぐ。
APIマイグレーションの事故
サブシステムのAPIをメインのシステムから参照している場合にサブシステムのAPIをアップグレードして別のデータを返す必要がある場合、
基本的にサブシステムとメインシステムを完全同時にリリースすることはできないので、ステップ踏む必要がある。
- サブシステムの新APIを実装、旧APIはまだ消さない。サブシステムをリリースする。
- メインシステムにサブシステムの新API呼び出しの処理を実装、旧APIを消す。メインシステムをリリース。
- サブシステムの旧APIを消す。サブシステムをリリースする。
変更がサブシステムに影響を与えてしまう事故
変更がメインシステムだけでなくサブシステムにも影響を与えないか考慮する。
この辺はシステムの全体像がわかっていないと厳しい、有識者が実装、レビューするしかない。
普段から情報をシェアし合う体制が必要。
特に起こりやすいのはDBのテーブルフィールドを変更・削除した場合に
redashなどのBIツールのクエリやsalesforceなどの別システムへのデータ同期を自動で行っている場合などに影響がでて事故る。
排他制御の事故
DBトランザクション、マルチスレッドの排他制御などは、処理をブロッキングしてしまうため、解除し忘れるとシステムをハングさせる。(デットロック)
例えばトランザクション開始時にtry構文でwrapしてやり、finally文で必ずロック解除するようにするなどの対策がある。
ライブラリ(OSS)のバージョンアップに伴う事故
これはnpmやgemなどのパッケージマネージャーツールで3rdパーティライブラリを管理している場合に起こる
ライブラリを使って良いのは保守を上回るメリットがある場合だけで、オーバスペックなライブラリは容量を食うし(特にユーザがアプリやJSファイルをDL際に影響する)、ライブラリのバージョンアップが義務付けられるのでそもそも不要なライブラリは入れずに言語仕様や標準のAPIで実装する。
ライブラリのバージョンは動作確認が取れるまで無闇に上げずに固定しておく(メジャーバージョン、マイナーバージョン、パッチバージョン)。
また、package-json.lockやyarn.lockなど詳細な依存関係を管理しているファイルは3rdパーティライブラリが依存しているライブラリのバージョンが記載されているため、無闇に消してはいけない。
これらのファイルを消してしまうと再インストールした際に3rdパーティライブラリが依存しているライブラリのバージョンが引き上がって事故ることがあるからだ(1回あった)。
技術的負債
事故直接の要因ではないかもしれないが、怠ると事故を引き起こす要因となりえるもの。
型付きの言語で実装する
特にバックエンドは型付きの言語で実装したほうが良い(NodeJS+TypeScript、go、Java)。
理由としては、静的コンパイルによってケアレスミスが防げるからだ。
- 型チェックで意図しない型のパラメータが引数に渡ってしまうのを防げる
- 型チェックでパラメータの引数への渡し忘れを防げる
- 型があることでprimitiveなデータなのかクラスやオブジェクトの型なのかすぐに判別がつく
- 型チェックがあることでoptionalな引数かそうでないかが型定義でわかる(TypeScriptの場合)
- 戻り値の型がわかる
TypeScriptでの実装の場合、TypeScriptの為のクリーンコードが参考になる。
設計
KISS(シンプルな設計・実装にする)を心がける。
クラスを使う場合、SOLID原則、デメテルの法則も意識すると仕様変更にも強く、テストしやすい。(依存と関心の分離)
- デフォルト引数を与えてフェールセーフにする、ただし空関数をデフォルト引数に指定するなどの場合は実装漏れなどはエラーは握りつぶさずにエラーログを送信してすぐ発見できるように通知する
- DBテーブルフィールドの直接の変更、削除は事故になるので別フィールドを追加して処理とデータも移行してから元のフィールドを削除する
- DRYに則って同じような処理は関数に共通化する。変更が少ないユーティリティは共通化してもよいが、過度な共通化は無駄に影響範囲を広げてしまう・・・あなたはDRY原則を誤認している?
- 呼び出し箇所が多い関数やテーブルフィールドの修正時には影響範囲に注意する。依存グラフをツールで出力するなどで把握する
- ビジネスロジックはインタフェース、abstractで抽象化する方が仕様変更に対応しやすいので望ましい。実装は委ねられるが、引数と戻り値の型が保証される。
可読性、検索のしやすさ
命名規則、コーディングルールを統一する。プロジェクトが小さいうちは良いのだが、プロジェクトが大きくなるとファイルの即時検索ができないと明らかに作業効率が落ちる。
キャメルケース、スネークケースなどはどれか一つに統一する(混ぜない!)。
意外に大事なのがtypoを防ぐ、別の意味で使っているのに同じ変数名や関数名にしない、表記揺れをなくす。
これは、修正漏れを防いだり、誤解を招くことに起因する。(ドメインモデルを統一する)
プロジェクト内の既存のtypoの検索にはあいまい検索でfzfなどが使える。
コード量に比例してバグの量も増えるので、YAGNI(無駄な実装をしない、残さない)を常に心がける。
- lintを入れてコーディングルールを統一する
- 適切なコメントを入れる(主に機能やビジネスロジックの仕様の説明)、できる限り簡潔に書く
- ファイル名、変数名、関数名は命名規則を統一する、中身がわかりやすい名前をつける(typoしない)
- コードが追いにくくなるので関数の呼び出し(コールスタック)を深くしない
- ネストは深くしない(条件分岐の早期リターンする、非同期コールバック処理はawaitする)
- 変数、DBテーブルフィールドのダブルミーニングはしない
- 継承より合成を優先して使う(継承だと不要な変数、メソッドまで継承するリスクや可読性・メンテナンス性が落ちる)、継承自体を禁止する必要はないが、子継承までが限界だと思う
PRの掟(おきて)
事故を起こさないようにするためのPRのルール、PRの役割を複数持たせない(単一責任)
コード量に比例してエラー数も増えるため、修正量は少なくする。
特にソースファイルをまたいでいる数が多い修正や依存が強い箇所の修正は危険なので、極力修正を混ぜない。
リリース後作業が必要なもの、重要な修正に関してはチェックリストをつける。
- リファクタリングは機能追加、機能変更と同時にしない、PRを分ける
- 大きすぎるリファクタリングは小分けにリファクタリングして、PRを分ける
- PRを作成するときのテンプレートにチェックリストを作る
- PR作成時にlint、テストを実行する
テスト
資産になるテストを書き、CIで再帰テストを行う。
- 正常系、境界値、異常系の単体テストを書く
- 重複したテストを書かない
- できる限り並列テストにする
- 条件が複雑なものほど再現が困難なため単体テストで網羅する、単体テストできるような構造にする
- 単体テストとAPIテストなど異なる種別のテストは同じファイルに書かない(フォルダ分けする)
- E2Eテストは壊れやすいが網羅性が高いので、サービスのコア機能などにピンポイントで使う
- 表示の差分テストはビジュアルリグレッションテストが良い(UIライブラリのバージョンアップにも追従できる)