Hugo を使った CloudFront + S3 のブログサイトを構築してみた 〜 GitHub Actions で CI/CD 付き

個人ブログサイトを AWS で作れる!?
2022.08.02

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

AWS と GitHub Actions の勉強を兼ねて静的なブログサイトを構築・公開してみませんか?という試みで、Hugo と CloudFront + S3 で構成のブログサイト作ってみました。

手を動かしてみると得られるものも多いかと思います。

  • AWS リソースの構築は CloudFormation
  • 静的サイトジェネレーターは Hugo
  • ブログ記事の投稿から Web 公開までは GitHub Actions(CI/CD)

構築するもの

以下の構成を作成します。ドメイン名の取得と、ACM と証明書発行は事前に済ますことを前提とします。

以下のディレクトリ構成で進めます。後々sample-blogディレクトリに Hugo で使うファイルを保存することになります。

$ tree -L 2
.
├── cloudformation
│   ├── cloudfront-s3.yaml
│   ├── github-oidc.yaml
│   └── iam-role.yaml
└── sample-blog
    ├── archetypes
    ├── config.toml
    ├── content
    ├── data
    ├── layouts
    ├── public
    ├── resources
    ├── static
    └── themes

今回構築に使用したファイルは以下においてあります。

bigmuramura/hugo-cloudfront-s3

AWS 側の準備

静的サイトを公開するための CloudFront + S3 の構築と、GihHub Actions で CI/CD を行うために必要な権限を作成します。

CloudFront + S3

CloudFront(CDN)+ S3 構成にカスタムドメインでアクセスできる環境を Cloudformation で作成します。

前提条件

詳細は以下リンクの紹介している構成と同じですのでご参照ください。

あとで CloudFront Functions を追加することになるのですが当初デプロイしたコードを載せておきます。最初から完成形のコードを使いたい方は後半に改めてコードを載せておりますのでそちらをご利用ください。

折りたたみ

cloudfront-s3.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: Cloudfront and S3

Parameters:
  ProjectName:
    Description: Project Name
    Type: String
    Default: unnamed
  Environment:
    Description: Environment
    Type: String
    Default: dev
    AllowedValues:
      - prod
      - dev
  S3BucketName:
    Description: Bucket name for static site
    Type: String
  AliasName:
    Description: Alias for CloudFront
    Type: String
  HostedZoneId:
    Description: Route53 Host Zone ID
    Type: String
  CertificateId:
    Description: ACM Certificate ID must be us-east-1 region
    Type: String

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Common Settings
        Parameters:
          - ProjectName
          - Environment
      - Label:
          default: SSL Settings
        Parameters:
          - AliasName
          - HostedZoneId
          - CertificateId

Resources:
  # ------------------------------------------------------------------------------------ #
  # S3
  # ------------------------------------------------------------------------------------ #
  # Static site bucket
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}
      OwnershipControls:
        Rules:
          - ObjectOwnership: "BucketOwnerEnforced"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
            BucketKeyEnabled: false
      LifecycleConfiguration:
        Rules:
          - Id: AbortIncompleteMultipartUpload
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: "Enabled"
          - Id: NoncurrentVersionExpiration
            NoncurrentVersionExpiration:
              NewerNoncurrentVersions: 3
              NoncurrentDays: 1
            Status: Enabled
  # Bucket Policy for CloudFront
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}

  # ------------------------------------------------------------------------------------ #
  # CloudFront
  # ------------------------------------------------------------------------------------ #
  CloudFrontDistribution:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        PriceClass: PriceClass_All
        Origins:
          - DomainName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}.s3.${AWS::Region}.amazonaws.com
            Id: !Sub "S3origin-${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}"
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: !Sub "S3origin-${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}"
          Compress: true
          ViewerProtocolPolicy: redirect-to-https
          # CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          ForwardedValues:
            Cookies:
              Forward: none
            QueryString: false

        Logging:
          Bucket: !GetAtt S3BucketLogs.DomainName
          IncludeCookies: false
        Aliases:
          - !Ref AliasName
        ViewerCertificate:
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
          AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}"
        HttpVersion: http2
        IPV6Enabled: true
        Enabled: true
  # OAI
  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: Unique Domain Hosting Environment
  # CloudFront log bucket
  S3BucketLogs:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-cloudfrontlogs-${AWS::AccountId}
      OwnershipControls:
        Rules:
          - ObjectOwnership: "ObjectWriter"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
            BucketKeyEnabled: false
      LifecycleConfiguration:
        Rules:
          - Id: AbortIncompleteMultipartUpload
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: "Enabled"
          - Id: CurrentVersionExpiration
            ExpirationInDays: 180
            Status: "Enabled"
          - Id: NoncurrentVersionExpiration
            NoncurrentVersionExpiration:
              NoncurrentDays: 30
            Status: "Enabled"
  # Alias Record
  Route53RecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: !Sub ${AliasName}
      HostedZoneId: !Sub ${HostedZoneId}
      Type: A
      AliasTarget:
        DNSName: !GetAtt CloudFrontDistribution.DomainName
        HostedZoneId: Z2FDTNDATAQYW2 # fixed

