【2020年7月2日 追記】Amplify Console において、Previewサイトのビルドと同時に Basic認証もかけるようにしてみました。


はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

この QG Tech Blog は、はてなブログ などのブログ・サービスを使わずに自前で仕組みを構築、運用しています。

はてなブログの他にも MediumnoteWordPress.com など優れたサービスが数多ある中、なぜそれらを利用せずわざわざ自分たちで実装したのか、その背景や設計思想、アーキテクチャについてご紹介したいと思います。

自前実装に至った経緯

弊社クイックガードはエンジニアの採用を最重要の経営課題と位置付けており、採用力を強化するために、以前より定期的に Qiita で自社のエンジニア文化や取り組みを発信してきました。

しかし、記事の執筆から公開までのフローが Qiita の中だけで閉じてしまっており、スタッフ同士が協働できていないという課題がありました。
(一人で記事を書き上げて、軽くリーガル・チェックを通したらエンジニア同士のレビューなしでそのまま公開、とか…。)

このような閉鎖的なワークフローでは社内に協働の空気は生まれず、良い記事も執筆できません。

そこで、この閉鎖されたフローを打破し、みんなで協働して良い記事を書ける環境を目指して、自由にフロー設計できる独自媒体へ移行することに決めました。

設計するうえで重視したのは以下 2点です。

  • 記事を書きやすく、レビューしやすいこと
  • 運用の手間がかからないこと

記事を書きやすく、レビューしやすいこと

「書きやすい」というのは単なる「文字の打ち込みやすさ、装飾のしやすさ」にとどまらず、「自分も記事を書いてみよう」というモチベーションであったり、「記事にならないまでも、ネタになるアイデアを出して議論してみよう」という参加障壁の低さも意味しています。

そのような社内コミュニティの活性化も視野に入れて、以下を要件としました。

  • Markdown で記事をかけること
  • GitHub上でレビューできること

Markdown についてはデファクトなので他に選択肢はありません。

レビューについては、普段から業務利用している GitHub にブログのコンテンツも Push して、記事ごとに Pull request を作成することにしました。
これにより、スタッフみんながいつも業務でレビューしているのと同じ感覚で記事をレビューするようになることを期待しました。

以上の要件から、GitHub を (文字どおり) ハブとするアーキテクチャに決定しました。

運用の手間がかからないこと

ブログの運用が重荷になって本業の足を引っ張るようでは本末転倒なので、運用の手間がかからないことも大事だと考えました。

手間を極限まで省くために、

  • 静的な HTML であること
  • キャパシティ制限のない安定したマネージド・サービスにホスティングすること
  • SSL/TLS証明書や独自ドメインなどの付随物の管理を丸投げできること
  • 記事の公開フローが GitHub と統合されていること (= マージしたら自動でデプロイされること)

というあたりを要件としました。

これにより、「SSG (Static Site Generator) + GitHub と連携するホスティング・サービス」という方向性が定まりました。

仕組み

本業があるので、構築作業にもなるべく手間をかけたくありません。
あまり凝った作り込みはせず、信頼できる部品を繋ぎ合わせて、コンパクトで安定した仕組みをサッと作るという方針で進めました。

自分たちはインフラ屋なので、そういうやり方は得意なのです。:)

技術スタック

技術スタックは次のとおりです。

  • Hugo
  • GitHub
  • AWS Amplify Console

Hugo

Golang製なのでサイトのビルドが高速で、何よりもバイナリぽん置きで動くので取り回しがとても良いです。
このように SSG は CI への組み込みやすさを重視して Hugo に決めました。

ローカル・サーバ + ライブ・リロード (hugo serverコマンド) もあって、「記事をちょっと書く » 手元で確認」のサイクルをサクサク回せます。

ただし、アセットの扱いでいくつか課題がありました。
(後述します。)

GitHub

要件で述べたとおり、ブログのコンテンツ一式の格納とレビュー基盤、ホスティング・サービスとのハブとして使います。

AWS Amplify Console

