All Articles

Gatsby + Azure Static Web Apps で作る技術ブログ

はじめに

僕は IT が仕事であり趣味みたいな生き物なんですが、その習性として手元には数多の技術的な検証メモなどが溜まっています。それを整理して知見として共有する場所としてこの技術ブログを開設しました。

これまで技術ネタの発信に使っていた Qiita を使い続けるという選択肢もあったのですが、

  • 自分が書いた記事を自分の管理下に置きたい
  • 副業で React をガリガリ書いていてフロントエンドの知見が溜まってきたので試しに作ってみたい
  • 自分好みにカスタマイズしたい

という事情から TechBlog-as-a-Service ではなくて自分でコードを書いて自分で配信のための仕組みを作る方向でやってみることにしました。

技術選定

Gatsby に決まるまで

選択肢と選定

クラウドの課金はつまるところ CPU とストレージですが、ストレージが圧倒的に安く CPU はそれなりに高いのが現代です。自分で運用するということでその運用コストももちろん自分持ちということで、スケールしたとき (スケールするのか……?) のコストを考えると CPU 量に応じてスケールが制約されるアーキテクチャは避けたいのが本音です。ということで静的なページを作る方向で技術選定を進めました。

WordPress のような CPU (高いリソース) を食う CMS は選外、Flask などのテンプレートエンジンを使って広義のサーバーサイドレンダリングをするスタイルも選外です。

というかこの要件だとほぼ静的サイトジェネレーターで決まりです。React や Vue.js をそのまま使うという手もありますが、これはめんどくさすぎます。

数多ある静的サイトジェネレーターから Gatsby を選んだのは、

  • ベースになっている React の知見があるので比較的カスタマイズしやすい
  • 先日副業で作った ドキュメント の実装を Gatsby で行ったため少し知見があった
  • プラグインが豊富で実装の手間を減らせそう
  • markdown からページを生成する仕組みがあり、ノウハウや周辺プラグインも豊富

という事情に依ります。

Docusaurus astro も良さそうですが、今回はプラグインの豊富さが決め手となり Gatsby に決定しました。

ベースにするテンプレート

ベースとなる Starter を選びます。0 からデザインするのは流石にしんどいので、Starter をベースに気になるところを自分で直していくスタイルです。

公式 Starter 集から github まで幅広くネットサーフィンをしていった結果、最終的に gatsby-starter-lumen からシンプルでイケてる雰囲気を感じたのでこちらを採用しました。

TypeScript になっている点も良いですね。型、当然あるべきですよね。(圧) (型ヤクザ)

Azure Static Web Apps に決まるまで

選択肢

本業が日本マイクロソフト株式会社のアーキテクトで Azure を取り扱う立場なので Azure かなぁと思って FrontDoor か Static Web Apps にしようとも最初は思ったのですが、これは趣味なので Azure に限らず AWS や GCP も含めて検討しました。

それぞれ調べてみると、ブロックストレージ + CDN かマネージドなお手軽配信基盤があることが分かりました。

ストレージコストなど細かいコストは見積もりが難しいので、一番支配的であろう通信量と定額部分のコストがあればそれも合わせて価格を割り出してみました。

ベンダー サービス 無料枠 価格
AWS CloudFront + S3 1TB/month $0.114/GB
AWS Amplify 15GB/month $0.15/GB
GCP Cloud CDN + Cloud Storage - $0.09/GB
GCP Firebase 10GB/month $0.15/GB
Azure CDN Standard + Blob Storage - $0.129/GB
Azure FrontDoor Standard + Blob Storage - $35/month + $0.115/GB
Azure Static Web Apps Free 100GB/month -
Azure Static Web Apps Standard 100GB/month $26.52/month + $0.20/GB

CloudFront + S3

静的サイト故に配信で使用する帯域幅は大したことがないので無料枠があるサービスであればどれでも無料運用ができそうですが、特筆すべきは CloudFront + S3 で、1TB もの無料枠を個人運用のページが使い切ることはまず無いでしょう。

CDK によって IaC でインフラ管理ができる点も好評価です。

Amplify

お手軽アプリホストの仕組みです。プライベートで触ってみたことがあるのですが、直感的な操作でさくっとデプロイできる点は驚きです。CDN などをユーザーが設定する必要はなく、シームレスに統合された CloudFront によって CDN を使って展開してくる様子です。なかなか良い選択肢になりそうです。

