TerraformとYAMLしか書かない人間がGatsbyでブログを作った話 - インフラ編

2021/12/22

Recruit Advent Calendar 2021

本記事は Recruit Advent Calendar 2021 22日目の記事です。

21日目の記事はmasahiro331さんの XXXXX でした。

概要

もともと技術や趣味に関することははてなブログを使って書いていましたが、先日こっちに移行しました。

何故移行したかの理由は色々ありますが、

「WYSIWYGじゃなくてmarkdownで雑に記事を書きたかった」
「独自ドメインでブログをホスティングしたかった」
「なにか作りたかった」

みたいな感じで、特別はてなブログで記事を書くことに対して課題感があったというわけではありません。

今回ブログを移行するにあたって、どういう機能を持たせるか、どういう構成にするのかなど、検討内容や決定したことを書き残そうと思います。

話すこと

  • 設計の話
    • 要件定義、技術選定、アーキテクチャ決定など、いかにも開発プロセスっぽいことを書きます。
    • 各AWSサービスを具体的にどう設計したかを書きます。
  • Terraformの設計の話
    • Terraformのモジュール設計、レシピ全体の設計について書きます。

話さないこと

  • Gatsby周りの話
    • 今回はインフラ編なのでAWSやTerraformの話にフォーカスします。
    • heroイメージの追加やTable of Contentsの自動生成くらいは実装しましたが、ほかは殆どgatsby-starter-blogそのままです。作り込むのはまた後でにしようと思います。

ターゲット

  • AWSで静的ウェブサイトホスティングをしたい人
  • Terraformを使って小規模なシステムのインフラ設計・開発をやりたい人

要件定義

「何を、何のために/どうして、どのように作るのか」を明確にします。

成果物

  • ブログ本体
  • ブログをホスティングするためのインフラ

背景・目的

  • WYSIWYGじゃなくてmarkdownで雑に記事を書くため。
  • 独自ドメインでブログをホスティングするため。
  • なにか作りたかったため。

機能要件

ブログ自体に要求する機能と、ブログで記事の執筆や運用をするにあたって必要だと思った機能をここで定義します。

もともとはてなブログを利用していたのでいくつかの機能ははてなブログを参考にしていますが、markdownで入稿できることを含めて機能は必要最小限です。デザインの改修とか埋め込みリンクとかOGPのカスタムとかは追々やっていくことにします。

  • 記事一覧・詳細ページ
  • Gitベースのmarkdown入稿機能
  • 購読機能
    • はてなブログにあるような購読機能を再現したい。
  • 目次自動生成機能
    • 手で目次ツリーを書くのが面倒なので。
  • 下書き限定公開機能
    • 本番環境(公開用環境)と同等の環境でプレビューしたい。
    • 第三者にレビューしてもらえるようにしたい。
  • アクセス解析機能
  • ログ解析機能

非機能要件

ブログとブログのインフラに関する機能以外の要件をここで定義します。顧客に対して納品するようなシステムではないので、性能周りの要件はほぼオミットして今後の拡張性やセキュリティのみにフォーカスします。

  • 低コスト
    • 常時アクセスがあるわけではないので、オンデマンドでコストが発生する仕組みが好ましい。
  • ブログ本体とインフラの明確な分離
    • あとからGatsby以外のSSGに乗り換えたり、ブログ以外のページを相乗りさせられるようにするため。
  • SSL/TLS対応

技術選定・アーキテクチャ

次で解説します。

技術選定

このブログ全体を構成する技術について、選定理由とともにリストアップします。一部の技術はAWSを利用するが故にほぼ一択になりました。