フル・マネージドのホスティング・サービスには NetlifyFirebase HostingGitHub Pages などいろいろありますが、今回は AWS Amplify Console を採用しました。

選定の理由は特になく、雰囲気で選んだので、もっと良いサービスがあればそちらに乗り換えるかもしれません。
が、Pull request に連動して Previewサイト が自動生成されたり Basic認証 をかけられたりと機能はひととおり揃っている (※) ので、当面は Amplify Console を使い続ける予定です。

※ 後で述べるように Basic認証はもう一歩改善してほしいのですが…。

記事作成の流れ

続いて、記事の執筆から公開までの作業の流れをご説明します。

ざっくり以下の流れになっています。

項目 内容
1. 執筆 手元でローカル・サーバを起動し、プレビューしながら記事を書く。
2. 画像の用意 記事で使う画像を作成し、手元で Minify してから S3 にアップロードする。
3. レビュー 記事が仕上がったら Pull request を作成し、自動生成される Previewサイトと併せてレビューしてもらう。
4. デプロイ Pull request をマージすると、自動で本番サイトにデプロイされる。

各ステップを順にご説明します。

1. 執筆

まず、手元の PC にセットアップした Hugo で記事を執筆します。

指定バージョンのバイナリを置くだけなのでセットアップがとても簡単ですね。

手元の PC でも Amplify Console でも同じコマンドでビルドするので、セットアップ&ビルド処理は Makefile に集約しています。

version = 0.63.2
platform = macOS-64bit

hugo = ./bin/hugo

opt = --minify --gc --debug

setup:
	mkdir -p ./bin/
	
	curl -L https://github.com/gohugoio/hugo/releases/download/v$(version)/hugo_extended_$(version)_$(platform).tar.gz \
	  | tar -zxv -f - -C ./bin/ hugo

server:
	$(hugo) server $(opt)

build: clean
	$(hugo) $(opt)

clean:
	rm -rf ./public/

技術スタックのセクションで述べたとおり、Hugo にはローカル・サーバ + ライブ・リロード が備わっているので、編集 » 確認のサイクルを素早く回せて効率よく執筆できます。

2. 画像の用意

記事に挿し込む画像は、普通に作成したままだと画質が無駄に高くてサイズが大きすぎたり Exif が残っていたりして公開に適しません。

何らかの Minifyツールで軽量化するのが一般的で、当ブログでは imagemin を使っています。

ちなみに Hugo にも簡易的な 画像処理機能 があるのですが、当ブログはリポジトリの肥大を嫌って画像を Amazon S3 に格納するようにしているので、この機能を使うことはしませんでした。
(ビルド時に画像がファイルとして手元に存在しないといけないので。)

「あまり作り込まずに手運用でもいいからサッとブログを始めよう」という気持ちで立ち上げを急いだので、今のところ手で imagemin をキック » 手で S3 にアップロード、という運用でやっています。

画像の原本  --[Minify]-->  軽量化された画像  --[アップロード]-->  S3 

