見出し画像

2022年の開発を振り返る

こんにちはりょうまです。今年も残すところあとわずかとなりました。
ありがたいことに2022年も実に濃い経験をさせていただいたので、今年やったことの振り返りをしていきたいと思います。

完全に自分の振り返りの忘備録なので、面白みもなくかなり長いです。エンジニア4年生がどんな開発をしてるんだろ?と興味を持った方に向けてリアルな内容を書いていきます。

CognitoによるOAuthベースの認証

新規プロダクトの認証にてCognitoを使用しました。セッション漏洩リスクに備えるべく、有効期限の短いアクセストークンと期限の長いリフレッシュトークンを組み合わせるOAuthの形式に沿って実装しました。

参考


Go APIのエンドポイントをAPI Gatewayに自動で反映するパイプラインを作成

APIをAPI Gateway × Lambdaで動かす際、エンドポイントが増えるたびにAPI Gatewayにてルート作成 → Lambdaに統合 という工程が発生します。当初はこれを手動でやっていました。とんでもないトイル(苦労)です。

解決のため、以下の方法で完全自動化を実現しました。

  1. API GatewayクライアントのGo SDKを導入

  2. Gin(GoのFW)のルーティングを定義する箇所を関数に切り出し

  3. Ginのルーティングより、API のメソッドとパスの組み合わせを文字列配列で取得

  4. API Gatewayの既存のルーティングをSDKより取得

  5. 3と4の差分を確認し、新規で登場するエンドポイントを検知しデプロイ

  6. 1 ~ 6 をスクリプト化し、Github Actionsから実行

これにより、エンドポイントを1つ生やすたびに発生していた5 ~ 10 分の手作業および、手動設定によるヒューマンエラーが0になりました。

API関連のインフラをコード管理する方法も検討しましたが、アプリケーションのルーティング → IaC側のAPI Gatewayのコードに反映 はどうしても手動でコピーする必要があり、若干ではあるものの手動工程が残るため見送りました。

Gatlingによる負荷試験

新規プロダクトの負荷耐性を確認すべく、Gatlingを使った負荷試験を実施しました。

下記の例のように、認証を突破するシナリオを作成し、指定のページに対して大量アクセスを仕掛けました。

シナリオをScalaで書く必要があり、若干癖のあるツールだなと感じました笑

Lambda上で動くAPIだったのですが、同時1000アクセスまでAll Healthになることを確認でき、改めてLambdaのスケールアウトの凄さを実感した一件でございました。

OWASP ZAPによるアプリケーション脆弱性診断

OWASP ZAPというOSSを使って、新規プロダクトの脆弱性診断を行いました。

OWASP ZAPを起動し、ブラウザのプロキシ先をlocalhostに繋げた状態で、プロダクトの一通りのシナリオを動かすと、攻撃対象のページのパスや、リクエストを送るAPIのエンドポイント一覧が表示されます。

あとは、AutoScanを行うことで、SQLインジェクションやクロスサイトスクリプティングなどの攻撃をしたり、Cookieにセキュアな情報がのっていないかなどの確認をしてくれたりします。

結果として、クリティカルではないものの、Mediumレベルの脆弱性が見つかったため、管轄のエンジニアが開発できるようにチケットを起票していきました。

この手のセキュリティ関連のタスクは重要度は高いものの、緊急度は低く、後手後手の対応になりやすいです。確実にクローズまで持っていけるよう、できるだけ分かりやすく脆弱性の説明とリスクの可視化を行うように心がけました。

はじめに1つのプロダクトで検証し、期末に全てのプロダクトで実施できるように社内で勉強会を開催し、横展開するための工夫もしました。

Sentryをアプリケーションに導入

アプリケーション内でエラーが起こった際に開発者がすぐに反応できるようにSentryを導入しました。

GoのAPIではerrorインターフェースをラッピングしたカスタムエラー構造体を定義しており、そこにエラーstatus(4xx, 5xx)を持たせられるようにしています。

type ErrorWithStatus struct {
  Status int
  Object error
}