Cloud CDN + Cloud Storage

何より安い点が良さそうです。AWS CloudFront が備える 1TB の無料枠に大して損益分岐点を考える必要がありそうですが。

Firebase

こちらもプライベートで触ってみたことがあるのですが、簡単にアプリをホストできる便利サービスでした。CD が少々面倒な印象です。

CDN Standard + Blob Storage

価格面では AWS と GCP に見劣りしていますが、Microsoft 提供のエッジ以外に Verizon と Akamai を選べる点がユニークです。

FrontDoor Standard + Blob Storage

Azure FrontDoor は月額部分があって高く見えますが代わりに極めて高機能で、これ単体でリージョン分散や WAF 、DSA など様々な機能が統合されていて非常に強力で、大規模に運用する本番アプリケーションなら有望な選択肢になりそうです。

ただ今回の用途では宝の持ち腐れになりそうです。WAF も何も、シンプルなブログなので守るべきバックエンドがありません。

Static Web Apps

これはかなりユニークなサービスで、静的サイト配信に特化したマネージドサービスです。お手軽に静的アプリケーションをホストして CDN を通じて配信するという他社が備える仕組みとは異なり、マネージドサービスとして静的サイトの配信オリジンを裏側で勝手にマルチリージョンに展開して、CDN よりはもう少し粒度粗目ではありますが Points of Presence 的に近傍リージョンから配信する様子です。リージョンダウンに対する耐性はどんな感じなんでしょうか。かなり強そうな気配があります。

Free の場合は粒度粗目の CDN っぽい動きで配信しますし、Standard のエンタープライズグレードのエッジという機能を使うと配信オリジンの分散かつ CDN による配信になる様子です。

金額面では Free であれば無料で帯域幅超過をするとサービス提供が止まる仕組みの様子で、Standard であれば月額 9 ドル + エンタープライズグレードのエッジの費用月額 17.52 ドル + 100GB 超過分については $0.20/GB ということで少々お高めです。

負荷試験をしているブログ記事を見つけました。Standard は後述のエンタープライズグレードのエッジが出る前でちょっと当てにならないですが、Free は素の状態でもそれなりに頑張ってる印象です。

選定

まず前提として、僕は諸事情あって Azure については毎月いくらかのクレジットがあり、Azure が要求してくる月額コスト程度であれば全く問題になりません。そもそもが Azure 優位な技術選定コンペですが、まあ世の中そんなものです。

AWS と GCP のうち無料枠が大きいサービスか、Azure のいずれかのサービスがコスト的な意味では同じ土俵に立っていることになります。具体的には Cloud CDN + Cloud Storage が選定対象から外れます。

コストではほとんど絞れないので、個人的な技術的面白さで絞ります。単純な CDN + ストレージという組み合わせだと、やはり CDK によって IaC で楽しく書き味良くインフラ管理ができそうな CloudFront + S3 が一歩抜けています。簡単にデプロイできるマネージドサービス枠だと、CDN による分散のみならず配信オリジンまで分散している Static Web Apps が面白いです。

大変悩ましいのですが、最終的に裏で動いている地理的分散に対するときめきを根拠として Azure Static Web Apps の Standard + エンタープライズグレードのエッジを有効にする方向で決定しました。

もし不満を感じる部分があったら CloudFront + S3 のスタックを CDK で書いてそちらに移し替えるつもりです。

開発

gatsby-starter-lumen のリポジトリをローカルに clone してnpm install->npm startで特に問題なく Web ページが構築されました。

各種初期の記事を自前の記事に入れ替え、画像やらコンテンツを一通り入れ替え、いよいよカスタマイズです。

リンクカード

Qiita とかでリンクを張ると自動で生成されるこういう奴です。

image.png

無骨な URL 表示より見た目がいいので是非追加してみたいということで、プラグインに頼りつつ実装します。

まずリンクカードに限らない大きな話として、markdown 内の要素に対する処理は gatsby-transformer-remark というプラグインの仕事で、その配下にプラグインを追加していくことで markdown を解釈してページを生成する部分の拡張が可能です。

今回は以下のプラグインを使うことにしました。