用途 技術 理由 備考
SSG Gatsby Hugoは使ったことがあったのでそれ以外からブログを簡単に始められそうなものを選んだ。ユーザーが多いのも理由。 gatsby-starter-blog
DNS Route53 ドメインの購入と管理もここでできる。他のAWSサービスとの連携も考えるとこれが一番。 AWS
CDN CloudFront 低価格で始められる。他のAWSサービスとの連携も考えるとこれが一番。 AWS
SSL証明書 CertificateManager 証明書の自動更新が便利。AWSを利用していて、ドメイン検証(DV)の証明書でも問題ないのであればこれが一番。 AWS
エッジコンピューティング CloudFront Functions やることはアクセス制御とURLの一部書き換え程度なのでLambda@Edgeまでは必要ない。 AWS
オブジェクトストレージ S3 安い。CloudFrontと連携させると一般公開しないままファイルの静的ホスティングが可能になる。他のAWSサービスとの連携も考えるとこれが一番。 AWS
CI/CD CodeBuild Gatsbyのビルドとデプロイをするだけなので、トリガー条件の設定も含めて十分に実現可能。 AWS
メトリクス監視 なし 静的ホスティングかつサービスレベルも未定義なので不要。 -
利用料金監視 Budgets クラウド破産の事前検知をするため。 AWS
ログ解析 Athena CloudFrontとS3のアクセスログはS3に吐き出されるので、AWS公式のクエリテンプレートを使ってAthenaで解析するのが一番楽。 AWS
アクセス解析 GoogleAnalytics 定番。 Google
購読・通知 Push7 はてなブログの購読機能の代わり。 Push7
IaC Terraform 定番。 v1.0.x
CI/CD(IaC) Terraform Cloud TerraformのCI/CDをやるなら一番手軽かつ便利。 -

AWS vs GCP

AWSではなくGCPを採用しなかったのは、CloudCDNでのアクセス制御の実装がかなり難しいためです。エッジコンピューティングの機能も持っていないため、今回の要件にはマッチしないと考えました。

GCPを利用しないにしても、例えばCDNはCloudFlareを利用するとか、ドメインやDNSの管理をお名前.comにするとか色々な方法があると思います。

Terraformならば一つのレシピでAWSと一緒にCloudFlareとかを管理することもできます。でもそこまでして使いたいかというと微妙なので、1つのプロバイダーで完結するようにAWSにできるだけ寄せるような形になりました。

CloudFront + S3 vs Cloudflare + Netlify

CloudFront + S3以外にも、Cloudflare + Netlifyという構成も考えました。CloudflareをCDNとして利用して、Netlifyをホスティングサービスとして利用する形です。

Cloudflareにはエッジコンピューティングの機能もあるので採用の余地はありましたが、Netlifyでのアクセス制御に若干癖があるので見送りました。その辺はAWSのCloudFront + CloudFront Functions + S3のほうが素直に実装できそうです。

CloudFront Functions vs Lambda@Edge

AWSで利用可能なエッジコンピューティングのサービスにはCloudFront FunctionsとLambda@Edgeの2つがあります。どういうケースで使い分けをするのかはクラスメソッドさんのブログで詳細に解説されています。大体以下のイメージです。

  • CloudFront Functions
    • 簡単な処理
  • Lambda@Edge
    • そこそこ計算資源を食う処理
    • ネットワークやファイルアクセスが必要な処理

CodeBuild vs その他

CI/CDを実装するためのツールとして、OSSの導入は過剰だと思うので選択肢外として、GitHubと簡単に連携できるという点ではCircleCIやGitHub Actionsも候補に上がります。

今回は開発環境と本番環境で2環境用意する関係でCI/CD自体もTerraformでコード管理したかったので、CodeBuildを採用しました。環境ごとにCI/CDを用意すれば buildspec.yml を共通化した上で簡略化できますし、実際20行くらいしか実装していません。

Terraform Cloud

個人利用する分にはほとんどのユースケースはTerraform Cloudで事足りるのではないかと思うくらいに便利です。以下メリデメ。

  • メリット
    • 無料(orgあたりのユーザー数制限あり)
    • セットアップが簡単
    • Variable sets機能 … 複数の実行環境にまたがって共通で変数を設定できる。クレデンシャル情報とかはここに入れておくと多重管理を回避できる。
    • Structured Run Output機能 … 差分がグラフィカルで見やすい。
    • GitHubのステータスチェックと連携して、PullRequest上でplan結果の一部を簡単に確認できる。
  • デメリット
    • applyのトリガーをpushイベント以外で設定できないので、凝ったトリガー条件を設定しようとすると詰む可能性がある。
    • tfstateがクラッシュしたときの復旧作業が少し面倒くさい。