(e ErrorWithStatus) Error() string {
  return e.Oject.Error()
}

エラーを最終的にハンドリングする関数内にて、statusが500の場合にSentryへ通知を送る処理を入れることで、見通しよくシステムエラーを検知することができました。

Sentryへ送られたエラーはSlackのチャンネルへ連携され、クリティカルなエラーの場合は休日に元気よく出勤する流れです。(ネタです)

ドメインの変更、WAFによるリダイレクト設定

新規プロダクトのドメインに変更が入ったためドメイン変更対応をしました。加えて、すでにクライアントへ通知済みのドメインであったため、WAFによるリダイレクト設定を行いました。

WAFにて、指定のhostへアクセスがあればカスタムレスポンスでLocationヘッダーを付与して307リダイレクトさせるという方法です。

これまで、リダイレクトはnginxやアプリケーションサーバで対応することが多かったのですが、WAFでサクッとリダイレクトをかけられる体験は感動でした。

Lambda APIのコールドスタート対策

Lambdaは久方ぶりにアクセスがあると、0からコンテナ作成を行うため、立ち上がるのに10秒以上時間がかかります。リアルタイムでresponseを返さなければならないAPIにとって致命的なレイテンシです。

解決のため、ベタな手段ではありますが、1分に1度、指定パスへHTTPリクエストを送り、Lambdaが温まっている状態を維持できるようにしました。

定期的なアクセスはEventBridgeとLambdaで実現してます。

これにより、APIのレイテンシが激減し、久しぶりにサイトへアクセスしても大幅に待たされる負の体験がなくなりました。

新規プロダクトAのローンチ

2021年10月から開発キックオフしたプロダクトを1年の時を経て無事にリリースしました。技術選定、DB設計、API設計、開発、リリース、運用までフルサイクルで担当したので我が子の出産のように感動しました。

今ではARRで数億円規模のサービスに成長しており、運用が慌ただしくなってきたフェーズです。

Stripeによる決済機能の開発

新規プロダクトAに決済機能をつけるという方針になり、Stripeの調査からプロダクトに導入するための設計、実際にコーディングを担当しました。

課金形態が、月額の定額課金 + 指定の上限を超えた使用については従量課金 という若干複雑な形式でした。

  • Stripe上でのどのデータモデルをDBのどのテーブルと対応させるか

  • 決済が失敗した時のリカバリーはどのように保証するか

  • プランをアップグレードした時は日割りにするのか、翌月適用にするのか

  • クレジットカード払いではなく、請求書によるマニュアル対応するクライアントとの棲み分けはどうするか

など考えるべき項目が多く、難易度の高い実装でございました。

その中でも特に難しかったのが、システムエラーが起こった際の対応です。

基本的なフローとして、月初に請求関連のデータを集計するバッチを走らせ、規定の時刻になるとStripeの請求が自動で走り、決済が行われる、というものです。

従量課金の精算は月が終わらないとできない。しかし、Stripeの請求は月初に自動で走る。もし、月初のバッチ処理で何かしらの不具合があったとしたら??それがもし土日祝日だったら?。。

はい、休日出勤の完成です。こんな脆いやり方では月をまたぐときに安心して寝れなくなりますし、マンパワーと運任せの運用になってしまいます。

解決策として、StripeのWebhookを活用して何かデータ不整合があれば自動請求処理を止める。という対応にしました。

 StripeのWebhookにて、請求書の下書き作成イベントが走った際に、指定のAPIのエンドポイントへリクエストを送るように設定できます。

そちらにて、データ不整合のチェック、何かおかしなところがあれば自動請求をOFFにする。という保険の処理の実装をしました。

月はじめの営業日に請求のステータスをチェックし、何か不具合があればシステム側の修正を行った上で、手動で請求書を作成する。

こうすることで月初にエンジニアや経理の方が焦ることなく、平穏無事に過ごすことができるようになりました。

Go × Gin × AdminLTEによるモノリス構成の管理画面の基盤構築

プロダクトAを無事にローンチしたものの、クライアントが使用開始する際の快適なオンボーディングを実現するには管理画面が必要です。