これで[$card](url)という風に記述することでリンクカードを生成することができます。

gatsby-browser.ts に

import "gatsby-remark-link-beautify/themes/notion.css";

と追記することで notion 風のスタイルを与えることができるとありますが、自分好みに改造するために notion.css をベースに色々とカスタマイズを加えたものをロードするようにしました。

gatsby-remark-link-beautify-notion-theme-custom.scss
@import "src/assets/scss/variables";
@import "src/assets/scss/mixins";

/* for link preview */
.link-preview-container {
    display: inline-block;
    position: relative;
}

.link-preview-container img {
    border: 1px solid rgba(55, 53, 47, 0.16);
    border-radius: 2px;
    -webkit-box-shadow: 0px 2px 5px rgba(10, 20, 20, 0.2);
    box-shadow: 0 2px 5px rgba(10, 20, 20, 0.2);
    display: none;
    height: 300px;
    left: calc(50% - 200px);
    object-fit: scale-down;
    position: absolute;
    top: 1.5em;
    width: 400px;
    z-index: 9;
}

.link-preview-container:hover img {
    display: block;
}

/* for link card */
.link-card-container {
    align-items: stretch;
    border: 2px solid var(--color-sidebar-border);
    border-radius: 3px;
    color: inherit;
    cursor: pointer;
    display: flex;
    fill: inherit;
    max-height: 120px;
    overflow: hidden;
    position: relative;
    text-align: left;
    text-decoration: none !important;
    transition: background 120ms ease-in 0s;
    user-select: none;
    width: 100%;
}

.link-card-container:hover {
    background-color: rgba(55, 53,47, 0.08);
}

.link-card-wrapper {
    display: block;
    flex: 80%;
    flex-direction: column;
    min-height: 60px;
    min-width:0;
    padding: 12px 14px;
    word-wrap: break-word;
}

.link-card-title {
    flex: 40%;
    font-size: 18px;
    font-weight: bold;
    line-height: 22px;
    margin-bottom: 2px;
    max-height: 22px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    word-wrap: break-word;
}

.link-card-description {
    flex: 40%;
    font-size: 14px;
    line-height: 18px;
    margin-bottom: 2px;
    max-height: 36px;
    overflow: hidden;
}

.link-card-text {
    align-items: center;
    flex: 1;
}

.link-card-url {
    flex: 20%;
    margin-top: 6px;
}

.link-card-favicon {
    display: none;
    height: 16px;
    margin-right: 6px;
    min-width: 16px;
    width: 16px;
}