tfcloud

※ 商用システム開発の用途においてはTerraform Enterpriseや、その他CI/CDツール(PipeCD、CodeBuildなど)の利用を検討してください。

アーキテクチャ

システム構成は以下のような概観です。必要な機能が明確かつ限られているので、全体の構造もシンプルなものになりました。

arch

設計・工夫ポイント

決まったアーキテクチャをもとに、具体的に各AWSサービスやTerraformモジュールをどのように設計していくのかを考えます。

シンプルだとは言いましたが、何も考えずに作れたというわけではありませんでした。それぞれどういった設計にしたのか、どういう工夫をしたのかを説明していきます。

ドメイン(Route53)

ドメイン構造の設計とDNSレコードの管理について。

ゾーンとレコードの管理以外にも、本番環境用のAPEXドメイン(micryo.net)もRoute53で取得しています。開発環境用のサブドメイン(sub.micryo.net ※ダミー)は手動でRoute53ゾーンを作成し、APEXドメインのゾーンにNSレコードを設定しています。

開発環境のドメインをどのように切るかについては決まった答えはありません。今回はTerraformを使った実装のしやすさの観点からゾーンを分けてdelegationをしていますが、APEXドメインのゾーンに全てのレコードを設定する形でも同じドメインの構造を実現できます。開発環境用のDNSレコードをAPEXドメインのゾーンに設定することに抵抗がなく、レコード管理が破綻しないのであればその方法でも良いでしょう。

以下の表では手動管理とTerraform管理のレコードが混在していますが、「一度しか作成する必要がなくて、作成後に変更しないものは手動」「それ以外はTerraform」という整理にすると分かりやすいかもしれません。Push7用のCNAMEレコードや開発用サブドメインのNSレコードについては、それこそこのブログが滅びるまでずっと変わることのないであろうレコードなので手動管理としました。

ドメイン名 タイプ 用途 ゾーン 管理
micryo.net A(Alias) 本番CloudFront micryo.net Terraform
xxxxx.micryo.net CNAME 本番ACM micryo.net Terraform
push.micryo.net CNAME Push7 micryo.net 手動
sub.micryo.net NS delegation micryo.net 手動
sub.micryo.net A(Alias) 開発CloudFront sub.micryo.net Terraform
xxxxx.sub.micryo.net CNAME 開発ACM sub.micryo.net Terraform

静的サイトホスティング(CloudFront + CloudFront Functions + S3)

AWSでの静的ホスティングとなると一番最初に思い浮かぶのはS3の一般公開だと思います。しかしCloudFrontを利用すれば、Origin Access Identityを利用してオリジンのS3に対するアクセスを制限しながらファイルの静的ホスティングをし、同時にCDNの機能も利用することが可能です。

一般公開したS3単体での静的ホスティングとCloudFront+S3での静的ホスティングを比較した場合、実装工数を除けばCloudFrontを利用したほうが圧倒的にメリットがあるように感じます。

しかし問題点が一つあって、CloudFrontはパス補完に対応していません。具体的には以下のようなことが発生します。

CloudFrontはリクエストのURLに対応するパスをObjctKeyとしてそのままオリジンのS3に転送してしまうので、存在しないファイルにアクセスを試みていることになってしまうというわけです。当然SSGが生成するページはそういった事情が考慮されているわけもないので、なんとかする必要があります。

そこで、CloudFront Functionsを利用してviewer-requestのトリガーでURLの書き換えを実施します。

https://github.com/RomTin/micryo-net-infra/blob/master/module/cloudfront_portfolio/src/auth.js#L20-L32