他社の話を聞くと、管理画面で新規技術を試すケースが多いらしいのですが、今回、管理画面を開発するために課された技術要件は以下の通りでした。

・ 採用を見据えてGoは確定
・ リソース不足のため、デザイナーレスで進める
・ とはいえ、他社も使うケースがあるのでそれなりにリッチなUIは欲しい
・ 納期は3ヶ月〜半年ほど

これらを考慮し、以下の技術選定になりました。

・ Ginのテンプレートを用いたMVCのモノレポ構成(controllerで構造体を作成し、View Templateに渡す形式)
・ CSSフレームワークとしてBootstrapベースのAdmin LTEを使用
・ API Gateway × Lambda のサーバレス構成

Admin LTEの採用理由はCSSやJSを極限まで書かずにそれなりのUIを実現したいためです。CSS弱者の僕でもこれぐらいのUIは作れたので、採用して良かったと思っています。

Admin LTEによる管理画面の例

爆速に作るため、Railsのようなモノレポ構成をGinで完全再現しました。Goでモノレポのプロダクトを作っている事例が極めて少なく、手探りで組むしかなかったのが辛かったところです。

後に同レポジトリの開発を担当いただいたエンジニアの方から、構成が分かりやすく、迷わず実装できた!とお褒めの言葉をいただけて大変嬉しかったです。

なんとか1ヶ月ほどでディレクトリ構成やベンチマークとなるCRUDのページ実装、development環境の構築、自動デプロイのパイプライン稼働まで持っていくことができ、新たに参画したエンジニアの方にバトンを渡すことができました。

「自分がここで頑張らなければ、せっかく参画いただいたエンジニアの方を手持ち無沙汰にしてしまう。。」というプレッシャーがあったので、これまでで最も集中して開発できた期間でもありました(笑)

この記事を書いている12月には素晴らしいクオリティで管理画面をリリースすることができ、開発いただいたエンジニアの方に感謝の気持ちでいっぱいです。

React × Next.js によるフロントエンド開発

新規プロダクトのフロントエンド、バックエンド間の連携を強化すべく自ら手を上げてフロントエンドの開発を担当させていただきました。

CSS FWとしてTailwind、CSS-in-JSライブラリとしてstyled-components、状態管理としてProvider Context(一部 Recoil)、Atomic Designによるコンポーネント設計、TypeScript という構成です。

主に担当した機能は以下の通り。

  • ログインユーザーの権限による表示出しわけ、

  • ログインしているワークスペースの情報の更新

  • インクリメンタルサーチ機能(候補検索)の実装

  • パンくずリストの作成

  • リリース前のバグ修正対応

フロントエンドを担当したことで、これまで自分の中でブラックボックス化していた箇所がクリアになり、フロントエンド、バックエンド間のコミュニケーションコストが大幅に下がったように思います。

加えて、敏腕フロントエンドエンジニアの方々とともに開発でき、大変貴重な経験になりました。

StepFunctionsによる複数バッチ処理の管理

新規プロダクトAのバッチ処理を複数作っていたのですが、実行順依存が大きくなり、時刻指定による担保では心もとなくなってきました。

そこで、StepFunctionsによりECSタスクの実行を管理することで、バッチ実行の順番や処理の終了時刻を気にする必要なく、複数バッチを直列に実行できるようになりました。

バッチの管理がしんどくなってきた環境ではStepFunctionsがドンピシャでハマりますね。

WAF × CloudFront によるシステムメンテナンスページの表示

システムメンテナンスページを表示するには、一時的にアプリケーション側に更新をかけたり、DNSよりルートドメインの向き先をS3の静的コンテンツに切り替えたりするのが一般的かと思います。

もう少し楽をしたいということでWAF × CloudFrontでメンテナンスページを表示する方法を検討しました。

セキュリティ強化の一貫として、インターネットからのアクセスはCloudFrontに集約させていました。その経路を遮断するWAFで制御ができれば全プロダクトを一括で管理できるので美味しいという背景です。