.link-card-link {
    font-size: 12px;
    line-height: 16px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.link-card-image-wrapper {
    display: block;
    flex: 20%;
    margin: 5px auto;
    min-height: 60px;
    position: relative;
}

.link-card-image {
    border-radius: 1px;
    display: flex;
    height: 90%;
    object-fit: scale-down;
    width: 90%;
}

// Copyright (c) 2022 Taozc
// https://github.com/Talaxy009/gatsby-remark-link-beautify/blob/main/LICENSE
// https://github.com/Talaxy009/gatsby-remark-link-beautify/blob/main/themes/notion.css をベースに改変

CSS はあまり得意ではないので、mdn のリファレンスを見ながら一か所変えては望み通りになるか確認しながら変更しています。意味のない余計な記述などもあるかもしれません。

Syntax highlight とコードブロックのタイトル表示

コード部分の Syntax highlight は prism.js によって実現されています。

gatsby-browser.ts
import "prismjs/themes/prism-okaidia.css";

とするだけで、現在採用している Okaidia Theme が適用されます。

続いて、コードブロックのタイトル表示が欲しいところです。

これは以下プラグインによって実現できます。

スタイルは 説明ページにあった Okaidia 風の CSS を gatsby-browser.ts から import する形で反映しました。

予約投稿

素の状態だと未来の日付であろうとお構いなしに公開状態になってしまいますが、今後アドベントカレンダー等でも活用することを考えると予約投稿機能が欲しいところです。ということで、簡易的な予約投稿機能を実装します。

src/templates/IndexTemplate/IndexTemplate.tsx の中身を見ると

const { edges } = data.allMarkdownRemark;

という部分で markdown を変換して得た各記事のデータを取得しています。これを現在までの記事のみに絞り込む関数を以下のように実装しました。

get-posts-exclude-future-posts.ts
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";

import { Edge } from "@/types";

dayjs.extend(timezone);
dayjs.extend(utc);

const isFuturePost = (edge: Edge): boolean => {
  const now = dayjs();
  const postTime = dayjs.tz(edge.node.frontmatter.date, "Asia/Tokyo");
  return postTime > now;
};

const getPostsExcludeFuturePosts = (edges: Edge[]): Edge[] =>
  edges.filter((edge) => !isFuturePost(edge));

export default getPostsExcludeFuturePosts;

この関数にedgesを渡することで現在までの記事のみに絞り込み、未来の記事についてはインデックスページに含めないようにします。この処理を src/templates/CategoryTemplate/CategoryTemplate.tsx など他のedgesを取り扱っている部分についても適用することで、ブログ全体から未来の投稿へのリンクを排除します。

リンクは排除されても記事の実態は存在しているので、記事の URL を特定された場合はアクセスを防ぐことができません。とはいえただの個人のブログに厳密に将来の投稿を守る理由はないのでこの簡易的な実装でヨシとします。

Twitter 埋め込み

上記ページを使って生成したタイムラインページや「ツイートを埋め込む」で特定ツイートを記事中に埋め込むことも今後あろうかと思いますが、Gatsby は markdown に直接書いた script タグを無効化してくるのでひと手間必要です。

ということで、以下プラグインを導入しました。

これは Twitter についてのみ script タグの無効化を解除する公式のプラグインだそうです。

おなじみ gatsby-config.ts に上記プラグインの名前を加え、有効化することで無事ツイートを埋め込めました。

デプロイ

リソース準備とリポジトリの関連付け

完成したブログをこれからデプロイしていくわけですが、ドキュメントを参照したところどうも Static Web Apps は SDK などを使用してこちらからビルド産物を push するスタイルではなく、Static Web Apps 側の設定でリポジトリと紐づけることで対応フレームワークについてはビルドからホストまでよしなにやってくれる仕組みの様子です。

Static Web Apps にデプロイする前段階として Static Web Apps のリソースを作ります。

リソースを作る時点で関連付ける Git のリモートリポジトリを要求されます。

image.png

Github にあるこの技術ブログのリポジトリを紐づけたところ、Github Actions のワークフローが生成されました。このワークフローが Static Web Apps へのデプロイを担っている様子です。

ドメインの紐づけはリソースのメニューの「カスタム ドメイン」から行うことができました。Google Domains で管理している自前のドメインについて、CNAME レコードを設定して紐づけを行いました。

最後にエンタープライズグレードのエッジを有効にします。こちらはリソースのメニューの「エンダープライズ グレードのエッジ」から設定することができました。

デプロイ

markdown で記事を書いて PR を作って main にマージすると、Static Web Apps リソース作成時に自動で作られたワークフローによってブログ記事が公開されるという流れです。簡単で良いですね。

それだけで特に難しいことはないはずなのですが、今回 Gatsby (厳密には gatsby-remark-link-beautify) が依存している puppeteer 周りで 2 点問題と遭遇し、解決にかなりの手間をかけることになりました。

以下に記録を残しておきます。

puppeteer の依存ライブラリ問題の解決

自動作成された Github Actions のワークフロー内のビルドプロセスの途中、 puppeteer 周辺で依存ライブラリが無い旨のエラーが出てきました。

Failed to launch the browser process!
/github/home/.cache/puppeteer/chrome/linux-1069273/chrome-linux/chrome: error while loading shared libraries: libnss3.so: cannot open shared object file: No such file or directory

ローカル開発時に同じ問題と遭遇した際は以下ページを参考に依存ライブラリをまとめてインストールして解決しましたが、今回は GHA の環境なのでワークフロー中でインストール作業をする必要があります。ということで以下を追加したのですが、全く同じエラーが出てきました。

- name: Install dependencies
        run: |
          sudo apt-get install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils

実際にビルド処理を実行してるのは以下のワークフローです。

コードを見てみると

Dockerfile
FROM mcr.microsoft.com/appsvc/staticappsclient:stable
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["sh", "/entrypoint.sh"]
#!/bin/sh -l
cd /bin/staticsites/
./StaticSitesClient $INPUT_ACTION

となっていて、StaticSitesClientなるバイナリが鍵のようですが、この中身はmcr.microsoft.com/appsvc/staticappsclient:stableという非公開なイメージの内側にある様子で残念ながら処理の詳細は不明でした。

しかしsudo apt-getしてもダメだった理由はこの情報だけで十分です。ビルド環境がコンテナとして分離されているので、追記したsudo apt-getとはそもそも別環境であったために影響が及ばなかった様子です。

ということで、Azure/static-web-apps-deploy@v1app_build_commandapt-getを入れるという力業で行きます。

- name: Build And Deploy
  id: builddeploy
  uses: Azure/static-web-apps-deploy@v1
  with:
    azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PURPLE_PEBBLE_0D6157700 }}
    repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
    action: "upload"
    ###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
    # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
    app_build_command: apt-get update && apt-get install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils && npm run build
    app_location: "/" # App source code path
    api_location: "" # Api source code path - optional
    output_location: "public" # Built app content directory - optional
    ###### End of Repository/Build Configurations ######

