1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポートフォリオサイトをCloudFront+S3で構築 & NFCカードでデジタル名刺を作成

1
Posted at

はじめに

こんにちは、KokoHakotaroです。
私はよくAWSのエンジニアコミュニティイベントに参加しているのですが、そこでよく、このような方と出会います。

「このカードから自己紹介ページに飛ぶので、スマホをかざしてみてください」

そう、NFCカードを使ったデジタルプロフィールです。

コミュニティによく参加される方の中には、自身の経歴であったり、過去のブログ一覧などを紹介しやすいようにプロフィールサイトを作っている人がよくいます。
デジタルプロフィールを持っていると、口頭で細かく話さなくても簡単に相手に自分のことを伝えることができるので、大勢の方とお話するコミュニティイベントではうってつけです。
これまでそういった方々にお会いするたび、私は「かっこいいし羨ましいなぁ!!」と思っていました。

…………そう思うなら作ってしまえばいいのでは?

ということで、私も自分のプロフィールサイトをAWSで作ってみることにしました。

作成したプロフィールサイトはこちら → https://www.andy.hktaro.com

今回のブログでは、どのような手法、構築でプロフィールサイトを制作してみたのかを、簡単にまとめていこうと思います。

構成解説

myportfolio.drawio.png

システム全体の構成としてはこのようなものとなっています。

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円程度に収まります。

静的ホスティング構成はサーバーレスのため、アクセス数が増えない限りコストがほぼ変動しない点も魅力です。

構築の流れ

開発の流れとしては、次のように進めていきました。

  1. CloudFront + S3 の静的Webホスティング基盤構築
  2. Webサイトのページ作成
  3. GithubActionsワークフローの作成、デプロイ、表示確認
  4. ドメイン取得、ホストゾーン作成
  5. 証明書取得、Route53 + CloudFrontの連携

その1. Webサイト構築

S3の作成

まずはプロフィールサイト用の基盤となるS3を作成していきます。

variable.tf
variable "s3" {
  description = "S3 module configuration"
  type        = map(string)
  default = {
    "bucket_name" = "unique-bucketname"
  }
}

モジュールに入力する変数にはmap変数を使い、モジュール単位で変数の集合を管理できるようにしています。
こうすることによって、モジュールごとに個別の変数指定をしなくてもよくなるため、変数管理の負担を軽減させることができます。

modules/s3/main.tf
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、エラーページのパスなどのパラメータをまとめて管理しています。

modules/cloudfront/main.tf
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ロールの作成

playbook.yml
---
- 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
roles/portfolio_deploy/tasks/main.yml
---
# 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

terraform_plan.yml
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

terraform_apply.yml
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

ansible_dryrun.yml
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

ansible_run.yml
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で作成する必要があります。
東京リージョンで証明書を作成したとしても使えないので気をつけましょう!
(私はこれをやって一度証明書を作り直しました...)

prd/main.tf
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"]
  }
}
modules/acm/main.tf
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レコードを追加

prd/main.tf
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]
}
modules/route53/main.tf
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にドメインと証明書を設定

prd/main.tf
module "cloudfront" {
  ⋮
  custom_domain_config = {
    "domain_name"         = var.common["domain_name"]
    "acm_certificate_arn" = module.acm.certificate_arn
  }
   ⋮
}
module/cloudfront/main.tf
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レコードを書き込んでしまえば、あっという間にかざすだけで共有できるデジタルプロフィールカードが完成!!

カードデザインなどは、手軽なものならステッカーをコンビニ印刷で作成して貼り付けるだけでもいいので、かなりお手軽です。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?