ざっくりですが、以下のような方針で対応できました。

  • WAFのルールによりブロック

  • CloudFrontのエラーページ制御で、403ステータス(WAFでブロックした場合のデフォルトステータス)が返ってきた場合にエラーページのパスへプロキシ

  • ビヘイビアのパスパターンによりエラーページのパスをS3に向ける

これにより、WAFのルールをBLOCKに切り替えるだけでメンテナンスページを表示できるようになり、運用が幾分楽になりました。

新規プロダクトBの技術調査・選定

新規プロダクトAを無事にリリースし、大手クライアントに使ってもらえるようになり、運用が慌ただしくなったと思いきや。次は新規プロダクトBを開発する動きになりました。

ありがたいことにバックエンドの選定を任せてもらえることに。BFFとしての役割が強いAPIになるため、REST APIではなくGraphQLを採用することは早期に確定しました。

となると、AppSyncを試してみたくなります。GraphQLのクエリを1つ作成し、動かしてみましたが、結論として採用は見送りになりました。

・並行してREST APIを生やせない
・ローカル環境構築の難易度が高い
・GraphQL特化の既存フレームワークとの相性が悪い
・リゾルバーをLambdaに指定する際にLambdaのエイリアスを使用できない(ため、1環境につき1Lambdaを作る必要がある)
・VTL(Velocity Template Language)の癖が強く、キャッチアップコストがかかる

など課題が多く、それらを解決するコストを払ってでも使いたい大きなYESがありませんでした。

最終的に、API Gateway × LambdaによるサーバレスAPI構成とし、RuntimeはGo(1.18)、GraphQLフレームワークとしてgplgenを使うことになりました。

AppSyncの採用には至らなかったものの、プロトタイプでの検証を通して、AppSyncの強さが発揮できるケース、いまいちハマらないケースの知見をつけることができました。

Go × GraphQL × gqlgen によるAPIの基盤構築

先ほど選定した技術スタックを組み合わせて実際にGraphQL APIサーバの基盤を構築しました。gplgenの公式サイトが大変分かりやすく、こちらのレシピを参考にしながら構成を整えていきました。N+1問題を回避すべく、Dataloaderも導入しました。

Terraformによるインフラ環境構築

Terraformによるコード管理のもとで新規プロダクトBのAPIサーバの環境を構築しました。構成は以下の通りです。

Route53、ACM、CloudFront、API Gateway、Lambda、Aurora RDS with RDS Proxy、Systems Manager、ネットワーク(VPC、Subnet、セキュリティグループなど)

以下、工夫したポイントです。

  • 既存で似た環境があったのでimportをフル活用

  • moduleを用いてドライに

  • 他プロダクトでも共通して使用するリソースはデータソースにより定義

  • 環境分けはディレクトリで対応

既存で似た環境があったのでimportをフル活用

すでに他プロダクトで似た環境を構築していたので(not IaC)、importコマンドをフルに活用し、ソラでresourceを定義する時間を最小にしました。

具体的には、以下の作業の繰り返しです。

1. terraform importにより既存のリソースをstateに反映
2. terraform state showによりresource定義ブロックを確認 & コピー
3. コピーしたresourceブロックをxxxx.tfファイルに貼り付け
4. terraform planにより差分がなくなることを確認
5. terraform state rm によりstateファイルから削除
6. nameやdescriptionなどを新規プロダクト独自の値に変更
7. terraform applyにより反映

1 ~ 7 を繰り返すことにより、既存プロダクトの環境を複製する形式で新規プロダクトを構築できます。

moduleを用いてドライに

main.tfに全てのresourceを定義するモノリス構成を脱却すべくmodule化していきました。ディレクトリ構成は以下の通りです。

Standard Module Structure を参考にしました。他のプロダクトでも転用できそうなので、module管理用のリポジトリを作成する案も検討中です。

他プロダクトでも共通して使用するリソースはデータソースにより定義

VPCやSubnet、ACMなどは他のプロダクトでも共用しています。基本的にこれらに変更が入ることはありませんし、万が一stateファイルの操作をミスしてリソースが消えてしまうと悲惨です。

