なにこれ
自分のブログ(Gatsby製)のビルドが遅すぎてNetlifyでタイムアウトしてしまうので、ビルドチューニングをしました。 15分以上かかっていたビルドが7分以下になり50%短縮できたので、その時の知見をまとめます。 割と地味な作業が多いですが、Gatsbyのビルド時間短縮に関するTipsを効果がある順にご紹介します。
- 🚀1. 画像生成処理を並列化する
- 🚅2. 必要な画像だけクエリで取得する
- 🚂3. 生成画像の種類を減らす
- 🚗4. 画像の縦横サイズを最適化する
- 🚴🏻5. 画像のファイルサイズを圧縮する
- 🏃🏻6. 生成する画像をシンプル&低クオリティにする
- 🏊🏻7. netlify-plugin-gatsby-cacheを使う
- 💎番外編1: Circle CIでビルドしてNetlifyにデプロイする
なぜビルドが遅いのか → 画像生成に時間がかかっている
ビルド時間の内訳を見たところ、Generating image thumbnails
に10分以上かかっており大半を締めていました。
これはgatsby-imageで様々なブラウザ幅に最適化するために、サイズ違いの画像を複数枚生成するための処理です。
gatsby-imageまわりのビルドチューニングについて、日本の記事をあまり見かけませんが、 英語の記事やGitHubでは、画像生成が遅いという記事をちらほら見かけました。
- Improve Gatsby Build Speeds With Parallel Image Processing
- Gatsby build on Netlify fails(Image generation): Command did not finish within the time limit · Issue #8056 · gatsbyjs/gatsby
- Gatsby stuck on generating image thumbnails · Issue #23033 · gatsbyjs/gatsby
あとGatsbyの公式ドキュメントでも画像の取り扱いについて言及されています。
そのため、本記事では、いかに画像生成処理の時間を短縮するかをメインにTipsを紹介していきます。
1. 画像生成処理を並列化する
gatsby-parallel-runnerで画像生成処理を並列化します。 何をやっているかというと、gatsby buildで一番重い画像生成処理を、Google Cloud Platform(以降GCP)に投げて、並列で処理して結果を受け取るというプラグインです。GCPのアカウントが必要ですが、この方法が一番楽に劇的にビルド時間を短縮できます。 Netlifyの記事では6分21秒かかっていたビルドが3分22秒、およそ半分になったそうです。
設定方法はNetlifyの記事にキャプチャ付きで詳しく載っているので、そちらをご覧いただければ簡単に設定できると思います。GCPの環境をCLIで構築してNetlifyに環境変数を設定すればすぐに使えるようになります。
2. 必要な画像だけクエリで取得する
フィルタを全く指定しないクエリは、全ての画像を取得してしまうのでビルド時間が大幅に増えてしまいます。 そのため、クエリで取得する画像は他画像とフォルダを分けて、クエリのフィルタ条件を指定するなりしましょう。 そうすることで不必要に画像を取得せずに済み、ビルド時間を短縮できます。
query {
- // 全画像を取得してしまう
- allFile {
+ // 検索条件を指定して必要な画像だけ取得するようにする
+ allFile(filter: {relativePath: {regex: "/^thumbnail/*/"}}) {
edges {
node {
childImageSharp {
fluid {
...GatsbyImageSharpFluid
}
}
}
}
}
}
クエリのフィルタ条件をContext経由で指定する
例えば、トップページの上部にピックアップした3つ記事のサムネイル付きで表示する場合、 3つのサムネイル画像だけ取得するクエリを定義したいところですが、クエリに画像のパスをフィルタ条件としてハードコードしたくはないので、一旦、全てのサムネイルをクエリで取得して、JS(コンポーネント)中で3つにフィルタするようなケースがあります。この場合、不必要な画像も取得してしまうのでビルド時間が増えます。
StaticQueryなどは、その名の通りStaticなのでJS中の変数をフィルタ条件として使うことができませんが、 ページテンプレートに指定しているコンポーネントのクエリではContextの値がフィルタ条件として使えます。 これを利用して以下のように、3つの画像だけ取得するクエリを定義でき、不必要に画像を生成せずに済み、ビルド時間を短縮できます。
// 設定ファイルで3つの画像パスを指定する
module.exports = {
featuredPosts: [
'/blog/2019/05/09',
'/blog/2018/05/08',
'/blog/2016/03/18'
],
};
// 設定ファイルを読み込むconst { featuredPosts } = require('./config/featured-posts.js');
exports.createPages = ({ graphql, actions }) => {
// 中略
// ページ生成時にContextとして渡す createPage({
path,
component: postTemplate,
context: {
featuredPostPathList: featuredPosts, },
});
// 中略
});
// 中略
export const query = graphql`
query($featuredPostPathList: [String]) { allMarkdownRemark(filter: { frontmatter: { path: { in: $featuredPostPathList } } }) { edges {
node {
frontmatter {
cover {
childImageSharp {
fluid {
...GatsbyImageSharpFluid
}
}
}
}
}
}
}
}
`;
// 中略
なおsrc/pages/index.jsx
のようにgatsby-node.js
で明示的にページ生成しないようなコンポーネントでも、以下のようにgatsby-node.js
のonCreatePage
でページを再生成することでContextを指定できます。
exports.onCreatePage = ({ page, actions }) => {
const { createPage, deletePage } = actions
// ルートページの場合のみ処理継続
if (page.path !== '/') {
return;
}
// いったんルートページを削除
deletePage(page)
// pageオブジェクトをもとにルートページを再生成
createPage({
...page,
context: {
...page.context,
// Contextにクエリのフィルタ条件を指定 featuredPostPathList: featuredPosts, },
})
}
3. 生成画像の種類を減らす
以下のように同じ画像でも、記事埋め込み用、OGP画像用などに違う縦横サイズ、クオリティを指定していると、画像生成枚数が増えて、ビルド時間も増えてしまいます。 少しの違いであれば、ある程度妥協して同じ縦横サイズ、クオリティを指定して、生成画像枚数を減らしましょう。 これにより、大幅にビルド時間を短縮できます。
query {
images: allFile(filter: {relativePath: {regex: "/^thumbnail/*/"}}) {
edges {
node {
childImageSharp {
- // 記事の最大幅にあわせた少し小さめで低品質な画像を取得
- fluid(maxWidth: 800, quality: 50, pngQuality: 50) {
+ // 生成画像枚数を減らすために、あえて縦横幅、クオリティをサムネイルに合わせる
+ fluid(maxWidth: 1200, quality: 90, pngQuality: 90) {
...GatsbyImageSharpFluid
}
}
}
}
}
}
// OGP画像用に少し大きめで高品質な画像を取得
query {
images: allFile(filter: {relativePath: {regex: "/^thumbnail/*/"}}) {
edges {
node {
childImageSharp {
fluid(maxWidth: 1200, quality: 90, pngQuality: 90) {
...GatsbyImageSharpFluid
}
}
}
}
}
}
4. 画像の縦横サイズを最適化する
画像のサイズが大きいとビルド時間は指数関数的に増加します。
そのため、表示幅に併せて画像の縦横比を縮小しましょう。
例えば記事中で最大800pxで表示するような場合、Retina対応も考慮すると最大幅は800px * 2 = 1600pxで十分です。
そのためビルド前に1600pxより幅が大きい画像は全て1600pxに縮小しましょう。
Sharpというライブラリを使えばできます。
以下のようなスクリプトを定義して、package.jsonにoptimizeImages
のようなnpm scriptsを定義しましょう。
const sharp = require(`sharp`)
const glob = require(`glob`)
const fs = require(`fs`)
// 最適化したい画像のパスを正規表現で指定
const matches = glob.sync(`src/pages/**/*.{png,jpg,jpeg}`)
// Retinaを考慮し2倍にする
const MAX_WIDTH = 800 * 2
Promise.all(
matches.map(async match => {
const stream = sharp(match)
const info = await stream.metadata()
if (info.width <= MAX_WIDTH) {
return
}
// 画像幅をMAX_WIDTHまで縮小して上書き
const outputBuffer = await stream.resize(MAX_WIDTH).toBuffer()
fs.writeFileSync(match, outputBuffer)
console.log(`success resize markdown image. ${info.width} -> ${MAX_WIDTH}, ${match}`)
return 'Success'
})
)
"scripts": {
// 中略
"optimizeImages": "node ./scripts/optimize-images.js",
// 中略
}
こうすればnpm run optimizeImages
で画像の縦横サイズを最適化できます。
5. 画像のファイルサイズを圧縮する
写真は無駄に高画質でファイルサイズが大きくなりがちです。
この場合、imageminを使って画像のファイルサイズを圧縮しましょう。
以下のようなスクリプトを定義して、package.jsonにcompressImages
のようなnpm scriptsを定義しましょう。
jpegはimagemin-mozjpeg、pngはimagemin-pngquantを使えば画質をそれほど落とすことなく圧縮できます。
const glob = require(`glob`);
const fs = require(`fs`);
const path = require("path");
const imagemin = require('imagemin');
const imageminMozjpeg = require('imagemin-mozjpeg');
const imageminPngquant = require('imagemin-pngquant');
const addSizeInfo = (filePath) => {
const state = fs.statSync(filePath);
return {
size: state.size,
filePath
}
};
(async () => {
const matchesThumbnail = glob.sync(`src/images/thumbnail/**/*.{png,jpg,jpeg}`);
const matchesPage = glob.sync(`src/pages/**/*.{png,jpg,jpeg}`);
[...matchesThumbnail, ...matchesPage].map(addSizeInfo).forEach(async(factor) => {
const filePath = factor.filePath;
const fileDir = path.dirname(filePath)
const fileExt = path.extname(filePath)
if(['.png'].includes(fileExt)) {
await imagemin([filePath], {
destination: fileDir, // overwrite
plugins: [
imageminPngquant()
]
});
const compressedSize = fs.statSync(filePath).size;
console.log(`${factor.size} -> ${compressedSize} ${filePath}`);
} else if(['.jpg','.jpeg'].includes(fileExt)){
await imagemin([filePath], {
destination: fileDir, // overwrite
plugins: [
imageminMozjpeg()
]
});
const compressedSize = fs.statSync(filePath).size;
console.log(`${factor.size} -> ${compressedSize} ${filePath}`);
} else {
console.log("Can't optimize", filePath)
}
})
})();
"scripts": {
// 中略
"compressImages": "node ./scripts/compress-images.js",
// 中略
}
こうすればnpm run compressImages
で画像のファイルサイズを圧縮できます。
6. 生成する画像をシンプル&低クオリティにする
gatsby-imageでは、生成する画像の種類を以下のように選択できます。
レスポンシブ対応する場合Fluid
系の画像の指定が必須ですが、
GatsbyImageSharpFluid_tracedSVG
やGatsbyImageSharpFluid_withWebp
はSVGやWebpなどの画像も併せて生成することになるので、ビルド時間は長くなってしまいます。 特にこだわりがなければ一番シンプルなGatsbyImageSharpFluid
を選択するとビルド時間が短縮できるでしょう。
GatsbyImageSharpFixed
← Fixed系はレスポンシブだと使えないGatsbyImageSharpFixed_noBase64
GatsbyImageSharpFixed_tracedSVG
GatsbyImageSharpFixed_withWebp
GatsbyImageSharpFixed_withWebp_noBase64
GatsbyImageSharpFixed_withWebp_tracedSVG
GatsbyImageSharpFluid
← シンプルGatsbyImageSharpFluid_noBase64
← 最初に表示するBase64のぼかし画像がないが、それでいいなら一番シンプルGatsbyImageSharpFluid_tracedSVG
← SVGを生成するのでビルド時間が長くなるGatsbyImageSharpFluid_withWebp
← Webpを生成するのでビルド時間が長くなるGatsbyImageSharpFluid_withWebp_noBase64
GatsbyImageSharpFluid_withWebp_tracedSVG
<-SVGとWebpを生成するのでビルド時間がかなり長くなるGatsbyImageSharpFluidLimitPresentationSize
また、クエリで生成画像のクオリティを指定できます。 クオリティある程度妥協できるならば、低い値を指定することでビルド時間を短縮できます。
query {
images: allFile(filter: {relativePath: {regex: "/^thumbnail/*/"}}) {
edges {
node {
childImageSharp {
fluid(
maxWidth: 800,
quality: 50, pngQuality: 50 ) {
...GatsbyImageSharpFluid
}
}
}
}
}
}
7. netlify-plugin-gatsby-cacheを使う
Netlifyでビルド&ホストしているなら、netlify-plugin-gatsby-cacheが使えます。 これは前回ビルド時のキャッシュを用いてビルド時間を短縮するためのNetlifyプラグインです。 自分は、コレを導入したおかげで、記事を追加するだけならビルド時間が2分程度で済むようになりました。
以下のようにnetlify.toml
を設定し、package.jsonのnpm scriptでgatsby build
にちょっとした引数を指定するだけキャッシュが有効になります。
// 中略
[[plugins]]
package = "netlify-plugin-gatsby-cache"
// 中略
"scripts": {
// 中略
"build": "GATSBY_EXPERIMENTAL_PAGE_BUILD_ON_DATA_CHANGES=true gatsby-parallel-runner build --log-pages",
// 中略
}
ただ、こちらはキャッシュを使ったビルド時間短縮方法ですので、初回のビルドは短縮されません。 Netlifyのビルドでタイムアウトを解決したい場合の根本的な解決策にはならないので、気をつけてください。 キャッシュでビルド時間がタイムアウト時間以内に収まっていたとしても、ドラスティックな機能改修でほとんどキャッシュが効かない場合や、なんらかの不具合でキャッシュなしでクリーンビルドが必要になった場合、結局タイムアウトが発生してしまいます。 この場合は先にあげた1~6のTipsを実施して、根本的なビルド時間の短縮を図ってください。
番外編1: Circle CIでビルドしてNetlifyにデプロイする
本線からは少し脱線しますが、どうしてもNetlifyでタイムアウト問題が克服できない or ビルド時間が安定しない時はCircleCIを使ってビルドすると良いです。Netlify側でコケても、CircleCIでビルド検証ができるようになります。 CircleCIはタイムアウトになる心配もないし、Netlifyより少しビルドが早くなります。
開発用依存ライブラリにnetlify-cliして以下のスクリプトを定義しましょう。
なるべくキャッシュして次回以降のビルド時間を短縮するようにしています。
ビルドが終わったら、netlify-cliでビルド資産をNetlifyにdeployしています。
masterブランチの場合は--prod
を指定して本チャンdeployになるようにしています。
deploy時は、Netlifyのトークンが必要になるので、事前にNetlifyでトークンを発行して、CircleCIの環境変数に設定してあげましょう。
version: 2.1
executors:
node-executor:
docker:
- image: circleci/node:12
commands:
gatsby-build:
steps:
- checkout
- restore_cache:
keys:
# when lock file changes, use increasingly general patterns to restore cache
- node-v1-{{ .Branch }}-{{ checksum "package-lock.json" }}
- node-v1-{{ .Branch }}-
- node-v1-
- run: npm install
- save_cache:
paths:
- ~/usr/local/lib/node_modules # location depends on npm version
key: node-v1-{{ .Branch }}-{{ checksum "package-lock.json" }}
- restore_cache:
keys:
- gatsby-public-cache-{{ .Branch }}
- gatsby-public-cache-
- run: GATSBY_CPU_COUNT=2 NODE_OPTIONS="--max-old-space-size=8192" npm run build
- save_cache:
key: gatsby-public-cache-{{ .Branch }}
paths:
- ./public
workflows:
version: 2
build-deploy:
jobs:
- build:
filters:
branches:
ignore:
- master
- release:
filters:
branches:
only:
- master
jobs:
build:
executor: node-executor
working_directory: ~/repo
steps:
- gatsby-build
- run:
name: Netlify Deploy
command: ./node_modules/.bin/netlify deploy --dir=public --message "deploy preview from $CIRCLE_BRANCH" --auth $NETLIFY_ACCESS_TOKEN
release:
executor: node-executor
working_directory: ~/repo
steps:
- gatsby-build
- run:
name: Netlify Deploy
command: ./node_modules/.bin/netlify deploy --prod --dir=public --message "deploy from $CIRCLE_BRANCH" --auth $NETLIFY_ACCESS_TOKEN
番外編2:効果がみられなかった取り組み
ビルド時間短縮のためアレコレと試しましたが、効果がなかったものもあります。
画像取得系StaticQueryを集約する
複数箇所に散らばっていStaticQueryを1つコンポーネントに集約してみました。 確かにクエリの実行回数は減りましたが、画像生成枚数は変わらないのでビルド時間は短縮されませんでした。
クエリをgatsby-node.jsに集約する
上記と似ていますが、すべてのクエリをgatsby-node.js
で発行して、クエリの結果をContextで各ページのコンポーネントに渡すようにしても、ビルド時間は短縮されませんでした。
またgatsby-node.js
にクエリを集約するのは、以下のような弊害もあるため、あまりオススメしません。
gatsby develop
時にgatsby-node.js
を修正してもホットリロードされない- ページのコンポーネントで使うデータを取得するためのクエリが、
gatsby-node.js
という離れた場所にあるので管理しづらい - クエリでGraphQLのフラグメントが使えない
- gatsby-imagesのGatsbyImageSharpFluidなどが使えないので、以下のようにフラグメントに定義されてる全プロパティを書き出す必要があります。
query {
allFile(filter: {relativePath: {regex: "/^thumbnail/*/"}}) {
edges {
node {
childImageSharp {
fluid {
base64 aspectRatio src srcSet sizes }
}
}
}
}
}
まとめ
Gatsbyは割とgatsby-imageとの戦いで、画像の取り扱いが面倒くさく、ビルド時間も長くなりがちですが、 今回紹介したようなTipsを使えば、ある程度ビルド時間は短縮できます。 ただ、これらTipsを実施してもビルド時間が短縮できない場合は、いっそのことgatsby-imageを使うのをやめて、 他の画像ホスティングサービスを使うのもアリなんじゃないかと思います🍅
参考
- Improve Gatsby Build Speeds With Parallel Image Processing
- Open source parallel processing for Gatsby - DEV Community 👩💻👨💻
- Preoptimizing Your Images | GatsbyJS
- Use Imagemin to compress images
- gatsby-plugin-sharp | GatsbyJS
- Enable Gatsby Incremental Builds on Netlify
- Building gatsby on CircleCi and deploying on Netlify — Orestis Ioannou
- Use Imagemin to compress images
- 5 Optimizations to Get Faster Gatsby Builds Today