AliasNameHostedZoneIdなど環境依存のパラメータはご自身の環境に合わせて変更してください。rain コマンドでデプロイした引数(Cloudformation のパラメータ)を参考に記します。

$ rain deploy ./cloudformation/cloudfront-s3.yaml hugo-cloudfront-s3-stack --params ProjectName=hugo,Environment=dev,S3BucketName=hugo-static-web-site,AliasName=hugo.ohmura.classmethod.info,HostedZoneId=Z04083533GATCXXXXXXX,CertificateId=d6627991-XXXX-4f69-XXXX-ba004cXXXXXX

rain コマンドについては以下の記事をご参照ください。

OpenID Connect ID Provider

GitHub Actions 用に AWS アカウント1つにつき、OpenID Connect ID Provider 設定が1つ必要になります。

IAM ロールを利用した GitHub Actions をすでに実行している AWS アカウントであればスキップしてください。初回の場合は規定の設定を作成しましょう。

規定の設定を作成します。

github-oidc.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: GitHub OIDC Provider

Resources:
  GithubOidc:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: https://token.actions.githubusercontent.com
      ClientIdList:
        - sts.amazonaws.com
      ThumbprintList:
        - 6938fd4d98bab03faadb97b34396831e3780aea1

rain コマンドでデプロイしました。

$ rain deploy ./cloudformation/github-oidc.yaml github-oidc-stack

IAM ロール

CloudFront + S3 構成の S3 バケットへ GitHub Actions からファイル転送するための権限(IAM ロール)を作成します。

iam-role.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: IAM Role for Hugo

Parameters:
  ProjectName:
    Description: Project Name
    Type: String
    Default: unnamed
  Environment:
    Description: Environment
    Type: String
    Default: dev
    AllowedValues:
      - prod
      - dev
  GitHubUserName:
    Type: String
  RepositoryName:
    Type: String
  DeployTargetS3BucketName:
    Type: String

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Common Settings
        Parameters:
          - ProjectName
          - Environment