これらを考慮し、共用リソースはデータソース(既存のリソースを参照する定義方法)を使い、依存関係を排除しました。

さらに、これをData-Only Modulesとしてmoduleにラップすることで、リソースの参照方法を完璧にカプセル化しました。

moduleでカプセル化することで、moduleを呼び出す側は、中身がresourceなのかdata_sourceなのか気にする必要がなくなります。

環境分けはディレクトリで対応

stagingや本番環境の環境はディレクトリを分けることで対応しました。

Workspacesを使う方法もありましたが、環境ごとに完全対称な構成でないこと、運用難易度が高いことを考慮し、見送りました。

インフラの更新をデプロイパイプラインに組み込む

TerraformによるIaCの基盤はできたので、これらをGithub ActionsのCI/CDパイプラインに組み込みました。

ざっと以下のようなパイプラインです

  • PR作成時にplanジョブを起動。terraformのフォーマット、planによるドライランを実行し、差分結果をGithubのコメントに残す

  • PR作成以降のコミットのたびに上記のチェックを走らせる

  • PRマージ時にapplyジョブを起動し、インフラを更新

工夫した点は以下の通りです。

・terraform planの結果をコメントで見れるようにする
・push時ではなくmerge時にのみapplyジョブを動かす
・指定ディレクリ以下のファイルに差分がある時のみパイプラインを走らせる

terraform planの結果をコメントで見れるようにする

Pull Request作成時にterraform planが走りますが、何もしないとレビュワーはGithub Actionsのジョブ実行結果を見に行かなければなりません。面倒ですし、Github Actionsに慣れていない方にとっては分かりづらすぎます。

これらを解決すべく、tcfmtというCLIツールを使ってplan結果がコメントに通知されるようにしました。

Githubでのコメント例

これで、PRを見ればこれからどのような変更をかけようとしているのかすぐに確認できます。

push時ではなくmerge時にのみapplyジョブを動かす

環境を分離する場合、一般的にrelease/stagingやrelease/productionのような環境用のブランチを作るかと思います。

では、どのタイミングでterraform applyを実行するかという話ですが、結論としてpush時では微妙でした。理由は、インフラの更新には必ずレビューを挟みたかったためです。

当リポジトリでは、development、staging環境であればローカルから直pushして気軽に更新をかけられるようになっております。そのため、push時にapplyジョブを起動してしまうとノーレビューでインフラの更新ができてしまいます。これはかなり危険です。

解決のため、push時ではなくmerge時にapplyジョブが起動するようにしました。

Github Actionsのon pushイベントだと、PRのマージだけでなくローカル環境からのpushにも反応してしまいます。そこで、以下のように、on pull_reeustとした上で、types: [closed] に絞ることで、ローカル環境からの直pushでは反応しないようにしました。

on:
  pull_request:
    branches:
      - release/development
    types: [closed]

これに加えて、PRのmergeをレビュワーのApprove必須にすることで、ノーレビューによるインフラ更新ができない仕組みを構築できました。

指定ディレクリ以下のファイルに差分がある時のみパイプラインを走らせる

最後は小さな工夫ですが、terraformのコードを管理するディレクトリ下で差分がある場合のみ、terraform plan、applyジョブを起動するようにしました。プロダクトのソースコードの変更しかないのに毎度terraform関連ジョブを走らせるのはもったいないですし、リスクもありますので。

ちなみにこちらを使わせていただきました。


API仕様書を自動で反映するワークフローの整備

API開発をする際、仕様書をどれだけ実態に近づけられるかがポイントです。私が携わるプロジェクトでは、gin-swaggerを使ってGoの型定義から自動生成しています。

上記のようにコメントアウトで指定のフォーマットを入力していくだけで、swaggerによるAPI仕様書が生成されます。とても便利です。

Github Actionsのデプロイパイプラインの1ステップにて、swaggerをビルドする処理を入れることで、API仕様書が自動で最新に保たれる仕組みを構築しました。