...
  var olduri = request.uri;
  var newuri = olduri;

  // URIが拡張子を含まず、'/'で終わっていない場合に'/'を付与する
  if (!olduri.includes('.') && olduri.match(/^(?!.*\/$).*$/)) {
    newuri = newuri + '/';
  }

  // URIの末尾が'/'で終わっている場合にURIをindex.htmlで終わるように上書きする
  newuri = newuri.replace(/\/$/, '\/index.html');
  request.uri = newuri;

  return request;
...

Clientからリクエストを受け取ったあと、オリジンのS3に対して転送をする前にこの処理を挟むことで、

  • /hoge のようなURLを /hoge/index.html にリダイレクト
  • /hoge/ のようなURLを /hoge/index.html にリダイレクト
  • /hoge.ext のようなURLだけはそのまま

と、期待した通りの挙動をさせることができます。(※ 拡張子を持たないファイルが本当に存在する場合は意図せずアクセスできないことになりますが、今の所そういった問題は発生していません。)

また、開発環境向けに必要なベーシック認証もこのCloudFront Functionsで同時に処理するようにしました。

https://github.com/RomTin/micryo-net-infra/blob/master/module/cloudfront_portfolio/src/auth.js#L5-L18

...
  var authString = "Basic ${password}";

  if (${auth_enabled}) {
    if (
      typeof headers.authorization === "undefined" ||
      headers.authorization.value !== authString
    ) {
      return {
        statusCode: 401,
        statusDescription: "Unauthorized",
        headers: { "www-authenticate": { value: "Basic" } }
      };
    }
  }
...

password はTerraformの templatefile() 経由で、TerraformCloudに設定してあるsecret variableから埋め込むようになっています。 auth_enabled は、本番環境用に認証をスキップするためのフラグ変数です。

これらの処理を実装して初めて、普通のwebサイトのような挙動をCloudFront+S3の静的ホスティングで再現できるようになりました。

SSG用CI/CD(CodeBuild)

ブログ記事をデプロイするためのCodeBuildでは以下の2つの処理をしています。S3バケット名やCloudFrontのディストリビューションIDを環境変数化しておくことで、開発環境と本番環境で共通の buildspec.yml を利用できます。

  1. gatsby buildの実行と、S3への同期(記事のデプロイ)
    • aws s3 sync コマンドに --delete フラグを追加することで削除された記事や古いアセットファイルなどを同期と同時に削除するため。
  2. CloudFrontのキャッシュパージ
    • キャッシュされている旧ページを削除し、新しくデプロイされたページが配信されるようにするため。

CodeBuildの実行トリガーにはGitHubリポジトリとのWebhook連携を使用しています。main/developブランチでpushイベントが発行された段階で、CodeBuildは上記の2つの処理を実行して新しいページをデプロイする仕組みになっています。

version: 0.2
phases:
  install:
    runtime-versions:
      nodejs: 14
    commands:
      - "touch .npmignore"
      - "npm install -g gatsby"
  pre_build:
    commands:
      - "npm install"
  build:
    commands:
      - "npm run build"
  post_build:
    commands:
      - 'aws s3 sync "public/" "s3://$BUCKET_NAME/blog" --delete && aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*"'
cache:
  paths:
    - ".cache/*"
    - "node_modules/*"
    - "public/*"

ただこの実装は1つ問題があって、ビルド1回につき5分前後の時間がかかります。今後はビルド実行用のDockerイメージを用意して、それを使うような形でビルド時間の短縮を図ってみたいと思います。

アクセスログ解析(Athena)

CloudFrontとS3はアクセスログをS3に吐き出します。AWSから公式に提供されているCloudFront用のクエリテンプレートS3用のクエリテンプレートを使用すれば、Athenaを使ったアクセスログ解析環境を簡単に用意できます。

athena

監視

CloudFrontは、他の数多くのAWSサービスと同様にCloudWatchを利用してメトリクスモニタリングとアラート通知を実装できます。4xx/5xxエラーの割合やリクエストのレイテンシーを取得できるので、一般的な感覚だと閾値を決めて監視するべきだと感じます。

さて、静的ホスティングだけで構成される今回のシステムではアラート通知の実装をするべきでしょうか?