Resources:
  IAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${ProjectName}-${Environment}-S3AccessRole
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Action: sts:AssumeRoleWithWebIdentity
            Principal:
              Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com"
            Condition:
              StringLike:
                token.actions.githubusercontent.com:sub: !Sub repo:${GitHubUserName}/${RepositoryName}:*
      ManagedPolicyArns:
        - !Ref S3AccessPolicy
  S3AccessPolicy:
    Type: "AWS::IAM::ManagedPolicy"
    Properties:
      ManagedPolicyName: !Sub ${ProjectName}-${Environment}-S3AccessPolicy
      Path: "/"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action:
              - "s3:ListBucket"
              - "s3:PutObject"
            Resource:
              - !Sub arn:aws:s3:::${DeployTargetS3BucketName}
              - !Sub arn:aws:s3:::${DeployTargetS3BucketName}/*

Outputs:
  Role:
    Value: !GetAtt IAMRole.Arn

GitHubUserNameや S3 バケット名など環境依存のパラメータはご自身の環境に合わせて変更してください。rain コマンドでデプロイした引数(Cloudformation のパラメータ)を参考に記します。

$ rain deploy ./cloudformation/iam-role.yaml hugo-iamrole-githubactions-stack --params GitHubUserName=bigmuramura,RepositoryName=hugo-cloudfront-s3,DeployTargetS3BucketName=hugo-dev-hugo-static-web-site-12345789012,ProjectName=hugo,Environment=dev

以上で AWS 側の準備は完了です。CloudFront + S3 構成の静的コンテンツ配信するガワを作成し、GitHub Actions から AWS へアクセスすための認証(OpenID Connect provider)、権限(IAM ロール)を用意しました。

Hugo の準備

静的サイトジェネレーターの Hugo を使って静的サイトを作成します。Quick Start に沿って進めてローカル環境でブログ記事を書き、記事をプレビュー画面で確認するまでを行うこととします。CI/CD まで完成したらおしゃれなテーマへの変更などカスタマイズ作業もはかどりますので Web 公開できる環境を目指しましょう。

Hugo のコンテナイメージがあったので使ってみました。

klakegg/hugo - Docker Image | Docker Hub

現時点の最新版を利用します。初期セットアップをコンテナ内から実施します。

$ docker run --rm -it -v $(pwd):/src klakegg/hugo:0.101.0-ext-alpine shell

初期セットアップ

hugo new site hogeを実行するとhogeディレクトリが作成されます。作成したディレクトリに移動してから、テーマをサブモジュールで追加します。

$ hugo new site sample-blog
$ cd sample-blog
$ git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
$ echo theme = \"ananke\" >> config.toml

git submodule addコマンドによりルートディレクトリに.gitmodulesが追加(新規作成)されます。

.gitmodules

[submodule "sample-blog/themes/ananke"]
	path = sample-blog/themes/ananke
	url = https://github.com/theNewDynamic/gohugo-theme-ananke.git

新規ブログ記事を作成します。コンテナは一度終了して作成コマンドで生成された Markdown 形式のファイルをお好きなエディタで開いて編集します。

$ hugo new posts/my-first-post.md
$ exit

デフォルトのテンプレートから生成された素っ気ないファイルを編集しました。

sample-blog/posts/my-first-post.md

---
title: "こんにちは"
date: 2022-07-23T03:58:27Z
draft: false
---
## テスト投稿
こんにちは

サーバー起動

改めて Hugo コンテナを起動して新規投稿記事のプレビューを確認します。

.github/workflows/hugo.yaml

docker run --rm -it \
  -v $(pwd):/src \
  -p 1313:1313 \
  klakegg/hugo:0.101.0 \
  server

http://localhost:1313 で TOP ページにアクセスできます。

テスト投稿した記事も確認できました。

以上で Hugo の準備は完了です。AWS へのデプロイは GitHub Actions から実行します。

GitHub Actions の準備

GitHub Actions を利用して、Build(静的ファイルの生成)と、Deploy(S3 へファイルを転送)を実行する GitHub Actions のワークフロー(hugo.yml)を作成します。

$ tree .github
.github
└── workflows
    └── hugo.yml

ワークフローの解説

環境依存の変更箇所とポイントを簡単に説明します。

hugo.yml

name: Hugo

on:
  push:
    branches:
      - main
    paths:
      - "sample-blog/**"

defaults:
  run:
    working-directory: sample-blog

env:
  AWS_REGION: ap-northeast-1
  AWS_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/hugo-dev-S3AccessRole
  AWS_TARGET_S3_BUCKET: ${{ secrets.AWS_TARGET_S3_BUCKET }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          submodules: recursive # Fetch Hugo themes (true OR recursive)
          fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: "0.101.0"

      - name: Build
        run: hugo --minify

      - name: Configure AWS credentials from IAM Role
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Upload file to S3
        run: aws s3 sync --delete public s3://${{ env.AWS_TARGET_S3_BUCKET }}

GitHub Secrets

AWS アカウント ID はシークレットに登録しました。S3 バケット名もシークレットに登録したのは、S3 バケット名の重複回避 のために AWS アカウント ID を付与していたためです。

  • AWS_ACCOUNT_ID
  • AWS_TARGET_S3_BUCKET

暗号化されたシークレット - GitHub Docs

ワークフロー実行のトリガー

mainブランチにマージ(push)かつ、Hugo のブログ用のディレクトリ(sample-blog)に変更があったときをトリガーとしました。

モノレポ構成で CloudFormation 用のコードも同レポジトリに保存している都合、CloudFormation テンプレートの修正時し、main ブランチにマージしたときも Hugo のビルド・デプロイが走らないためにです。

ブログ用のディレクトリ名は各環境によって変わるため変更してください。

on:
  push:
    branches:
      - main
    paths:
      - "sample-blog/**"

作業ディレクトリ名指定

ブログ用のディレクトリ名は各環境によって変わるため変更してください。作業用ディレクトリとしてブログ用ディレクトリ(sample-blog)を指定しています。

defaults:
  run:
    working-directory: sample-blog

環境変数の設定

AWS_ROLE_ARN:CloudFormation で作成した IAM ロールの ARN に変更してください。シークレットに登録した値と組み合わせて環境変数を登録しました。

env:
  AWS_REGION: ap-northeast-1
  AWS_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/hugo-dev-S3AccessRole
  AWS_TARGET_S3_BUCKET: ${{ secrets.AWS_TARGET_S3_BUCKET }}

OpenID Connect 設定

GitHub Actions 上に AWS に対して永続的な認証情報(IAM アクセスキー)を持たせず、よりセキュアに OpenID Connect を利用した短期間有効な認証方法を利用します。

以下のドキュメントを参考にしました。

Configuring OpenID Connect in Amazon Web Services - GitHub Docs

  • id-token: write: OIDC JWT ID Token をリクエストするために必要
  • contents: read: actions/checkoutを利用するために必要
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

Hugo Build 設定

Markdown 形式で執筆した記事を含め Webサイトに必要な静的ファイルを hugo コマンドで生成します。

まず、actions/checkout を利用してソースコードをチェックアウトします。

  • submodules: recursive: Hugo のテーマ取得のために必要
  • fetch-depth: 0: 全タグ、全ブランチの全履歴を取得

Hugo の Build には peaceiris/actions-hugo を利用しました。設定方法は Getting started を参考にしています。

  • hugo-version: "0.101.0": Hugo のバージョンはローカルで確認済みのバージョンに合わせて指定してください
  • run: hugo --minify: HTML, xml, jsなどファイルを圧縮(不要な改行やインデントを削除)して生成
    • public ディレクトリ配下に必要な静的ファイルが保存されます。
    • 参考: hugo | Hugo

      - name: Checkout
        uses: actions/checkout@v3
        with:
          submodules: recursive # Fetch Hugo themes (true OR recursive)
          fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: "0.101.0"

      - name: Build
        run: hugo --minify

GitHub Actions から AWS へのアクセス権限取得

AWS へのアクセス権限(時限制)を手に入れ、静的サイト用の S3 バケットに GitHub Actions からファイル転送できる準備をします。

aws-actions/configure-aws-credentialsを利用します。GitHub OCID プロバイダーを使用した短期間有効なクレデンシャルを取得し、CloudFormation で作成した特定の S3 バケットへファイル転送できる IAM ロールを引き受けます(Assume Role)。

      - name: Configure AWS credentials from IAM Role
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

aws s3 sync オプション

public ディレクトリに保存されている静的ファイルを S3 バケットへ転送します。

  • --delete
    • 同期元(GitHub Actions)にないファイルは同期先(S3 バケット)から削除します
    • S3 バケットではバージョニングを有効にしているため完全に削除されるわけではありません
      - name: Upload file to S3
        run: aws s3 sync --delete public s3://${{ env.AWS_TARGET_S3_BUCKET }}

説明が長くなりましたが以上です。

AWS でブログを公開

Hugo の準備と、GitHub Actions の準備で作成したローカルのファイル類をコミットしてプッシュして、mainブランチにマージすると GitHub Actions が実行され S3 バケットに静的ファイルが配置されます。 これで終わりですと言いたかったのですがサブページを表示できないという事に気づきました。

サブページが表示できない

独自ドメイン名でアクセスするとローカルで確認したと同じ TOP ページにアクセスできます。

しかし、ルート以外のサブディレクトリでは index.html が補完されないため記事のリンクをクリックしても表示できません。パスの末尾にindex.htmlを手動で入力するとサブページを表示できます。

原因と対応方法

原因

CloudFront のデフォルトルートオブジェクトの設定で index.htmlを指定しているため、ルートディレクトリ(TOP ページ)は問題ないのですが、サブディレクトリ(投稿記事など)ではindex.htmlを返してくれません。ということをすっかり忘れていました。

対応方法

CloudFront Functions でindex.htmlを補完してあげます。スクリプトは公式ドキュメントで公開されていたためそのまま利用します。他には Lambda@Edge を使う、S3 バケットの静的ホスティングを有効化する方法もあります。

Add index.html to request URLs that don’t include a file name - Amazon CloudFront

CloudFront Functions のコードも含め Cloudformation テンプレートで作成し、CloudFront へ紐付けるコードを追加しました。

cloudfront-s3.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: Cloudfront and S3 with CloudFront Functions

Parameters:
  ProjectName:
    Description: Project Name
    Type: String
    Default: unnamed
  Environment:
    Description: Environment
    Type: String
    Default: dev
    AllowedValues:
      - prod
      - dev
  S3BucketName:
    Description: Bucket name for static site
    Type: String
  AliasName:
    Description: Alias for CloudFront
    Type: String
  HostedZoneId:
    Description: Route53 Host Zone ID
    Type: String
  CertificateId:
    Description: ACM Certificate ID must be us-east-1 region
    Type: String

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Common Settings
        Parameters:
          - ProjectName
          - Environment
      - Label:
          default: SSL Settings
        Parameters:
          - AliasName
          - HostedZoneId
          - CertificateId

Resources:
  # ------------------------------------------------------------------------------------ #
  # S3
  # ------------------------------------------------------------------------------------ #
  # Static site bucket
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}
      OwnershipControls:
        Rules:
          - ObjectOwnership: "BucketOwnerEnforced"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
            BucketKeyEnabled: false
      LifecycleConfiguration:
        Rules:
          - Id: AbortIncompleteMultipartUpload
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: "Enabled"
          - Id: NoncurrentVersionExpiration
            NoncurrentVersionExpiration:
              NewerNoncurrentVersions: 3
              NoncurrentDays: 1
            Status: Enabled
  # Bucket Policy for CloudFront
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}

  # ------------------------------------------------------------------------------------ #
  # CloudFront
  # ------------------------------------------------------------------------------------ #
  CloudFrontDistribution:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        PriceClass: PriceClass_All
        Origins:
          - DomainName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}.s3.${AWS::Region}.amazonaws.com
            Id: !Sub "S3origin-${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}"
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: !Sub "S3origin-${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}"
          Compress: true
          ViewerProtocolPolicy: redirect-to-https
          # CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          ForwardedValues:
            Cookies:
              Forward: none
            QueryString: false
          # CloudFront Function Association
          FunctionAssociations:
            - EventType: viewer-request
              FunctionARN: !GetAtt CloudFrontFunction.FunctionMetadata.FunctionARN

        Logging:
          Bucket: !GetAtt S3BucketLogs.DomainName
          IncludeCookies: false
        Aliases:
          - !Ref AliasName
        ViewerCertificate:
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
          AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}"
        HttpVersion: http2
        IPV6Enabled: true
        Enabled: true
  # OAI
  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: Unique Domain Hosting Environment
  # CloudFront log bucket
  S3BucketLogs:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-cloudfrontlogs-${AWS::AccountId}
      OwnershipControls:
        Rules:
          - ObjectOwnership: "ObjectWriter"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
            BucketKeyEnabled: false
      LifecycleConfiguration:
        Rules:
          - Id: AbortIncompleteMultipartUpload
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: "Enabled"
          - Id: CurrentVersionExpiration
            ExpirationInDays: 180
            Status: "Enabled"
          - Id: NoncurrentVersionExpiration
            NoncurrentVersionExpiration:
              NoncurrentDays: 30
            Status: "Enabled"
  # Alias Record
  Route53RecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: !Sub ${AliasName}
      HostedZoneId: !Sub ${HostedZoneId}
      Type: A
      AliasTarget:
        DNSName: !GetAtt CloudFrontDistribution.DomainName
        HostedZoneId: Z2FDTNDATAQYW2 # fixed

  # ------------------------------------------------------------------------------------ #
  # CloudFront Functions
  # ------------------------------------------------------------------------------------ #
  CloudFrontFunction:
    Type: "AWS::CloudFront::Function"
    Properties:
      Name: "add-index-function"
      FunctionCode: |
        function handler(event) {
            var request = event.request;
            var uri = request.uri;

            // Check whether the URI is missing a file name.
            if (uri.endsWith('/')) {
                request.uri += 'index.html';
            }
            // Check whether the URI is missing a file extension.
            else if (!uri.includes('.')) {
                request.uri += '/index.html';
            }

            return request;
        }
      FunctionConfig:
        Comment: "Add index.html to the path"
        Runtime: "cloudfront-js-1.0"
      AutoPublish: true

パラメータに変更はないため最初に作成した rain コマンドを再入力して更新をかけました。

$ rain deploy ./cloudformation/cloudfront-s3.yaml hugo-cloudfront-s3-stack --params ProjectName=hugo,Environment=dev,S3BucketName=hugo-static-web-site,AliasName=hugo.ohmura.classmethod.info,HostedZoneId=Z04083533GATUFF6GHTWT,CertificateId=d6627991-XXXX-4f69-XXXX-ba004cXXXXXX

CloudFunctions がデプロイされると、投稿した記事も表示できるようになりました。

CI/CD を体感

2回目のテスト投稿を通して全体の動きをみてみましょう。

ブランチを切って記事を執筆します。

$ git checkout -b good-afternaoon

ブログ用のディレクトリでコンテナを起動し hugo コマンドで新規投稿記事の雛形を作成します。

$ cd sample-blog
$ docker run --rm -it -v $(pwd):/src klakegg/hugo:0.101.0-ext-alpine shell
$ hugo new posts/good-afternoon.md
$ exit

エディタで投稿記事を書きます。初回は「こんにちは」でしたの「こんばんは」とだけ記した記事を作成しました。

sample-blog/content/posts/good-afternoon.md

---
title: "こんばんは"
date: 2022-07-23T15:20:58Z
draft: false
---
## テスト投稿2
こんばんは

ローカルサーバで投稿記事を確認します。

$ docker run --rm -it -v $(pwd):/src -p 1313:1313 klakegg/hugo:0.101.0 server

表示に問題ありませんでしたのでコミットしてプッシュします。

$ git add .
$ git commit -m "テスト投稿2本目"
$ git push origin good-afternoon

GitHub を確認して、main ブランチにマージしました。

GitHub Actions で設定したワークフローが動きだしました。

30秒もしないで終わりました。Hugo で静的ファイル生成してから S3 へファイル転送しているのですけど早いですね。

独自ドメインの CloudFront へアクセスすると2本目のテスト投稿記事を TOP ページから確認できました。

今回は新規投稿記事をすぐに確認できるよう CloudFront のキャッシュ設定を無効化していました。ブログサイトとして運用するときはキャッシュを有効化するとよいでしょう。

cloudfront-s3.yaml

          # CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled

テスト投稿2を開いてもちゃんと見ることができました。

ローカルでブログを書いて GitHub にプッシュすれば自動的に Web 公開できる環境ができました。

さらに勉強したいときは

S3 バケットのアクセスログを取得設定を追加したり、CloudFront のアクセスログを Athena で検索してみたり、CloudFront に AWS WAF(ランニングコストに注意)をつけてみたりするとより AWS を堪能できるかと思います。Hugo のテーマをとっかえひっかえしているときの方が楽しい気はするので Hugo で調べてみてください。

おわりに

Hugo + GitHub Actions + CloudFront + S3 の組み合わせは見かけますが、求めている構成のものが見当たらなかったので書きました。想定よりずいぶんと長くなりまして、CloudFront + S3 の構築から Hugo の設定まで通して書いている記事が少ない理由がわかった気がします。

昔、個人サイトを Hugo で作ったときは AWS 力が低くて CloudFront + S3 構成に CI/CD まで作れませんでした。なので Netlify を使って GitHub からのお手軽 CI/CD 構成にしています。

参考