はじめに
こんにちは、KokoHakotaroです。
私はよくAWSのエンジニアコミュニティイベントに参加しているのですが、そこでよく、このような方と出会います。
「このカードから自己紹介ページに飛ぶので、スマホをかざしてみてください」
そう、NFCカードを使ったデジタルプロフィールです。
コミュニティによく参加される方の中には、自身の経歴であったり、過去のブログ一覧などを紹介しやすいようにプロフィールサイトを作っている人がよくいます。
デジタルプロフィールを持っていると、口頭で細かく話さなくても簡単に相手に自分のことを伝えることができるので、大勢の方とお話するコミュニティイベントではうってつけです。
これまでそういった方々にお会いするたび、私は「かっこいいし羨ましいなぁ!!」と思っていました。
…………そう思うなら作ってしまえばいいのでは?
ということで、私も自分のプロフィールサイトをAWSで作ってみることにしました。
作成したプロフィールサイトはこちら → https://www.andy.hktaro.com
今回のブログでは、どのような手法、構築でプロフィールサイトを制作してみたのかを、簡単にまとめていこうと思います。
構成解説
システム全体の構成としてはこのようなものとなっています。
Webシステム
プロフィールサイトとして重要なWebサイト機能には、S3とCloudFrontを使用した静的ホスティングを採用しています。
今回作るサイトは、あくまでも過去の経歴や自己紹介などを掲載する程度で考えています。
その場合、EC2のようなリソースは過剰となるため、S3とCloudFrontを使うことで運用コストを極限まで抑えてみました。
ドメイン運用
また、独自のドメインでサイトにアクセスするために、Route53とACMを使っています。
Webサイトの公開だけれあれば、S3単体やCloudFrontだけでも十分ではありますが、その場合、システムを構築し直した場合などで、サイトにアクセスするためのリンクが変わってしまうおそれがあります。
せっかくプロフィールを他者と共有するのであれば、固定リンクで設定したほうがいいと感じたため、今回はドメインを設定してみました。
ちなみに余談ですが、私は今回、この構築を実現するために初めて自分のドメインを取得してみました。
祝!!初ドメイン!!!
ドメインは1つもっているだけで様々な場所で応用ができるものです。
ドメイン運用には一定のコストはかかってしまいますが、持っていて損は無いと思っています。
GithubActions/IaC
インフラの構築やWebページのデプロイには、TerraformとAnsibleを使っています。
今回の構築はシンプルであるため、手作業での構築も十分可能ではあるものの、IaC(Infrastructure as Code)を使うことで構築や修正はもちろん、不要になった際の削除も簡単なため、インフラ管理は格段に楽になります。
また、IaCの実行環境として、GithubActionsでワークフローを定義しました。
GithubActionsを使用することで、Github上でIaCのコード管理とデプロイを合わせて行うことが可能になり、これもまたシステム管理を楽にすることができます。
運用コスト
AWS Cost Explorerで試算したところ、月額約**$0.50〜0.62**(年間約$6.0〜7.5、≒900〜1,100円)となりました。
| サービス | 内容 | 月額目安 |
|---|---|---|
| Route 53 | ホストゾーン維持費 | $0.50 |
| CloudFront | 転送量・リクエスト | $0.10未満 |
| S3 | ストレージ+リクエスト | $0.01未満 |
| ACM | SSL証明書 | 無料 |
| 合計 | 約$0.50〜0.62 |
コストの大半はRoute 53のホストゾーン維持費($0.50/月 固定)で、CloudFrontやS3は個人サイト規模ではほぼ無視できる額です。
ドメイン自体の取得・維持費(お名前.com)は別途かかりますが、それを含めても年間2,000〜3,000円程度に収まります。
静的ホスティング構成はサーバーレスのため、アクセス数が増えない限りコストがほぼ変動しない点も魅力です。
構築の流れ
開発の流れとしては、次のように進めていきました。
- CloudFront + S3 の静的Webホスティング基盤構築
- Webサイトのページ作成
- GithubActionsワークフローの作成、デプロイ、表示確認
- ドメイン取得、ホストゾーン作成
- 証明書取得、Route53 + CloudFrontの連携
その1. Webサイト構築
S3の作成
まずはプロフィールサイト用の基盤となるS3を作成していきます。
variable "s3" {
description = "S3 module configuration"
type = map(string)
default = {
"bucket_name" = "unique-bucketname"
}
}
モジュールに入力する変数にはmap変数を使い、モジュール単位で変数の集合を管理できるようにしています。
こうすることによって、モジュールごとに個別の変数指定をしなくてもよくなるため、変数管理の負担を軽減させることができます。
resource "aws_s3_bucket" "portfolio" {
bucket = var.s3_config["bucket_name"]
}
resource "aws_s3_bucket_website_configuration" "portfolio" {
bucket = aws_s3_bucket.portfolio.id
index_document {
suffix = "index.html"
}
error_document {
key = "404.html"
}
}
resource "aws_s3_bucket_public_access_block" "portfolio" {
bucket = aws_s3_bucket.portfolio.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_policy" "portfolio" {
bucket = aws_s3_bucket.portfolio.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.portfolio.arn}/*"
}
]
})
depends_on = [aws_s3_bucket_public_access_block.portfolio]
}
CloudFrontの作成とS3との連携
CloudFront用の変数もS3と同様にmap変数で定義し、price_classやTTL、エラーページのパスなどのパラメータをまとめて管理しています。
resource "aws_cloudfront_origin_access_identity" "portfolio" {
comment = "Origin Access Identity for Portfolio Site"
}
resource "aws_cloudfront_distribution" "portfolio" {
enabled = true
default_root_object = var.cloudfront_config["default_root_object"]
price_class = var.cloudfront_config["price_class"]
aliases = length(var.custom_domain_config) > 0 ? [var.custom_domain_config["domain_name"]] : []
origin {
domain_name = var.s3_bucket_domain_name
origin_id = "S3-${var.s3_bucket_id}"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.portfolio.cloudfront_access_identity_path
}
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${var.s3_bucket_id}"
viewer_protocol_policy = "redirect-to-https"
⋮
min_ttl = var.cloudfront_config["min_ttl"]
default_ttl = var.cloudfront_config["default_ttl"]
max_ttl = var.cloudfront_config["max_ttl"]
}
custom_error_response {
error_code = var.cloudfront_config["error_response_code"]
response_code = var.cloudfront_config["error_response_code"]
response_page_path = var.cloudfront_config["error_page_path"]
}
⋮
# viewer_certificateはドメイン設定時に追加(後述)
}
その2. Webページ作成
Webページ用のHTMLファイルとCSSファイルの作成
プロフィールサイトはHTMLとCSSを使ったシンプルなWeb構成で作成。
ページの中には、自己紹介や登壇記録、執筆ブログの他、保有する資格情報も記載しています。
静的ホスティングのため、掲載する内容はすべてHTMLで定義しないといけないのが難点ですが、今回は低コストかつ単純な構成を目的としているので、都度手動更新する構成で妥協しています。
LambdaやDB、APIを活用することで、HTML定義を細かく定義しなくてもいい構成を実現できそうです。
手動での記載が面倒であれば、管理用のページを作成してみるものよさそうです。
S3にWebページのファイルをデプロイするAnsibleロールの作成
---
- name: Deploy portfolio site to S3 and CloudFront
hosts: localhost
gather_facts: false
vars:
resource_tags:
ansible_tag: "portfolio-site"
project_tag: "Production"
bucket_name_filter: "portfolio-site"
roles:
- portfolio_deploy
---
# Find S3 bucket and CloudFront distribution by tags
- name: Get all S3 buckets
amazon.aws.s3_bucket_info:
register: all_s3_buckets
- name: Get tags for each S3 bucket
command: aws s3api get-bucket-tagging --bucket "{{ item.name }}" --output json
loop: "{{ all_s3_buckets.buckets }}"
register: s3_tags_raw
changed_when: false
check_mode: false
failed_when: false
when: bucket_name_filter in item.name
- name: Find portfolio S3 bucket by tags
set_fact:
s3_bucket_name: "{{ all_s3_buckets.buckets[index].name }}"
loop: "{{ s3_tags_raw.results | default([]) }}"
loop_control:
index_var: index
vars:
tags_dict: "{{ (item.stdout | from_json).TagSet | items2dict(key_name='Key', value_name='Value') if item.stdout else {} }}"
when:
- not item.skipped | default(false)
- item.rc == 0
- tags_dict.Ansible is defined
- tags_dict.Ansible == resource_tags.ansible_tag
- name: Get all CloudFront distributions using AWS CLI
command: aws cloudfront list-distributions --query 'DistributionList.Items[*].[Id,ARN]' --output json
register: cf_distributions_raw
changed_when: false
check_mode: false
- name: Parse CloudFront distributions
set_fact:
cf_distributions: "{{ cf_distributions_raw.stdout | from_json if cf_distributions_raw.stdout else [] }}"
- name: Get tags for each CloudFront distribution
command: aws cloudfront list-tags-for-resource --resource "{{ item[1] }}" --output json
loop: "{{ cf_distributions }}"
register: cf_tags_raw
changed_when: false
check_mode: false
when: cf_distributions | length > 0
- name: Find portfolio CloudFront distribution by tags
set_fact:
cloudfront_distribution_id: "{{ cf_distributions[index][0] }}"
loop: "{{ cf_tags_raw.results | default([]) }}"
loop_control:
index_var: index
vars:
tags_dict: "{{ (item.stdout | from_json).Tags.Items | items2dict(key_name='Key', value_name='Value') }}"
when:
- not item.skipped | default(false)
- tags_dict.Ansible is defined
- tags_dict.Ansible == resource_tags.ansible_tag
- tags_dict.Project is defined
- tags_dict.Project == resource_tags.project_tag
- name: Display found resources
debug:
msg:
- "S3 Bucket: {{ s3_bucket_name | default('Not found') }}"
- "CloudFront Distribution: {{ cloudfront_distribution_id | default('Not found') }}"
- name: Fail if S3 bucket not found
fail:
msg: "Could not find S3 bucket with tags Ansible={{ resource_tags.ansible_tag }} and name containing '{{ bucket_name_filter }}'"
when: s3_bucket_name is not defined
# Task 1: Sync HTML files to S3
- name: Sync HTML files to S3
command: >
aws s3 sync {{ role_path }}/files/ s3://{{ s3_bucket_name }}/
--delete
--exclude "*"
--include "*.html"
--content-type "text/html"
register: sync_html
changed_when: "'upload:' in sync_html.stdout or 'delete:' in sync_html.stdout"
# Task 2: Sync CSS files to S3
- name: Sync CSS files to S3
command: >
aws s3 sync {{ role_path }}/files/ s3://{{ s3_bucket_name }}/
--delete
--exclude "*"
--include "*.css"
--content-type "text/css"
register: sync_css
changed_when: "'upload:' in sync_css.stdout or 'delete:' in sync_css.stdout"
# Task 3: Sync SVG images to S3
- name: Sync SVG images to S3
command: >
aws s3 sync {{ role_path }}/files/images/ s3://{{ s3_bucket_name }}/images/
--delete
--exclude "*"
--include "*.svg"
--content-type "image/svg+xml"
register: sync_svg
changed_when: "'upload:' in sync_svg.stdout or 'delete:' in sync_svg.stdout"
# Task 4: Sync PNG/JPG images to S3
- name: Sync PNG/JPG images to S3
command: >
aws s3 sync {{ role_path }}/files/images/ s3://{{ s3_bucket_name }}/images/
--exclude "*"
--include "*.png"
--include "*.jpg"
--include "*.jpeg"
register: sync_images
changed_when: "'upload:' in sync_images.stdout or 'delete:' in sync_images.stdout"
# Task 5: Invalidate CloudFront cache
- name: Invalidate CloudFront cache
community.aws.cloudfront_invalidation:
distribution_id: "{{ cloudfront_distribution_id }}"
target_paths:
- "/*"
when: cloudfront_distribution_id is defined and cloudfront_distribution_id != ""
S3へのデプロイには、aws s3 syncコマンドを使いました。
GithubActionsでは、IAMロールを用いた認証情報を参照することで、awscliコマンドをローカル実行させることが可能です。
今回の場合だと、リポジトリ内にあるWebページ用のフォルダをs3にアップロードするように、Ansibleの実行タスクを定義してあります。
その3. GithubActionsワークフローの実装とデプロイ実行
Terraformワークフロー
Terraformのワークフローは、構築テストを実施するplanと実際にAWSクラウドにデプロイを実行するapplyの2種類のワークフローがあります。
planは開発ブランチからのPRがあった際に自動実行し、マージされるとapplyのワークフローを自動実行するように定義することで、CI/CDの自動デプロイを実現しています。
Terraform Plan
name: 1. Terraform Plan
on:
push:
branches: [develop]
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
jobs:
terraform-plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
working-directory: terraform/environments/prd
- name: Terraform Plan
run: terraform plan -var="created_at=$(date +%Y-%m-%d)"
working-directory: terraform/environments/prd
Terraform apply
name: 2. Terraform Apply
on:
push:
branches: [production]
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
jobs:
terraform-apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
working-directory: terraform/environments/prd
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: terraform/environments/prd
Ansibleワークフロー
AnsibleワークフローもTerraformと同様に、構築テストを実施するDry Runとデプロイを実行するRunの2種類のワークフローがあります。
Terraformワークフローと異なる点として、こちらは双方ともGithub上からの手動実行を行っています。
これは、自動実行にしてしまうと、インフラの新規構築時にTerraformと同時にAnsibleも動いてしまうため、インフラ構築前にAnsibleが走ってしまうことを始めとした実行タイミングの問題があったからです。
これについては、インフラ管理とアプリ管理のリポジトリを同じ場所にしているからとも言えます。
別リポジトリで管理を行うことで、ワークフローの実行タイミングをそれぞれの開発に合わせて行えるので、問題は解決するかも?
Ansible Dryrun
name: 3. Ansible Dry Run
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
ansible-check:
runs-on: ubuntu-latest
environment: prd
timeout-minutes: 15
defaults:
run:
working-directory: ansible
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Install Ansible
run: |
python3 -m pip install --upgrade pip
pip3 install ansible boto3 botocore
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install community.aws
- name: Ansible Check
run: |
echo "Running Ansible check for prd environment..."
ansible-playbook playbook.yml -i hosts/aws_ec2.yml -D --syntax-check
ansible-playbook playbook.yml -i hosts/aws_ec2.yml -D --check --diff -v
env:
AWS_DEFAULT_REGION: ap-northeast-1
Ansible Run
name: 4. Ansible Run
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
ansible-deploy:
runs-on: ubuntu-latest
environment: prd
timeout-minutes: 15
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Ansible
run: |
pip install ansible boto3 botocore
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install community.aws
- name: Ansible Deploy
run: |
echo "Running Ansible deploy for prd environment..."
ansible-playbook -i hosts/aws_ec2.yml playbook.yml -v
env:
AWS_DEFAULT_REGION: ap-northeast-1
これらを実行することで、CloudFront + S3の静的ホスティングWebサイトが完成です。
特にドメイン設定が不要という場合は、ここまでで十分です。
ここからは、オプション的立ち位置となるドメイン設定の構築に入ります。
その4. ドメイン取得とホストゾーン作成
ドメインの取得
ドメイン取得の方法としては、Route53で取得する方法と、外部のドメイン登録サイトで取得する方法の2通りがあります。
Route53で取得したほうがAWS上でインフラリソースをすべて管理することができるのですが、調べたところ、この方法で取得したドメインの管理料金は割高になりそう。
そのため今回は、「お名前.com」というサイトでドメインを取得しました。
ホストゾーンの作成
お名前.comで取得したドメインを使用できるようにするためには、ドメインとレコードを紐づけるためのネームサーバーをお名前.com上で登録する必要があります。
そのため、Route53上で取得したドメインのパブリックホストゾーンを作成し、NSレコードをお名前.comに登録しました。
ホストゾーンについては、プロフィールサイト以外でも同じドメインを使う可能性があるため、Terraformではなく手動で作成しました。
その5. ドメインの設定
証明書の取得
Route53でドメインの接続をCloudFrontに向ける際、HTTPS化をするためにはSSL証明書が必要なため、作成を行います。
注意点として、CloudFrontで扱うACM証明書はus-east-1で作成する必要があります。
東京リージョンで証明書を作成したとしても使えないので気をつけましょう!
(私はこれをやって一度証明書を作り直しました...)
module "acm" {
source = "../../modules/acm"
providers = {
aws.us_east_1 = aws.us_east_1
}
acm_config = {
"domain_name" = var.common["domain_name"]
"hosted_zone_id" = var.common["hosted_zone_id"]
}
}
resource "aws_acm_certificate" "portfolio" {
provider = aws.us_east_1
domain_name = var.acm_config["domain_name"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = {
Name = "Portfolio Site Certificate"
}
}
# 証明書のDNS検証レコードをRoute53に自動追加
resource "aws_route53_record" "cert_validation" {
for_each = { for dvo in aws_acm_certificate.portfolio.domain_validation_options : dvo.domain_name => { ⋮ } }
⋮
}
resource "aws_acm_certificate_validation" "portfolio" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.portfolio.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
Route53 + CloudFrontの連携
パブリックホストゾーンへAレコードを追加
module "route53" {
source = "../../modules/route53"
route53_config = {
"hosted_zone_id" = var.common["hosted_zone_id"]
"domain_name" = var.common["domain_name"]
"cloudfront_domain_name" = module.cloudfront.distribution_domain_name
"cloudfront_hosted_zone_id" = module.cloudfront.distribution_hosted_zone_id
}
depends_on = [module.cloudfront]
}
resource "aws_route53_record" "portfolio" {
zone_id = var.route53_config["hosted_zone_id"]
name = var.route53_config["domain_name"]
type = "A"
alias {
name = var.route53_config["cloudfront_domain_name"]
zone_id = var.route53_config["cloudfront_hosted_zone_id"]
evaluate_target_health = false
}
}
CloudFrontにドメインと証明書を設定
module "cloudfront" {
⋮
custom_domain_config = {
"domain_name" = var.common["domain_name"]
"acm_certificate_arn" = module.acm.certificate_arn
}
⋮
}
resource "aws_cloudfront_distribution" "portfolio" {
⋮
aliases = length(var.custom_domain_config) > 0 ? [var.custom_domain_config["domain_name"]] : []
⋮
viewer_certificate {
cloudfront_default_certificate = length(var.custom_domain_config) == 0 ? true : null
acm_certificate_arn = length(var.custom_domain_config) > 0 ? var.custom_domain_config["acm_certificate_arn"] : null
ssl_support_method = length(var.custom_domain_config) > 0 ? "sni-only" : null
minimum_protocol_version = length(var.custom_domain_config) > 0 ? "TLSv1.2_2021" : null
}
まとめ
今回は、プロフィールサイトを静的ホスティングで構築した手法について、ズラッと記載をしてみました。
実際に作ってみると、シンプルな構成ながらも、手軽に独自のサイトを公開することができる上、用途に応じてさらなる拡張も見込めるため、とても使い勝手のいいシステム構成だと感じました。
また、IaCを使って構築をすることで、複数のリソースを扱うシステムも簡単に管理することができるため、手軽に作成を試してみることもできるのは大きな利点です。
今回作ったプロフィールサイトは、今後参加する様々なコミュニティイベントでのエンジニア交流で役立てていこうと思います。
余談
インフラ構築とはあまり関係ないところではありますが、そもそものサイト開発のきっかけであったNFCカード作成も合わせてやってみました。
使うのはこのICチップ内蔵型のNFCカード。
調べてみると、Amazonなどで非常に安く購入することができます。
また、カードへの書き込みについては、NFC Toolsアプリというものでスマホから簡単に行うことができます。
これを使い、カードにプロフィールサイトのURLレコードを書き込んでしまえば、あっという間にかざすだけで共有できるデジタルプロフィールカードが完成!!
カードデザインなどは、手軽なものならステッカーをコンビニ印刷で作成して貼り付けるだけでもいいので、かなりお手軽です。