AWSによる障害が発生している場合以外にも、CloudFrontとS3はごく稀に5xxエラーを返すことがあります。裏を返せば、正しく実装されている限りはこれらの要因以外で5xxエラーを返すということがありません。

アラート通知の発報は「オペレーターに問題が発生していることを知らせて、何らかの復旧アクションを取らせる」ということに意味があります。今回のシステムで5xxエラーの監視とアラート通知をしたとして、その通知は単純にAWS側で何らかの問題が発生している事実を知らせるだけ以上の意味を持ちません。オペレーター(=私)はただ指をくわえてAWSが復旧するのを待っていることしかできないと思います。

こういったアラート通知は往々にして無視されるようになり、将来的にはオオカミ少年化を招くことになります。クラウド破産を防ぐためのBudgets以外の監視を特に実装していないのはそういう理由です。

Terraform: モジュール設計方針

この規模のシステムならモジュールを作成せずにフラットな構造のレシピにしてしまっても良いと思います。今回の実装では、

  • CloudFrontモジュール
    • 静的ホスティングをするためのCloudFrontとS3を含むシステム本体。
    • それに付随して必要になるAthenaクエリやSSL証明書など。
  • CodeBuildモジュール
    • デプロイを担うCodeBuild。
    • CodeBuildを自動トリガーするためのWebhookなど。

という整理でモジュールを2つに切り分けました。

Teraformのレシピを実装するにあたってモジュールを増やすというのは、モジュール間でのvariableとoutputの引き渡しが増えることを意味します。「CloudFrontと周辺リソース」と「CodeBuildと周辺リソース」という分け方をすることによって、CodeBuildはデプロイ先になるS3バケットの場所さえ分かれば良くなります。この分け方のおかげで、今回はある程度きれいなモジュール設計に落とし込むことができました。

個人的にTerraformのモジュールを設計するときに気をつけているのは以下のポイントです。

  • モジュールのユースケース・責務
    • モジュールが内包するリソースは、アーキテクチャを構成するコンポーネントをなるべく完全に構成するようにする。例えば、今回のCloudFrontモジュールを構成するために CloudFrontディストリビューションを作成するモジュールS3バケットを作成するモジュール に分けてその2つを組み合わせるような設計にはしないこと。
    • インフラの増改築をする際に、「どのモジュールにリソースを追加すればよいか」が迷わずに判断できるような設計にする。
  • モジュールのインターフェース
    • outputには特定のリソースのうちの1つのattributeではなく、リソースのobject自体をvalueとして設定する。
    • variableにはなるべく map 型を利用しない。乱用すると可読性が著しく下がる。
  • モジュールの可搬性
    • ユースケース・責務を考慮した上で、動的に設定する必要のあるparameterをvariableとして定義する。
    • variableを増やしすぎて可搬性を下げないようにする。

Terraform: CloudFrontモジュール

https://github.com/RomTin/micryo-net-infra/tree/master/module/cloudfront_portfolio

このCloudFrontモジュールは 静的ホスティングアクセスログ解析 に関わるリソースとして主に以下を含みます。

  • Amazon CertificateManager
  • AWS Athena
    • CloudFrontログ用クエリ
    • S3ログ用クエリ
  • Amazon CloudFront
    • CloudFront Functions用NodeJSスクリプト
  • Amazon Route53
  • Amazon S3

SSGで生成したファイルを配置するためのS3バケットは内包していますが、ログを保管するためのバケットは今後ALBなどのログを保管する可能性があることも考慮してモジュール外で管理しています。バケットの参照はdata source経由で行っています。

このCloudFrontは開発環境向けにアクセス制御用のベーシック認証を有効化するために、 basic_auth_password にbase64エンコードされたAuthorizationヘッダー用の文字列を設定することができます。空文字列 "" を設定した場合だけ、ベーシック認証が無効化され本番環境向けに公開されたCloudFrontを構成します。

...
  code = templatefile("${path.module}/src/auth.js", {
    password     = var.basic_auth_password
    auth_enabled = length(var.basic_auth_password) > 0 ? "true" : "false"
  })