お察しのように、このステップは課題が多いので要改善ポイントです。:(
(詳細は後ほどご説明します。)

3. レビュー

執筆者は任意のタイミングで GitHub に Push して Pull request を作成します。

いつ Pull request を作るのかは執筆者の自由で、記事がほぼ仕上がって完成品としてレビュー可能になったタイミングや、逆に、ガッツリ書き始める前に大まかな構成についてコメントをもらうために早い段階で Pull request を出すなど、記事によって様々です。

Pull request を作成すると、それに連動して、当該記事をプレビューできる Previewサイトが Amplify Console上に自動で生成されます。

この Previewサイトは社内のスタッフなら誰でも閲覧できるため、エンジニアだけでなく他の職種のスタッフにもレビューに参加してもらっています。
具体的には、エンジニアによる技術レビュー + 広報担当者による PR (Public Relations) 面でのレビューの二段構えとしています。

なお、Previewサイトは Basic認証がかかっていない状態で生成されるので、面倒ですが、サイトの自動生成に合わせて手動で Basic認証をかける運用にしています。

4. デプロイ

Approved な Pull request がマージされると、それを契機に自動でビルドが走り、5分くらいで本番サイトに反映されます。

記事のリリースに合わせて広報するため、広報担当者が都合の良いタイミングで、自分で Pull request をマージするようにしています。

技術面での課題

技術面でうまく解決できていない課題 (というか手を抜いたところ?) がいくつか残っているので、ここでご紹介します。

webpack との統合

当ブログではテーマの装飾に BulmaFont Awesome を使っています。

Hugo にも アセット管理 の機能はありますが、やはりフロントエンドの調整には Hugo単体では力不足なので、これらアセットを扱いやすくするために webpack を利用することにしました。

以下の図のとおり仕組みはとても素朴で、webpack の生成物を Hugo の assetsディレクトリに出力して Hugo Pipes に通しています。

webpack build --[hugo/assets/ にファイル出力]--> hugo build という二段階のステップを踏まねばならず、webpack と Hugo が分断されるので、JavaScript や CSS の変更は開発体験が良くありません。
が、一度デザインが確定してしまえば頻繁に変更するものではないので、今のところは許容しています。

記事画像の保管場所

記事中で表示する画像は、リポジトリの肥大を嫌って Git に入れず、代わりに Amazon S3 に置くようにしています。

仕組みやオペレーションを煩雑にしたくなかったので、手動でアップロードし、全環境 (= ローカルでの執筆中も、Previewサイトも本番サイトも) で同じ画像を表示することにしたのですが、その結果 2つの課題が生じました。

  1. アップロードしたものの使わないことになった画像の後始末を忘れがち。
    (推敲の過程で画像が要らなくなったとか、記事自体をお蔵入りしたとか。)
  2. そもそも手動でアップロードするのがだるい。

どちらも元々想像できていた課題なので許容はできるのですが、あまり神経質にならず画像もリポジトリに含めてしまってよかったかもしれません。

画像の Minify

画像を Minify するタイミングはいろいろ考えられますが、どうせ手動で S3 にアップロードするのだから、そのついでに同じく手動で Minify することにしました。

gulp + imagemin を Dockerイメージに焼いて、ワンショットでコンテナを走らせています。

$ make prep
rm -rf ./img/dest/
docker-compose up
Starting image_processor_image_processor_1 ... done
Attaching to image_processor_image_processor_1
image_processor_1  | [03:10:08] Using gulpfile /srv/gulpfile.js
image_processor_1  | [03:10:08] Starting 'default'...
image_processor_1  | [03:10:08] Starting 'jpg'...
image_processor_1  | [03:10:08] Starting 'svg'...
image_processor_1  | [03:10:08] Starting 'png'...
image_processor_1  | [03:10:08] Starting 'gif'...
image_processor_1  | [03:10:08] gulp-imagemin: Minified 0 images
image_processor_1  | [03:10:08] gulp-imagemin: Minified 0 images
image_processor_1  | [03:10:08] gulp-imagemin: Minified 0 images
image_processor_1  | [03:10:08] Finished 'jpg' after 247 ms
image_processor_1  | [03:10:08] Finished 'svg' after 247 ms
image_processor_1  | [03:10:08] Finished 'gif' after 247 ms
image_processor_1  | [03:10:09] gulp-imagemin: Minified 3 images (saved 838 kB - 72.9%)
image_processor_1  | [03:10:09] Finished 'png' after 1.42 s
image_processor_1  | [03:10:09] Finished 'default' after 1.42 s
image_processor_image_processor_1 exited with code 0

だいたい元画像の 20〜30% くらいのサイズに軽量化できているようです。

$ ; 元画像
$ ls -lh ./img/src
total 2256
-rw-r--r--@ 1 gondawara_yumeko  staff   595K  6 16 16:33 tech-blog-arch_auto-deploy.png
-rw-r--r--@ 1 gondawara_yumeko  staff   287K  6 16 15:55 tech-blog-arch_deploy-flow.png
-rw-r--r--@ 1 gondawara_yumeko  staff   240K  6 18 11:47 tech-blog-arch_webpack.png

$ ; Minified
$ ls -lh ./img/dest
total 624
-rw-r--r--  1 gondawara_yumeko  staff   189K  6 16 16:33 tech-blog-arch_auto-deploy.png
-rw-r--r--  1 gondawara_yumeko  staff    65K  6 16 15:55 tech-blog-arch_deploy-flow.png
-rw-r--r--  1 gondawara_yumeko  staff    50K  6 18 11:47 tech-blog-arch_webpack.png

仕組みの筋は悪くないと思うので、あとはこれを自動で回せるようにしたいところです。

ご参考までに gulpfile.js は以下のような感じです。

const gulp = require('gulp');

const imagemin = require('gulp-imagemin');

const imageminPngquant = require('imagemin-pngquant');
const imageminJpegtran = require('imagemin-jpegtran');

const srcDir = './img/src';
const destDir = './img/dest';

const jpg = () => {
  return gulp.src(srcDir + '/*.{jpg,JPG,jpeg,JPEG}')
    .pipe(imagemin([
      imageminJpegtran(), // Exif除去のため
      imagemin.mozjpeg({
        quality: 80,
        progressive: true
      })
    ]))
    .pipe(gulp.dest(destDir));
};

const svg = () => {
  return gulp.src(srcDir + '/*.{svg,SVG}')
    .pipe(imagemin([
      imagemin.svgo()
    ]))
    .pipe(gulp.dest(destDir));
};

const png = () => {
  return gulp.src(srcDir + '/*.{png,PNG}')
    .pipe(imagemin([
      imageminPngquant({
        quality: [0.7, 0.8],
        speed: 1,
        strip: true
      })
    ]))
    .pipe(gulp.dest(destDir));
};

const gif = () => {
  return gulp.src(srcDir + '/*.{gif,GIF}')
    .pipe(imagemin([
      imagemin.gifsicle({
        interlaced: true
      })
    ]))
    .pipe(gulp.dest(destDir));
};

exports.jpg = jpg;
exports.svg = svg;
exports.png = png;
exports.gif = gif;

exports.default = gulp.parallel(jpg, svg, png, gif);

Previewサイトの自動生成に Basic認証が連動しない

Amplify Console における Basic認証の取り扱い方 は以下の 2択しかなく、

  1. 本番サイトも含めて全ブランチに共通の ID/PW を使う。
  2. ブランチごとに (当該ブランチが作成されたあとに手動で) 個別に ID/PW を設定する。

Previewサイトの生成に連動して自動で Basic認証が ON になるような設定はできません。

何らかのフックで amplify update-branch API を叩くような作り込みは可能ですが、公式の機能として Previewサイトと連動するようになってほしいものです。

$ aws amplify update-branch \
  --app-id '{{ App ID }}' \
  --branch-name 'pr-{{ PR番号 }}' \
  --enable-basic-auth \
  --basic-auth-credentials "$( echo -n '{{ ID }}:{{ PW }}' | base64 )"

【2020年7月2日 追記】

どうやら Previewサイトはビルド開始時点ですでに存在する (= 「ビルド・アーティファクトの配備まで完了してはじめて操作可能になる」というわけではない) ようなので、ビルドと同時に Basic認証をかけるようにしてみました。

具体的には postBuildフェーズで amplify update-branch API を叩いています。

amplify.yml:

version: '0.1'
frontend:
  phases:
    build:
      commands:
        - ...ビルド処理...
    postBuild:
      commands:
        - |
          if [ "${AWS_BRANCH}" != "master" ]; then
            aws amplify update-branch \
              --app-id "${AWS_APP_ID}" \
              --branch-name "$( basename "${AWS_BRANCH_ARN}" )" \
              --enable-basic-auth \
              --basic-auth-credentials "$( echo -n "${BASIC_AUTH_ACCOUNT}" | base64 )"
          fi          

Pull request の番号は環境変数 AWS_BRANCH_ARN をパースして取得するのが簡単です。
(arn:aws:amplify:ap-northeast-1:{{ AWSアカウントID }}:apps/{{ App ID }}/branches/pr-{{ PR番号 }} という形式になっているので、basenameコマンドで一発です。)

また、本番サイトにまで Basic認証がかかってしまっては困るので、ブランチ名で判定しています。

Amplify Console のビルダーがこの API を実行するためには IAM で権限を与えないといけないのですが、今回は (用途が全然違うものの) Service role で対応してみました。
(本来はバックエンドをデプロイするための権限を与えるためのものです。)

これで一応、構成要素を安易に増やすことなく Previewサイトに自動で Basic認証をかける仕組みができました。


404 Not Found の取り扱い

Amplify Console にはデフォルトで以下のような リダイレクト・ルール が設定されているため、存在しないページへのアクセスは /index.html にリダイレクトされます。

/404.html を用意してそちらに飛ばすようなカスタマイズも可能ですが、当ブログではそこまで手が回っていないので /index.html のままにしています…。

いずれちゃんとした 404ページを作らないと…!

ネタ集めから記事化まで

ここまで技術的な仕組みを見てきました。

最後に、記事のネタ集めから執筆、公開までのコミュニケーション面での運用をご説明します。

ネタ集めの方針

当ブログは「今すぐ現場で使える Battle-tested な技術情報を発信する」をコンセプトに運営しています。

コンセプトに沿った記事を持続的に書き続けるために、以下の方針でネタ集めをすることにしました。

  • ブログの運営はインセンティブなし&ノルマなし
  • 小さなネタも気軽に投下 » みんなで育てる

ブログの運営はインセンティブなし&ノルマなし

執筆した記事の本数に対しては特別な人事評価は与えておらず、逆に記事を書かないからといってペナルティもありません。

記事の本数にインセンティブを与えてしまうと、とかく効率のよい記事で点数を稼ごうとするのが人の常というもの。
また、ノルマを課すと、それをこなすこと自体が目的になりがちです。

その帰結として「軽い記事」(ちょっとやってみた系や備忘的な Tips など) が乱立してブログのコンセプトがぼやけてしまう事態を危惧して、弊社ではインセンティブもノルマも与えないことにしました。
(念のため注記しますが、そのような「軽い記事」に価値がないと考えているわけではなく、単に当ブログのコンセプトに合わないため忌避したいという意図です。)

なお、評価対象としないのはあくまで「記事の本数」であって、コンセプトに合った良記事を書き、それがブログの目的に適えば (= 採用面での誘引として働いたり、事業の幅を広げるような新規案件に繋がったり)、当然ながら業務上の成果として高い評価を得られます。
(とはいえ定量的な評価は難しいですが…。)

小さなネタも気軽に投下 » みんなで育てる

通常業務の合間に記事1本分の質・量にふさわしいネタを捻り出すのは、やはり骨が折れるものです。

インセンティブもノルマもなく、さらに何のフォースも存在しないままでは、ネタ不足に陥るのは確実といえます。

そこで、ネタにも満たないネタのタネも臆せず気軽に投下して、そのタネのプールをみんなで揉んで洗って育てていくことにしました。

「みんなで」というところがポイントで、ネタ探しから執筆まで 1人ですべてを担当するのではなく、チームみんなで書きあげようぜ。豆鉄砲 (ネタにも満たないネタのタネ) でもいいから援護射撃を頼む。という姿勢です。

ネタ検討の流れ

ネタは常時 Trello で募集しており、誰でも自由にカードを作成したりコメントできます。

ボードのリスト構成 (工程) は以下のとおりです。

  1. アイデア
  2. 塩漬け
  3. 素材集め中
  4. 執筆準備OK
  5. 執筆中
  6. リリース済み

まず「アイデア」に気軽に投入します。

ネタの候補になるタネを拾い上げるのが目的なので、この段階では内容は粗々で OK です。

ある程度の数が集まったら、これをみんなで揉んで (筋の良し悪しを見極めて、仕分けをして) いきます。

ステータス 意味 処遇 行き先
却下 ブログのコンセプトに合わない 新たな価値解釈ができる日がくるまで寝かせる。 塩漬け
分量不足 1本の記事にするには分量が足りない 他のネタと抱き合わせて 1つの記事化を目指す。抱き合わせネタが揃うまで保留。 アイデア (動かさない)
もうひと押し 記事化するには課題が残る 課題が解決されるまで保留。 アイデア (動かさない)
筋良し 記事になりそう 記事にするための素材 (追加調査、検証) を集める。 素材集め中

「素材集め中」に移された筋良しのネタは、記事にするための追加調査や検証を実施し、データをまとめて考察します。
必ずしもネタの言い出しっぺがやる必要はなく、手が空いている人、興味がある人、誰がやっても OK です。

考察が済み、記事を書けるだけの素材が揃ったら、「執筆準備OK」に移して順次文章を書き上げていきます。

文章を書くのが苦手なスタッフもいるので、誰が書いてもよしとしています。
(なので、記事の執筆者表記はあくまで便宜的なものであり、実態は「エンジニア一同」であることが多いです。)

ネタのテンプレート

ネタを投下する際は以下のテンプレートを埋めるようにしています。

背景
====

どんな背景があってこの記事を書こうと思ったのか?

例:
Google Cloud Monitoring の Aggregation の概念がわかりづらかったので調べてみた。


価値
====

この記事は読み手にどんな価値を提案しているのか?

例:
この記事を読むことで、公式ドキュメントを軽く眺めた程度では理解が難しい Aggregation の概念をサッと理解できる。


我々が書く理由
==============

なぜ他者ではなく我々がこの記事を書くべきなのか?

例:
我々は GCP を扱うことも多い MSP であり、Monitoring のプロなので、正確な記事を書けるため。
かつ、わかりやすく解説しているサイトが少ないため。


構成
====

記事の大まかな構成は?

記事の構成を見出しレベルで、かつ、個々の見出しの主題がわかるように書く。

例:
1. はじめに: 記事の背景を説明
2. Aggregation の概念: どんな概念で構成されているのかを説明
   i. Aligner
   ii. Reducer
3. Aligner/Reducer の関数一覧: 関数の一覧を貼る
4. ...


図表
====

もしあれば、手書きでいいので、記事に掲載する図表を。

イメージが伝わればよいので、言葉で説明しても OK.


参考資料
========

参考になる情報を貼る。

他サイトや作業履歴 (Trello, wiki) など。


TODO
====

記事として完成させるための残件。(疑問や未調査事項など)

列挙した項目は次のステップ (素材集め中) で調査&解決する。

例:
* Webコンソールだと ALIGN_PERCENT_CHANGE が 選択肢に出てこないのが謎
* Webコンソールだと STRING型のメトリクスを選択できないのが謎
* ...

ただし、項目が多くてネタ出しを億劫に感じるようになってしまっては本末転倒なので、最低限ネタの雰囲気がわかればよいということで必須項目は「背景」だけとしています。

空欄の項目はネタの仕分け前後の必要なタイミングで意見を出し合いながら埋めていくのですが、我々は特に「価値」と「我々が書く理由」を重視していて、ここが明確でないネタはしっかりと議論してブログに公開する意味を見極めてから記事化を進めるようにしています。

スタンプ

アイデア欄のカードはそのままだと状態がわからないので、仕分けステータスを区別できるように スタンプ を貼ってみました。

個人的には見た目がモッサリしているのがちょっと気に入りませんが、ステータスがパッと見でわかりやすくて視認性は悪くないと思っています。

まとめ

当ブログの思想や技術構成、運用設計の詳細をお伝えしました。

いくつか課題はあるものの、目的に適う仕組みを作ることができたと感じています。

また、今のところ質・量ともに良い調子でネタを集められているので、ネタ集めの取り組みもしばらくは現行のまま継続していこうと思っています。

今後ともよろしくお願い申し上げます。


当記事の図表には Font Awesome のアイコンを使用しています。