Hugo を使った CloudFront + S3 のブログサイトを構築してみた 〜 GitHub Actions で CI/CD 付き
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 を追加することになるのですが当初デプロイしたコードを載せておきます。最初から完成形のコードを使いたい方は後半に改めてコードを載せておりますのでそちらをご利用ください。
折りたたみ
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
AliasName
やHostedZoneId
など環境依存のパラメータはご自身の環境に合わせて変更してください。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 アカウントであればスキップしてください。初回の場合は規定の設定を作成しましょう。
規定の設定を作成します。
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 ロール)を作成します。
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
が追加(新規作成)されます。
[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
デフォルトのテンプレートから生成された素っ気ないファイルを編集しました。
--- title: "こんにちは" date: 2022-07-23T03:58:27Z draft: false --- ## テスト投稿 こんにちは
サーバー起動
改めて Hugo コンテナを起動して新規投稿記事のプレビューを確認します。
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
ワークフローの解説
環境依存の変更箇所とポイントを簡単に説明します。
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
ワークフロー実行のトリガー
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 へ紐付けるコードを追加しました。
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
エディタで投稿記事を書きます。初回は「こんにちは」でしたの「こんばんは」とだけ記した記事を作成しました。
--- 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 のキャッシュ設定を無効化していました。ブログサイトとして運用するときはキャッシュを有効化するとよいでしょう。
# 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 で調べてみてください。
- サーバーアクセスログを使用したリクエストのログ記録 - Amazon Simple Storage Service
- Amazon Athena で CloudFront のアクセスログを集計する | DevelopersIO
- AWS WAFをCloudFrontに導入する | DevelopersIO
おわりに
Hugo + GitHub Actions + CloudFront + S3 の組み合わせは見かけますが、求めている構成のものが見当たらなかったので書きました。想定よりずいぶんと長くなりまして、CloudFront + S3 の構築から Hugo の設定まで通して書いている記事が少ない理由がわかった気がします。
昔、個人サイトを Hugo で作ったときは AWS 力が低くて CloudFront + S3 構成に CI/CD まで作れませんでした。なので Netlify を使って GitHub からのお手軽 CI/CD 構成にしています。