...

このモジュールを再利用すれば、micryo.netとは別のドメインで静的ホスティングをしようとなったときに portfolio_domain の文字列を入れ替えるだけで即座に構築が可能になっています。

Terraform: CodeBuildモジュール

https://github.com/RomTin/micryo-net-infra/tree/master/module/codebuild_cd

このCodeBuildモジュールは 自動デプロイ に関わるリソースとして主に以下を含みます。

  • AWS CodeBuild
  • Amazon CloudWatch

先述の buldspec.yml の実装にもある通り、実行環境には環境変数としてデプロイ先のS3バケット名とCloudFrontディストリビューションのIDを設定するようになっています。この2つの値はCloudFrontモジュールが構成するリソースからoutput経由で受け取った値です。

特に凝ったことはしていません。

Terraform: 実行単位

https://github.com/RomTin/micryo-net-infra/tree/master/env

「開発環境と本番環境で共通のリソースってどうやって構築しよう?」という問題についてです。恐らく直面したことのある人も多いと思います。

今回は実装にもある通り、各環境のリソースを構築するための devprd 、環境共通のリソースを構築するための shared に分けました。Terraformの実行は全てTerraformCloudが担っており、それぞれの実行条件は以下のようになっています。

  • dev
    • developブランチにpushされたときに、開発環境向けに terraform apply を実行する
    • developブランチ以外にpushされたときに、開発環境向けに terraform plan を実行する
  • prd
    • masterブランチにpushされたときに、本番環境向けに terraform apply を実行する
    • masterブランチ以外にpushされたときに、本盤環境向けに terraform plan を実行する
  • shared
    • masterブランチで env/shared 以下のファイルが変更されたときに、 terraform apply を実行する
    • masterブランチ以外で env/shared 以下のファイルが変更されたときに、 terraform plan を実行する

shared だけはリソース管理が例外的になるので、ファイルの変更ベースでterraformコマンドを実行するようしました。モジュール化による恩恵も小さく、先述のログ用S3バケットを始め以下のようなリソースを素で実装しています。

  • AWS Budgets
    • AWSアカウントの利用料金予算とアラート
  • AWS IAM
    • CodeBuildを実行するためのIAMロール
  • Amazon S3
    • ログ用S3バケット

IAMロールを全環境で共通化するのか、各環境ごとに構築するのかどうかは時と場合によります。

今回のように設定するIAMポリシーを共通化できたり、セキュリティ上のリスクがない場合は、IAMロールを共通化しておくほうが良いでしょう。同じようなIAMポリシーを持つIAMロールをいくつも作成してしまうとマネジメントコンソールのカオス化を招くことになります。逆に、顧客のセンシティブなデータを扱うようなシステムでのIAMロールなどはちゃんと分離したほうが安全だと言えます。(これはIAMロール以外でも言えることではあります。)

参考資料

最後に

今回初めてGatsbyとかGraphQLを触ってみましたが、先駆者の知見が無限に見つかるのであまり苦なく手を進められました。本当に有り難い限りです。

インフラ設計、Terraformのレシピ設計に関しては個人的には今まで書いたことのなかった話題でした。同時に何が正解なのかを明確に言い切ることが難しい話でもあります。技術選定やアーキテクチャの策定も含めて、制約や要求によってこれらは大きく変わることがあります。今回ここに書いたことが少しでも参考になって、読んでいる方の物作りに活かせそうであればそれだけでも幸いです。

また今回は、「今この瞬間の自分がどう考えてシステム開発のプロセスを進めていたのか」を証跡として書き残しておくことにも一つの意味付けをしています。未来の自分がこの記事を見返したときに、考え方や進め方がどう変わったか、過去のやり方がどうだったのかを振り返る材料になることに期待します。

明日は、norihisa miyakawaさんの記事が公開されます。乞うご期待!


Profile picture

Michael (Ryoya Komatsu) - Twitter / GitHub
Public cloud engineer / Biker