うーん、力業が過ぎる。

error There was an error in your GraphQL query:

Failed to launch the browser process!
[1213/040526.704745:FATAL:zygote_host_impl_linux.cc(127)] No usable sandbox! Update your kernel or see https://chromium.googlesource.com/chromium/src/+/main/docs/linux/suid_sandbox_development.md for more information on developing with the SUID sandbox. If you want to live dangerously and need an immediate workaround, you can try using --no-sandbox.

違うエラーが出てきましたが、とりあえず依存エラーは解決しました。

puppeteer の sandbox 問題の解決

エラーメッセージには puppeteer を使うときに--no-sandboxというオプションを使えとあります。

こちらを参照すると、要はコンテナ内では sandbox が機能しないので使わないようにする必要があるとのことです。まあコンテナが sandbox みたいなところもあるのでこのオプションを有効にすること自体は構わないのですが、問題はこれが Gatsby のどこかから発生しているエラーだということです。Gatsby かその依存先のパッケージのどこかに puppeteer に依存している部分がある様子なので、それを探し出す必要があります。

node_modules の中に潜って調査したところ、リンクカードを作るために入れた gatsby-remark-link-beautify の taskManagement.js の中で puppeteer を使っている部分を発見しました。

以下のような記述があります。

const init = async (options) => {
  const { browserNumer: num, puppeteerLaunchArgs: args } = options;
  if (global.WSE_LIST) {
    if (global.WSE_LIST.length >= num) {
      return;
    } else {
      return new Promise((resolve) => {
        emitter.once("linkBeautifyInit", resolve);
      });
    }
  }
  global.WSE_LIST = [];
  global.PUPPETEER_PAGE_NUMBER = 0; // Current page count
  global.LINK_BEAUTIFY_LISTENER = 0; // Listener number
  global.LINK_BEAUTIFY_TASKS = []; // Waitting tasks array
  global.LINK_BEAUTIFY_IMG = new Map(); // Images' map
  global.LINK_BEAUTIFY_CARD = new Map(); // Cards' map
  while (WSE_LIST.length < num) {
    const browser = await puppeteer.launch({ args });
    WSE_LIST.push(browser.wsEndpoint());
  }
  emitter.emit("linkBeautifyInit");
};

puppeteerLaunchArgsというものを puppeteer を launch するときに渡しているようですが、これは Gatsby の plugin をロードするところで定義できる options から引っ張ってきているようです。まさしくこのpuppeteer.launch--no-sandboxオプションを入れたかったわけでドンピシャでした。

以下のように gatsby-config.ts の plugin 周りを変更したところ、無事デプロイ成功しました。

          {
            resolve: "gatsby-remark-link-beautify",
            options: {
              puppeteerLaunchArgs: ["--no-sandbox"],
            },
          },

おわりに

こうして技術ブログのバージョン 1 が完成したので公開しました。

markdown ベースで記事を書くことができ、コードブロックやリンクカード、予約投稿など最低限欲しかった機能も実装できたのでひとまず満足です。

今後のカスタマイズとしては以下を予定しています。

  • tag 一覧ページ作成
  • カテゴリー一覧ページ作成
  • Table of Contents の自動作成と表示

Published 2022/12/15

ShuntaIto による技術ブログ