開発を進める際は、

  1. エンドポイントを実装

  2. 数行のコメントアウトを記載

  3. 開発環境にデプロイ

  4. API仕様書が自動生成

  5. フロントエンドエンジニアに仕様書の指定のエンドポイントのパスをslackで共有

という流れでコミュニケーションコストをかけることなく爆発で開発を進められます。

RDS からQuickSightにデータを連携

プロダクトAではBIツールとしてAWS QuickSightを使っています。プロダクトのKPI分析のためにRDS内の指定のテーブルにあるデータを定期的にQuickSightへ同期する設定を行いました。

AWS 公式ドキュメントより引用

QuickSight ネットワークインターフェイスにアタッチされているセキュリティグループがステートフルではないため、セキュリティグループに Egress ルールだけでなく、全ポートから通信を受け付けるIngressルールも追加しなければならない。

というハマりポイントがありましたが、無事に疎通し、連携できるようになりました。

ネットワーク周りの知見がさらに強化できた一件でございました。

参考

OpenID ConnectによるID Providerサーバの設計

新規プロダクトBではこんなことをやろうとしていました。

新規プロダクトの認証システム構想

セキュアな認証を導入すべく、プロダクトBではクライアント企業の持つアイデンティティ情報を利用したSAML認証でログインします。つまり、SAMLクライアントとして機能します。

一方で、他のサービスを使う時はプロダクトBをID ProviderとしてSSO(シングルサインオン)でログインできるようにしたい。つまり、OpneID ConnectのProviderとして機能します。

まとめると、プロダクトBはSAMLクライアントでありながらOpenID Connect Providerとしての振る舞いをさせるイメージです。

この要件を満たすために、

・Cognitoをどのように使えば実装難易度を低くできるか
・シーケンス図はどうなるのか
・そのために、プロダクトBにはどんなAPIを生やせばよいか
・セッション情報はどのように保持し、共有すればよいか

などを調査した上で設計していきました。OpenID Connect Providerとしてのサーバを実装するだけでもそれなりに難しいのですが、さらにSAMLクライアントとしての振る舞いも持たせるということで、大変やりごたえのある設計でございました。

WAFのチューニング

2022年は1年をかけてWAFのチューニングに励んだ一年でもありました。最初はAWSのマネージドルールでスタートしましたが、

攻撃を受けるたびに対策を強化し、通過してほしいファイルアップロードがブロックされた時に原因となっているルールを調査して緩めたり、ホワイトリストを設けたり、新しいルールがAWSからリリースされると検証して導入してみたり。

と、深く長くWAFと接した1年でもありました。

WAFは攻撃を防ぐだけでなく、Basic認証をかけたり、リダイレクト設定をしたり、メンテナンスページを表示したりすることもでき、実に活躍の幅が広い便利なサービスだと思いました。

プロダクトへのアクセスをCloudFrontに集約させ、WAFを置くことでセキュリティレベルを高くしつつ、通信制御もしやすくする。1年かけてベストに近いプラクティスに落ち着いたのかなと思っています。

APIのエンドポイント、バッチ処理の開発

その他、特筆すべき項目がなかったので詳細は割愛しますが、APIのエンドポイントを50本ほど、バッチ処理を5つほど実装しました。

テストカバレッジはAPI 60%、バッチ処理 100% です。

特にバッチ処理では請求に直接関わるところでしたので、みっちりテストを書きました。おかげで月初の請求のタイミングでも安心してバッチの稼働を見守れるようになりました。

さいごに

つらつらと今年の振り返りをしてみました。

たくさんチャレンジし、時に失敗も経験しながら着実に前進できた一年だったように思います。こうして振り返ることで少しは自分を褒めてあげられるきっかけになると思いますので、皆さんも時間があれば是非やってみてください!

最後までお読みいただきありがとうございました!

※ 各章において、課題に対してどう対応したかを書いていきました。あくまで一つのサンプルとして捉えていただき、もっとこうすれば楽にできるよ、という改善案があればTwitterのDMにて教えてもらえると嬉しいです!

https://twitter.com/engineer_ryoma


この記事が気に入ったらサポートをしてみませんか?