search
LoginSignup
0
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

bengo4弁護士ドットコム Advent Calendar 2020 Day 12

posted at

updated at

Organization

AWS CLI だけで Hugo のホスティング環境を構築してみる ー はじめよう、技術ブログ

こんにちは。
弁護士ドットコム株式会社にてバックエンドエンジニアとして 電子契約サービス クラウドサイン の開発をしている @michimani です。今回は、個人的に最近よく触っている AWS CLI について書きます。

はじめに

この記事は 弁護士ドットコム Advent Calendar 2020 の 12 日目の記事です。

昨日の記事は @happylifetaka さんの 「VueUseについて調べてみました」 でした。ぜひこちらの記事もご覧ください。

私の記事のメインは AWS CLI を使ったハンズオン資料になっているので、休日や年末年始のお休みの際にぽちぽちやっていただけたら幸いです。

目次

やること

今回は、AWS CLI のみを使って Hugo のホスティング環境を構築してみます。構築していく過程で様々なサービスを AWS CLI を通して操作していきます。また、サブタイトルに 「はじめよう、技術ブログ」 と付けている通り、ホスティング環境を作ったらそのまま Hugo でブログを公開してアウトプットしていきませんか?というお誘いです。

年末のこの時期になると、「来年こそは自分もアウトプットしないと...」 と思う方が少なからずいらっしゃると思います。この記事を見て、環境を構築してみて、 Hugo でブログを始めてみるのはいかがでしょうか。(というのを Qiita で書いている)

また、AWS CLI は AWS で各サービス・リソースを扱う上でとても基本的な部分に触れることができるツールなので、既に AWS を使ってサービス運用しているという方はもちろん、これから AWS を使って何かやろうとしている方にもぜひ使っていただきたいツールです。個人的にも最近は AWS CLI で何かやる機会が増えてきたので、今回取り上げています。

このあと前置きがちょっと長くなっているので、すぐに環境構築に入りたい!という方は ここ まで飛んでください。

長い前置き

実際に AWS CLI で Hugo のホスティング環境を構築するまでに、前置きとして Hugo や AWS CLI について少しだけ書いておきます。また、この記事では AWS CLI との比較として様々なサービスやツールについて触れていますが、それらを悪者にしようという意図はまったくありませんし、 AWS CLI が至高!という感じでもありません。

Hugo とは

Hugo とは Go で実装されているオープンソースの静的サイトジェネレーターです。特徴としては、とにかくビルドがめちゃくちゃ速いです。

ビルドするサイトに適用するテーマの種類も豊富で、一般的なブログ、個人のポートフォリオ、コーポレートサイトなど、様々なタイプの Web サイトを構築することができます。

The world’s fastest framework for building websites | Hugo

なぜ AWS CLI?

Hugo に限らず、 AWS 上で何かしらの環境構築をするには、 AWS CLI 以外に下記のような方法があります。

  • マネジメントコンソールでぽちぽち
  • AWS CloudFormation を使って構成管理
  • AWS CDK, AWS Chalice などの AWS 製ツール
  • Terraform, Serverless Framework などの 3rd パーティ製ツール

上記については、 マネジメントコンソールでぽちぽちそれ以外 に大別できます。なので、その 2 つと AWS CLI との比較を簡単に書いておきます。

マネジメントコンソールでぽちぽち

AWS を触り始めると、まずはマネジメントコンソールにアクセスして色んなリソースを触ってみると思います。世に出ている手順書やブログ記事も、マネジメントコンソールのキャプチャを用いて説明されているものも多いです。

初めて AWS に触れる際にはマネジメントコンソールを見ながら直感的に操作していくのはとてもわかりやすいと思いますが、 「あれ?このキャプチャ、実際の画面と違うんやけど...」 「そんなボタン無いやん...」 「どうすればええんや...」 という感じで キャプチャと実際の画面の差異に戸惑ったことはないでしょうか? というのも、 AWS のマネジメントコンソールの UI は日々変更が加わっていて、昨日なかった項目が今日追加されていることもあれば、逆に昨日あったボタンが今日なくなっているなんてこともあります。マネジメントコンソールでの操作はわかりやすい分、UI の変更に対応するのが大変という面があります。

それ以外

AWS CloudFormation (以下、CFn) や AWS CDK、 Terraform などを使った構成管理は、リソースのあるべき状態をコードで管理することができます。もちろん、リソースを操作する UI が変わっても影響はありません。その他、 Infrastructure as Code を実践することで得られるメリットについてはもう既に色んな場所で書かれていることなので詳しくは触れません。

これらのツールやサービスを利用するにあたって障壁となるのは、初期の学習コストです。 CFn で管理する場合はテンプレートの書き方を知る必要があります。 AWS CDK は各言語 (TypeScript が多いと思います) でリソースを定義できると言え、 CDK での書き方や、リソースによっては CFn での書き方を調べる必要が出てきます。Terraform も、 .tf ファイルの書き方を知る必要があります。 IaC するためにずっと同じツールやサービスを使い続けるのであれば問題ないですが、実際はプロジェクトやチーム、個人の趣味で色んなツールを使うことになると思うので、そのたびに学習コストが発生するのは辛いです。また、 3rd パーティのツールを使う場合は、そのツール自体のバージョンアップにも追従していく必要があります。特に AWS CDK に関しては GA となってからもアップデートスピードは非常に速く、破壊的変更が加えられることもあります。リソースの定義や管理がしやすい反面、実際のリソースに加えてツールのメンテナンスもしてくのは辛いポイントです。

AWS CLI

じゃあ AWS CLI は上記のような辛さは無いのか!と言われると、決してそんなことはありません。コマンドを毎回調べてリソースを一つずつ作成していくのは辛いと感じるときもあります。ただ、そんな辛さ以上に AWS CLI を触ることによって得られるメリットは大きいと思います。具体的には、次のような特徴・メリットがあります。

  • サブコマンドが各サービスの API と (ほぼ) 1 対 1 で対応している
    • aws <サービスを指定するコマンド> <API を指定するサブコマンド> の形
    • 各リソースを操作するのにどんな API を実行すればよいかがわかる
    • コマンドで確認できるヘルプが充実している
  • 新サービス・新機能への追従が早い
    • API と (ほぼ) 対になっているので、新しいサービスや機能も AWS CLI ならすぐ試すことができる
    • v1 と v2 があるが、 v1 のほうが先に対応することが多い
  • 長年大きな変化がないシェルの技術が元になっている
    • シェル芸 (やりすぎ注意)

特に一つ目の特徴として上げている サブコマンドが各サービスの API と (ほぼ) 1 対 1 で対応している に関しては、 AWS を理解するためには凄く良い点です。 API は頻繁に変わるものではないので、過去に覚えた API (コマンド) をほぼずっと使っていくことができます。また、 API を理解することで CFn 等でリソースを定義する際に、実際に作成されるリソースのイメージもつきやすくなります。

ということで、今後の AWS との付き合いをより良いものにするために AWS CLI は凄く良いツールですよ、という話です。AWS CLI の良さについては色んな方がアウトプットされていますので、そちらもご覧ください。

また、個人的には最近 JAWS-UG CLI 専門支部のイベントに参加することが多いです。内容としては、毎回各サービスのとある機能を AWS CLI で操作するというハンズオンになっています。今年に入ってからはオンラインで開催されているので、 AWS CLI に興味のある方はチェックしてみてはいかがでしょうか。

やってみる

前置きがだいぶ長くなりましたが、ここからは実際に AWS CLI のみを使って Hugo のホスティング環境を構築していきましょう。いくつか前提事項があるので、もしその中で準備が済んでいないものがあればご用意ください。

前提事項

  • AWS CLI の実行環境は macOS を想定
  • jq コマンドが使用可能であること
  • macOS 上で AWS CLI のセットアップが完了していること
  • git コマンドが使用可能であること
  • Hugo がインストールされていること
  • 利用可能なドメインが Amazon Route 53 で管理されていること
    • HostedZone に登録されていること

作成するリソース

今回 Hugo のホスティング環境として下図のような構成を構築していきます。

adcale2020 (2).png

作成するリソースは下記のとおりです。(以降の手順内でもリソースを指す文脈では下記のリソース名を用いるようにしますが、ゆれがあったらよしなに読み解いてください...)

  • AWS Certificate Manager
    • Certificate
  • Amazon Route 53
    • RecordSet
  • Amazon CloudFront
    • Distribution
    • CloudFrontOriginAccessIdentity
  • Amazon S3
    • Bucket
    • BucketPolicy

各リソース (構成要素) の作成手順は下記の通りなので、この順番で進めていきます。

  1. ACM で Certificate (SSL 証明書) を発行
  2. CloudFront で CloudFrontOriginAccessIdentity を作成
  3. Hugo で生成された静的ファイルを配置する S3 Bucket を作成
  4. CloudFront で Distribution を作成
  5. Route 53 に RecordSet を作成
  6. 設定したドメインでアクセスできるか確認

※手順の留意点

  • 手順内のコマンドについては十分確認していますが、そのまま本番環境で運用できるようなものではないので個人の利用範囲に留めておいてください (記載ミスなどあれば編集リクエストください)
  • 手順内で特に記述がない場合、リソースの作成先リージョンは 東京 (ap-northeast-1) とします
  • 各コードブロックは単一のコマンドになっています
  • お手元で実行する際は各コードブロックのコマンドを全てコピーして実行してください (Qiita のコードブロックって右上にコピーボタンついてたんですね。便利)
  • 全てターミナルの同一セッションで実行してください (コマンド実行時に使用するパラメーターを変数に設定して利用するため)
  • 手順の書き方については JASW-UG CLI 専門支部 のハンズオン資料を参考にさせていただいてます!

1. ACM で Certificate (SSL 証明書) を発行

ACM で Certificate を発行するにはリクエストしたあとにドメイン承認する必要があるので、それぞれの手順を 1.11.2 に分けています。

1.1 証明書発行をリクエスト

まず最初に、 Hugo で作成したサイトのドメインに対する SSL 証明書を ACM (Amazon Certificate Manager) で発行します。今回は <your-domain> というドメインが Route 53 で管理されているとして、そのサブドメインの hugo.<your-domain> というサブドメインをサイトのドメインにしてみます。

コマンド上で扱いやすいように、変数に代入しておきます。(以降の手順でも適宜 変数に代入してコマンドで使用する形をとります)

HOSTED_DOMAIN="<your-domain>"
HUGO_DOMAIN="hugo.${HOSTED_DOMAIN}"

Certificate の発行には acm request-certificate コマンドを使用します。また、ここで発行される Certificate は後に CloudFront の Distribution にアタッチするので、 バージニア北部 (us-east-1) リージョンで作成する必要があります。

次の手順であるドメイン認証の方法としては、メールによる認証と Route 53 に認証用の RecordSet を追加する方法がありますが、今回は後者で認証します。これを --validation-method オプションで指定します。

実行結果として CertificateArn が出力されるので、以降の手順で使用するために変数に設定します。

SSL_ARN=$( \
  aws acm request-certificate \
  --domain-name ${HUGO_DOMAIN} \
  --validation-method DNS \
  --region us-east-1 \
  --output text) \
&& echo ${SSL_ARN}

リクエストが完了すると ARN が出力されます。(************ はアカウント ID です)

実行結果
arn:aws:acm:us-east-1:************:certificate/fd93963c-91da-4cda-abcd-1234xx9999xx

もし下記のようなエラーが出た場合は、 Certificate のリクエスト上限に達しているので、上限緩和を申請するか、既存の Certificate を削除してください。

An error occurred (LimitExceededException) when calling the RequestCertificate operation (reached max retries: 2): Cannot request more certificates in this account. Contact Customer Service for details.

発行ステータスを確認するには acm describe-certificate コマンドを使って、下記のように確認します。

aws acm describe-certificate \
--certificate-arn ${SSL_ARN} \
--query "Certificate.Status" \
--output text \
--region us-east-1

現時点ではまだドメイン認証がされていないため、実行結果としては PENDING_VALIDATION と出力されます。

1.2 ドメイン認証

ドメイン認証に必要なレコードの情報はステータスの確認と同様に acm describe-certificate コマンドで確認できます。確認したあと Route 53 に登録する必要があるので、出力結果を変数に設定します。

VLIDATION_RECORD_JSON=$( \
  aws acm describe-certificate \
  --certificate-arn ${SSL_ARN} \
  --query "Certificate.DomainValidationOptions[0].ResourceRecord" \
  --region us-east-1) \
&& VALIDATION_RECORD_NAME=$(echo ${VLIDATION_RECORD_JSON} | jq -r ."Name") \
&& VALIDATION_RECORD_TYPE=$(echo ${VLIDATION_RECORD_JSON} | jq -r ."Type") \
&& VALIDATION_RECORD_VALUE=$(echo ${VLIDATION_RECORD_JSON} | jq -r ."Value") \
&& echo "
Name:\t${VALIDATION_RECORD_NAME}
Type:\t${VALIDATION_RECORD_TYPE}
Value:\t${VALIDATION_RECORD_VALUE}"

実行結果として、ドメイン認証用のレコード情報が出力されます。

実行結果
Name:   _01bca14fa7d9xxxxxxxxxxxxxxxxxxxx.hugo.<your-domain>.
Type:   CNAME
Value:  _10c99aaddfd2xxxxxxxxxxxxxxxxxxxx.wggjkglgrm.acm-validations.aws.

このレコード情報を Route 53 に登録するには route53 change-resource-record-set コマンドを使用します。その際に、対象のドメインの HostedZoneId が必要になるので、予め取得して変数に設定しておきます。使用するコマンドは route53 list-hosted-zones コマンドです。

HOSTED_ZONE_ID=$( \
  aws route53 list-hosted-zones \
  --query "HostedZones[?Name=='${HOSTED_DOMAIN}.'].Id" \
  --output text) \
&& echo ${HOSTED_ZONE_ID}

実行結果として、対象のドメインの HostedZoneId が出力されます。

実行結果
/hostedzone/Z3911234567890

ドメイン認証に必要な情報が揃ったので、認証用のレコードを作成します。

aws route53 change-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--change-batch \
"{
  \"Changes\": [
    {
      \"Action\": \"CREATE\",
      \"ResourceRecordSet\": {
        \"Name\": \"${VALIDATION_RECORD_NAME}\",
        \"Type\": \"${VALIDATION_RECORD_TYPE}\",
        \"TTL\": 300,
        \"ResourceRecords\": [{\"Value\": \"${VALIDATION_RECORD_VALUE}\"}]
      }
    }
  ]
}"

実行結果として、 RecordSet の変更情報が出力されます。

実行結果
{
    "ChangeInfo": {
        "Id": "/change/C0573087HOZHOD5SURWO",
        "Status": "PENDING",
        "SubmittedAt": "2020-12-02T14:12:55.563000+00:00"
    }
}

実行時の出力では StatusPending となっていますが、ほぼリアルタイムに変更が適用されます。

しばらくしてから Certificate の発行ステータスを確認すると、 ISSUED になっていることが確認できます。

aws acm describe-certificate \
--certificate-arn ${SSL_ARN} \
--query "Certificate.Status" \
--output text \
--region us-east-1
実行結果
ISSUED

これで hugo.<your-domain> に対する Certificate の発行が完了しました。

2. CloudFront で CloudFrontOriginAccessIdentity を作成

Amazon CloudFront では Distribution と CloudFrontOriginAccessIdentity というリソースを作成しますが、ここではまず CloudFrontOriginAccessIdentity を作成します。使用するコマンドは aws cloudfront create-cloud-front-origin-access-identity です。実行結果として作成された CloudFrontOriginIdentity の情報が出力されるので、その中から後で使用する Id を変数に設定します。

CFOAI_ID=$( \
  aws cloudfront create-cloud-front-origin-access-identity \
  --cloud-front-origin-access-identity-config \
    CallerReference="hugo-contents",Comment="OAI for Hugo bucket" \
  --query "CloudFrontOriginAccessIdentity.Id" \
  --output text) \
&& echo ${CFOAI_ID}

実行結果として、作成された CloudFrontOriginAccessIdentity の Id が出力されます。

実行結果
E3NRXXXXXXXXXX

3. Hugo で生成された静的ファイルを配置する S3 Bucket を作成

Hugo で生成された静的ファイルを配置する S3 Bucket を作成します。バケットを作成したあとに、作成したバケットに対してバケットポリシーをアタッチする必要があるので、バケットの作成とポリシーのアタッチ それぞれを手順 3.13.2 とします。

3.1 S3 Bucket を作成

作成するバケット名は <サイトのドメイン>-<AWS アカウント ID> という名前にします。アカウント ID を付けているのは、グローバルユニークな名前にするためです。(アカウント名を付けたら必ずユニークになるというわけではありません)

AWS アカウント ID は sts get-caller-identity コマンドの実行結果に含まれているので、そこから抜き出します。

AWS_ACCOUNT_ID=$( \
  aws sts get-caller-identity \
  --query 'Account' \
  --output text ) \
&& echo ${AWS_ACCOUNT_ID}
実行結果
************

あとは、ドメイン名と結合したものをバケット名として変数に設定します。

S3_BUCKET_NAME="${HUGO_DOMAIN}-${AWS_ACCOUNT_ID}"
echo ${S3_BUCKET_NAME}
実行結果
hugo.<your-domain>-************

S3 Bucket を作成するには s3api create-bucket コマンドを使用します。今回 S3 Bucket は東京 (ap-northeast-1) リージョンに作成するので、 --create-bucket-configuration でリージョンを指定します。

aws s3api create-bucket \
--bucket ${S3_BUCKET_NAME} \
--create-bucket-configuration "LocationConstraint=ap-northeast-1"
実行結果
{
    "Location": "http://hugo.<your-domain>-************.s3.amazonaws.com/"
}

3.2 バケットポリシーをアタッチ

3.1 で作成した S3 Bucket に対しては、バケットポリシーを用いて次のようなアクセス制限を設定します。

  • パブリックアクセスをすべてブロック
  • CloudFront 経由で読み込み可能

2 つ目の制限を設定するために、先ほど作成した CloudFrontOriginAccessIdentity の Id が必要になります。

バケットポリシーは JSON ファイルとして作成しておく必要があるので、下記のコマンドで bucket-policy.json を作成します。

cat << EOF > bucket-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "2",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CFOAI_ID}"
            },
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::${S3_BUCKET_NAME}/*",
                "arn:aws:s3:::${S3_BUCKET_NAME}"
            ]
        }
    ]
}
EOF

中身を確認します。

cat ./bucket-policy.json
実行結果
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "2",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E3NRXXXXXXXXXX"
            },
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::hugo.<your-domain>-************/*",
                "arn:aws:s3:::hugo.<your-domain>-************"
            ]
        }
    ]
}

バケットポリシーのアタッチには s3api put-bucket-policy コマンドを使用します。

aws s3api put-bucket-policy \
--bucket ${S3_BUCKET_NAME} \
--policy file://bucket-policy.json

出力はなにもないので、 s3api get-bucket-policy コマンドで正しくアタッチされたかどうか確認しておきます。

aws s3api get-bucket-policy \
--bucket ${S3_BUCKET_NAME}
実行結果
{
    "Policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"2\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E3NRXXXXXXXXXX\"},\"Action\":[\"s3:GetObject\",\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::hugo.<your-domain>-************/*\",\"arn:aws:s3:::hugo.<your-domain>-************\"]}]}"
}

ちょっと見にくいですが、アタッチできていることがわかります。

4. CloudFront で Distribution を作成

Distribution を作成 と簡単に書いていますが、設定項目が非常に多いです。Distribution が持っている要素としては次のようなものがあります。

  • Alias : カスタムドメインの情報
  • DefaultCacheBehavior : デフォルトの Behavior
  • Origins : オリジンの情報
  • HttpVersion : 許可する HTTP のバージョン

これら以外にも多くの設定項目がありますが、それらはコマンドのオプションで指定するのではなく、あらかじめ JSON ファイル (distribution-config.json) として作成しておきます。すべての項目を設定すると大変なので、省略可能なものは省略しています。

cat << EOF > distribution-config.json
{
    "Aliases": {
        "Quantity": 1,
        "Items": [
            "${HUGO_DOMAIN}"
        ]
    },
    "DefaultRootObject": "index.html",
    "Origins": {
        "Quantity": 1,
        "Items": [
            {
                "Id": "S3-${S3_BUCKET_NAME}",
                "DomainName": "${S3_BUCKET_NAME}.s3.amazonaws.com",
                "S3OriginConfig": {
                    "OriginAccessIdentity": "origin-access-identity/cloudfront/${CFOAI_ID}"
                }
            }
        ]
    },
    "DefaultCacheBehavior": {
        "TargetOriginId": "S3-${S3_BUCKET_NAME}",
        "ViewerProtocolPolicy": "redirect-to-https",
        "AllowedMethods": {
            "Quantity": 2,
            "Items": [
                "HEAD",
                "GET"
            ],
            "CachedMethods": {
                "Quantity": 2,
                "Items": [
                    "HEAD",
                    "GET"
                ]
            }
        },
        "ForwardedValues": {
            "QueryString": false,
            "Cookies": {
                "Forward": "none"
            },
            "Headers": {
                "Quantity": 0
            },
            "QueryStringCacheKeys": {
                "Quantity": 0
            }
        },
        "MinTTL": 0,
        "DefaultTTL": 60,
        "MaxTTL": 300
    },
    "Enabled": true,
    "ViewerCertificate": {
        "CloudFrontDefaultCertificate": false,
        "ACMCertificateArn": "${SSL_ARN}",
        "SSLSupportMethod": "sni-only",
        "MinimumProtocolVersion": "TLSv1.1_2016",
        "Certificate": "${SSL_ARN}",
        "CertificateSource": "acm"
    },
    "HttpVersion": "http2",
    "IsIPV6Enabled": true,
    "CallerReference": "hugo-distribution",
    "Comment": "Distribution for Hugo site"
}
EOF

中身を確認します。

cat distribution-config.json
実行結果
{
    "Aliases": {
        "Quantity": 1,
        "Items": [
            "hugo.<your-domain>"
        ]
    },
    "DefaultRootObject": "index.html",
    "Origins": {
        "Quantity": 1,
        "Items": [
            {
                "Id": "S3-hugo.<your-domain>-************",
                "DomainName": "hugo.<your-domain>-************.s3.amazonaws.com",
                "S3OriginConfig": {
                    "OriginAccessIdentity": "origin-access-identity/cloudfront/E3NRXXXXXXXXXX"
                }
            }
        ]
    },
    "DefaultCacheBehavior": {
        "TargetOriginId": "S3-hugo.<your-domain>-************",
        "ViewerProtocolPolicy": "redirect-to-https",
        "AllowedMethods": {
            "Quantity": 2,
            "Items": [
                "HEAD",
                "GET"
            ],
            "CachedMethods": {
                "Quantity": 2,
                "Items": [
                    "HEAD",
                    "GET"
                ]
            }
        },
        "ForwardedValues": {
            "QueryString": false,
            "Cookies": {
                "Forward": "none"
            },
            "Headers": {
                "Quantity": 0
            },
            "QueryStringCacheKeys": {
                "Quantity": 0
            }
        },
        "MinTTL": 0,
        "DefaultTTL": 60,
        "MaxTTL": 300
    },
    "Enabled": true,
    "ViewerCertificate": {
        "CloudFrontDefaultCertificate": false,
        "ACMCertificateArn": "arn:aws:acm:us-east-1:************:certificate/fd93963c-91da-4cda-abcd-1234xx9999xx",
        "SSLSupportMethod": "sni-only",
        "MinimumProtocolVersion": "TLSv1.1_2016",
        "Certificate": "arn:aws:acm:us-east-1:************:certificate/fd93963c-91da-4cda-abcd-1234xx9999xx",
        "CertificateSource": "acm"
    },
    "HttpVersion": "http2",
    "IsIPV6Enabled": true,
    "CallerReference": "hugo-distribution",
    "Comment": "Distribution for Hugo site"
}

Distribution の作成には cloudfront create-distribution コマンドを使用します。

aws cloudfront create-distribution \
--distribution-config file://distribution-config.json

実行結果として、作成された Distribution のすべての情報が出力されますが、ここでは出力結果の記述は省略します)

cloudfront list-distributions コマンドを使用して、 Distribution が正しく作成できたことの確認も兼ねて、後に使用する Distribution の DomainName を取得します。

CF_DOMAIN_NAME=$(aws cloudfront list-distributions \
  --query "DistributionList.Items[?Aliases.Items[0]=='${HUGO_DOMAIN}'].DomainName" \
  --output text ) \
&& echo ${CF_DOMAIN_NAME}
実行結果
d1xxxxxxxxxxxx.cloudfront.net

5. Route 53 に RecordSet を作成

Hugo で生成したサイトに当てるドメインを Route 53 の RecordSet として追加します。と言っても、 SSL 証明書発行手順の中でドメイン認証のためのレコードを追加したので、コマンドはそれとほぼ同じです。
今回は RecordSet の Type が A 、つまり Alias レコードになるので、 AliasTarget としてレコードの情報を指定します。

aws route53 change-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--change-batch \
"{
  \"Changes\": [
    {
      \"Action\": \"CREATE\",
      \"ResourceRecordSet\": {
        \"Name\": \"${HUGO_DOMAIN}\",
        \"Type\": \"A\",
        \"AliasTarget\": {
          \"HostedZoneId\": \"Z2FDTNDATAQYW2\",
          \"DNSName\": \"${CF_DOMAIN_NAME}\",
          \"EvaluateTargetHealth\": false
        }
      }
    }
  ]
}"

実行結果
{
    "ChangeInfo": {
        "Id": "/change/C0557598RTB2IBN8A248",
        "Status": "PENDING",
        "SubmittedAt": "2020-12-03T15:57:20.421000+00:00"
    }
}

6. 設定したドメインでアクセスできるか確認

Hugo のサイトをデプロイする前に、シンプルな HTML ファイルを置いてドメインでアクセスできることを確認してみます。

cat << EOF > index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Hello Hugo!</title>
</head>
<body>
  <h1>Hello Hugo!</h1>
</body>
</html>
EOF

S3 Bucket にアップロードします。

aws s3 cp index.html s3://${S3_BUCKET_NAME}/
実行結果
upload: ./index.html to s3://hugo.<your-domain>-************/index.html

ブラウザ等で設定したドメインにアクセスして確認します。ここでは curl コマンドで確認してみます。

curl -i https://${HUGO_DOMAIN}
実行結果
HTTP/2 200
content-type: text/html
content-length: 148
last-modified: Thu, 03 Dec 2020 16:03:06 GMT
accept-ranges: bytes
server: AmazonS3
date: Thu, 03 Dec 2020 16:06:17 GMT
etag: "630ccccc8ccc651b9ccccbf1bf364c8c"
x-cache: Hit from cloudfront
via: 1.1 a8f6d439d4b35a734e48cf0ced363c2d.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-C2
x-amz-cf-id: odV08a6Fk2TZXDmyRxarBGbHH-Xv_gMz6_AL1c7Ww1V_Erz3PxJhKA==
age: 37

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Hello Hugo!</title>
</head>
<body>
  <h1>Hello Hugo!</h1>
</body>
</html>

無事にアクセスできました。場合によってはドメインでアクセスできるようになるまで数分から数時間かかる場合もあります。

Hugo で生成したサイトをデプロイしてみる

では最後に Hugo でサイトを生成してデプロイしてみます。下記のコマンドで、 Hugo 公式のクイックスタート の通りにサイトを生成してサンプルの投稿を作ってビルドするところまでやっています。(途中ちょっとおまじないを入れてます 1 )

hugo new site hugo-sample \
&& cd hugo-sample \
&& git init \
&& git submodule add https://github.com/budparr/gohugo-theme-ananke.git themes/ananke \
&& echo 'theme = "ananke"' >> config.toml \
&& sed -i -r "s/http:\/\/example.org/https:\/\/${HUGO_DOMAIN}/" config.toml \
&& sed -i -r 's/draft: true/draft: false\
url: "\/{{ .Type }}\/{{ .Name}}.html"/g' archetypes/default.md \
&& hugo new posts/my-first-post.md \
&& hugo

続いて、 S3 Bucket にデプロイします。

aws s3 sync public/ s3://${S3_BUCKET_NAME}/

CloudFront に先ほどアクセスしたキャッシュが残っている可能性があるので、 cloudfront create-invalidation コマンドでキャッシュをクリアします。その際、対象の Distribution の Id が必要になるので、 DomainName を取得したときと同じ方法で変数に設定します。

CF_DIST_ID=$(aws cloudfront list-distributions \
  --query "DistributionList.Items[?Aliases.Items[0]=='${HUGO_DOMAIN}'].Id" \
  --output text ) \
&& echo ${CF_DIST_ID}
実行結果
E3QDXXXXXXXXXX

この変数を用いて cloudfront create-invalidation コマンドを実行します。

aws cloudfront create-invalidation \
--distribution-id ${CF_DIST_ID} \
--paths "/*"

実行結果として、発行した Invalidation の情報が出力されます。

実行結果
{
    "Location": "https://cloudfront.amazonaws.com/2020-05-31/distribution/E3QDXXXXXXXXXX/invalidation/I394QPIOSBJY5Y",
    "Invalidation": {
        "Id": "I394QPIOSBJY5Y",
        "Status": "InProgress",
        "CreateTime": "2020-12-03T16:42:29.295000+00:00",
        "InvalidationBatch": {
            "Paths": {
                "Quantity": 1,
                "Items": [
                    "/*"
                ]
            },
            "CallerReference": "cli-1607013748-344932"
        }
    }
}

Invalidation のステータスは cloudfront list-invalidations コマンドで確認します。

aws cloudfront list-invalidations \
--distribution-id ${CF_DIST_ID}
実行結果
{
    "InvalidationList": {
        "Items": [
            {
                "Id": "I394QPIOSBJY5Y",
                "CreateTime": "2020-12-03T16:42:29.295000+00:00",
                "Status": "Completed"
            }
        ]
    }
}

ステータスが Completed になったら、今度はブラウザでアクセスしてみます。

sc__2020-12-04_21007.png

トップページが見えていますね 🎉

sc__2020-12-04_21024.png

記事ページも見えていますね 🎉

以上で Hugo のホスティング環境の構築が完了しました。お疲れさまでした。今回作成したリソースに対して発生するコストとしては下記のとおりです。(ドメインの準備は事前準備に含まれるので、ドメイン自体の料金と Route 53 の HostedZone にかかるコストも除外しています)

  • Amazon CloudFront
    • リクエストに応じたコスト
  • Amazon S3
    • オブジェクトのサイズに応じたコスト
      • 使用しているストレージに対して発生するコスト
      • S3 Bucket にオブジェクトを Put する際に発生するコスト

といっても、それぞれ発生するコストは微々たるものです。この構成で発生するコストについては以前に個人ブログの方に書いたので、参考にしてください。

これでアウトプットする場が完成したので、どんどんアウトプットしていきましょう!

この構成を成長させる

今回 構築した構成は最低限の内容になっています。ブログとして記事を書く側としても読む側としても、改善していく部分はいくつかあるので、過去に個人ブログでやってきた内容を例として挙げておきます。

デプロイの自動化

今回の手順では、Hugo で生成した静的ファイルをデプロイする際 s3 sync コマンドでファイルをアップロードして cloudfront create-invalidation でキャッシュをクリアしていましたが、毎回これを実行するのは面倒です。これらを自動化するために、 GitHub への Push をトリガーにして、 AWS CodeBuild を起動してデプロイするという構成が考えられます。

これに関しては以前に個人ブログに書いているので、参考にしてみてください。

Git リポジトリとして AWS CodeCommit を使う場合は AWS CodePipeline をあわせて使うと楽になります。こちらも参考記事を載せておきます。

URL の正規化

CloudFront + S3 で静的ファイルを配信する際、インデックスドキュメントの挙動が通常の Web サーバーとは異なります。というのは、 /index.html を省略してくれない場合が存在するということです。 CloudFront のエッジサーバーで動く Lambda@Edge を使えばそのあたりを上手く調整することができます。

キャッシュの最適化

キャッシュと一言で言っても、サーバー側のキャッシュとクライアント側のキャッシュがあります。それらを適切に設定することで、アクセスしてくる人の体験向上に繋がります。サーバー側のキャッシュについては CloudFront の Distribution で設定します。キャッシュの時間、クエリパラメータやヘッダー情報でキャッシュするかどうかの設定などがあってちょっと複雑なので、公式ドキュメントや以前まとめた記事を参考にしてみてください。

クライアント側のキャッシュについては、 S3 オブジェクトのメタデータとして設定します。そこで設定した値と CloudFront の設定によって挙動が決まるので、上記のページに加えて下記のページも参考になるかと思います。

リソースの削除

このままアウトプットしましょう!とか成長させましょう!とか書いておいてあれなんですが、せっかくハンズオンっぽい内容になっているので、最後に今回作成したリソースを削除する手順も書いておきます。

リソースを削除する順番は下記のとおりです。基本的にリソースの作成と逆の順序で削除していきます。

  1. S3 Bucket を削除
  2. Route 53 の RecordSet を削除
  3. CloudFront の Distribution を削除
  4. CloudFront の CloudFrontOriginAccessIdentity を削除
  5. ACM の Certificate とドメイン認証用の RecordSet を削除

1. S3 Bucket を削除

まずは S3 Bucket を削除しますが、削除するには S3 Bucket が空である必要があります。ただし S3 Bucket を空にする というコマンドは存在しないので、既存のコマンドを使ってすべてのオブジェクトを削除します。

S3 Bucket 内のオブジェクトをすべて削除するには、ローカルで空のディレクトリを作って s3 sync コマンドを実行する方法と、すべてのオブジェクトに対して s3api delete-object コマンドを実行する方法があります。 今回は前者の方法で実行します。

あらためて必要な情報は変数に定義して進めていきます。

HOSTED_DOMAIN="<your-domain>"
HUGO_DOMAIN="hugo.${HOSTED_DOMAIN}"
AWS_ACCOUNT_ID=$( \
  aws sts get-caller-identity \
  --query 'Account' \
  --output text )
S3_BUCKET_NAME="${HUGO_DOMAIN}-${AWS_ACCOUNT_ID}" \
&& echo "
HOSTED_DOMAIN\t= ${HOSTED_DOMAIN}
HUGO_DOMAIN\t= ${HUGO_DOMAIN}
AWS_ACCOUNT_ID\t= ${AWS_ACCOUNT_ID}
S3_BUCKET_NAME\t= ${S3_BUCKET_NAME}"
実行結果
HOSTED_DOMAIN   = <your-domain>
HUGO_DOMAIN     = hugo.<your-domain>
AWS_ACCOUNT_ID  = ************
S3_BUCKET_NAME  = hugo.<your-domain>-************

s3 sync でオブジェクトをすべて削除するには、ローカルで適当なディレクトリを作成し、そのディレクトリと対象の S3 Bucket を s3 sync コマンドに --delete オプションを付けて同期します。

mkdir -p empty_dir \
&& aws s3 sync ./empty_dir/ s3://${S3_BUCKET_NAME}/ --delete >&/dev/null \
&& aws s3api list-objects-v2 \
--bucket ${S3_BUCKET_NAME}

確認用に s3api list-objects-v2 コマンドを実行していますが、空の S3 Bucket になっているため何も出力されません。

S3 Bucket が空になったら、 s3api delete-bucket コマンドで S3 Bucket を削除します。

aws s3api delete-bucket \
--bucket ${S3_BUCKET_NAME}

実行結果は何も出力されません。念のため、対象の S3 Bucket が存在しないことを s3api list-buckets コマンドで確認しておきます。

aws s3api list-buckets \
--query "Buckets[?Name=='${S3_BUCKET_NAME}']" \
--output text

何も出力されなければ、 S3 Bucket は削除されています。

ちなみに、 s3api delete-object コマンドで削除する場合は、 s3api list-objects-v2 コマンドと合わせて下記のように実行します。

for i in $(
  aws s3api list-objects-v2 \
  --bucket ${S3_BUCKET_NAME} \
  --query 'Contents[].Key' \
  --output text
); do
  aws s3api delete-object \
  --bucket ${S3_BUCKET_NAME} \
  --key ${i}
done

2. Route 53 の RecordSet を削除

次に、 Route 53 に登録した RecordSet を削除します。ここで削除するのは、サイト用のドメインにあたる RecordSet です。 ACM で SSL 証明書を発行するために作成したドメイン認証用の RecordSet は後ほど削除します。

RecordSet を削除するには、登録するときと同様に route53 change-resource-record-sets コマンドを使用しますが、その前にドメインの HostedZoneId を取得しておきます。

HOSTED_ZONE_ID=$( \
  aws route53 list-hosted-zones \
  --query "HostedZones[?Name=='${HOSTED_DOMAIN}.'].Id" \
  --output text)

削除前に、対象の RecordSet の存在を確認するついでに、削除時に必要となる RecordSet の Value (Alias の DNSName) を取得します。

CF_DOMAIN_NAME=$( \
  aws route53 list-resource-record-sets \
  --hosted-zone-id ${HOSTED_ZONE_ID} \
  --query "ResourceRecordSets[?Name=='${HUGO_DOMAIN}.'].AliasTarget.DNSName" \
  --output text) \
&& echo ${CF_DOMAIN_NAME}
実行結果
d1xxxxxxxxxxxx.cloudfront.net.

これらの値を用いて route53 change-resource-record-sets コマンドで削除します。パラメータや値は作成時とほぼ一緒で、 Canges.ActionCREATE ではなく DELETE になります。

aws route53 change-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--change-batch \
"{
  \"Changes\": [
    {
      \"Action\": \"DELETE\",
      \"ResourceRecordSet\": {
        \"Name\": \"${HUGO_DOMAIN}\",
        \"Type\": \"A\",
        \"AliasTarget\": {
          \"HostedZoneId\": \"Z2FDTNDATAQYW2\",
          \"DNSName\": \"${CF_DOMAIN_NAME}\",
          \"EvaluateTargetHealth\": false
        }
      }
    }
  ]
}"
実行結果
{
    "ChangeInfo": {
        "Id": "/change/C06975891HKJHOX12B72Z",
        "Status": "PENDING",
        "SubmittedAt": "2020-12-07T13:02:59.240000+00:00"
    }
}

route53 list-resource-record-sets コマンドで、対象の RecordSet が削除されていることを確認します。

aws route53 list-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--query "ResourceRecordSets[?Name=='${HUGO_DOMAIN}.']"
実行結果
[]

これでサイト用の RecordSet が削除できました。

3. CloudFront の Distribution を削除

続いて CloudFront の Distribution を削除します。削除するには cloudfront delete-distribution コマンドを使用しますが、その前に cloudfront update-distribution コマンドで対象の Distribution の State を Disabled (DistributionConfig.Enabledfalse) に変更しておく必要があります。(Enabled のままだとエラーになります 2) これらのコマンドの実行時には対象となる Distribution の Id と ETag が必要になるので、まずはそれらを事前に取得しておきます。

CF_DIST_ID=$(aws cloudfront list-distributions \
  --query "DistributionList.Items[?Aliases.Items[0]=='${HUGO_DOMAIN}'].Id" \
  --output text)

取得した Id を元に ETag を取得します。

CF_DIST_ETAG=$(aws cloudfront get-distribution \
  --id ${CF_DIST_ID} \
  --query "ETag" \
  --output text) \
&& echo ${CF_DIST_ETAG}
実行結果
E38H402CF0QSGY

また、変更前の Distribution の情報を取得して、 Distribution.DistributionConfig の値を更新時に使用する JSON ファイルとして保存しておきます。その際、 Distribution.DistributionConfig.Enabled の値を false に変更しておきます。

aws cloudfront get-distribution \
--id ${CF_DIST_ID} \
| jq ".Distribution.DistributionConfig.Enabled|=false" \
| jq ".Distribution.DistributionConfig" \
> distribution-config-update.json

以上、 2 つの値と JSON ファイルを使用して、まずは Distribution の State を変更します。変更時には、作成時と同様に DistributionConfig として指定する JSON ファイルが必要になるので、先ほど作成した JSON ファイルを指定します。実行後は ETag の値が変わるので、出力結果から新しい ETag の値を取得しておきます。

CF_DIST_ETAG=$(aws cloudfront update-distribution \
  --id ${CF_DIST_ID} \
  --if-match ${CF_DIST_ETAG} \
  --distribution-config file://distribution-config-update.json \
  --query "ETag" \
  --output text ) \
&& echo ${CF_DIST_ETAG}
実行結果
E18V3ES2M2IU4T

これで State を Disabled に変更できたので、 Distribution を削除します。(実行後すぐに削除しようとすると、まだ State の変更が反映されていないためエラーになる場合があります)

aws cloudfront delete-distribution \
--id ${CF_DIST_ID} \
--if-match ${CF_DIST_ETAG}

出力は特にありません。

cloudfront get-distribution コマンドで、対象の Distribution が存在しないことを確認します。

aws cloudfront get-distribution \
--id ${CF_DIST_ID}
実行結果
An error occurred (NoSuchDistribution) when calling the GetDistribution operation: The specified distribution does not exist.

対象の Distribution は存在しないので NoSuchDistribution エラーになりました。

4. CloudFront の CloudFrontOriginAccessIdentity を削除

続いて、 CloudFront の CloudFrontOriginAccessIdentity を削除します。削除するには cloudfront delete-cloudfront-origin-access-identity コマンドを使用します。その際に、対象の CloudFrontOriginAccessIdentity の Id と ETag が必要になるので、事前に取得します。

CFOAI_ID=$(aws cloudfront list-cloud-front-origin-access-identities \
  --query "CloudFrontOriginAccessIdentityList.Items[?Comment=='OAI for Hugo bucket'].Id" \
  --output text)

取得した Id を元に ETag を取得します。

CFOAI_ETAG=$(aws cloudfront get-cloud-front-origin-access-identity \
  --id ${CFOAI_ID} \
  --query "ETag" \
  --output text) \
&& echo ${CFOAI_ETAG}
実行結果
EGBZ51UWS66MX

この 2 つの値を使って CloudFrontOriginAccessIdentity を削除します。

aws cloudfront delete-cloud-front-origin-access-identity \
--id ${CFOAI_ID} \
--if-match ${CFOAI_ETAG}

出力は特にありません。

cloudfront get-cloud-front-origin-access-identity コマンドで対象の CloudFrontOriginAccessIdentity が存在しないことを確認します。

aws cloudfront get-cloud-front-origin-access-identity \
--id ${CFOAI_ID}
実行結果
An error occurred (NoSuchCloudFrontOriginAccessIdentity) when calling the GetCloudFrontOriginAccessIdentity operation: The specified CloudFront origin access identity does not exist.

対象の CloudFrontOriginAccessIdentity が存在しないので NoSuchCloudFrontOriginAccessIdentity エラーになりました。

5. ACM の Certificate とドメイン認証用の RecordSet を削除

最後に、 ACM で発行した Certificate (SSL 証明書) と、発行時のドメイン認証のために作成した Route 53 の RecordSet を削除します。 Certificate の削除には Certificate ARN が、 RecordSet の削除には Name と Type および Value がそれぞれ必要になるので、 Certificate の情報からそれらを取得します。

まずは Certificate ARN を取得します。

SSL_ARN=$( \
  aws acm request-certificate \
  --domain-name ${HUGO_DOMAIN} \
  --validation-method DNS \
  --region us-east-1 \
  --output text) \
&& echo ${SSL_ARN}
実行結果
arn:aws:acm:us-east-1:************:certificate/fd93963c-91da-4cda-abcd-1234xx9999xx

続いて RecordSet の Name と Type と Value の情報を Certificate から取得します。

VLIDATION_RECORD_JSON=$( \
  aws acm describe-certificate \
  --certificate-arn ${SSL_ARN} \
  --query "Certificate.DomainValidationOptions[0].ResourceRecord" \
  --region us-east-1) \
&& VALIDATION_RECORD_NAME=$(echo ${VLIDATION_RECORD_JSON} | jq -r ."Name") \
&& VALIDATION_RECORD_TYPE=$(echo ${VLIDATION_RECORD_JSON} | jq -r ."Type") \
&& VALIDATION_RECORD_VALUE=$(echo ${VLIDATION_RECORD_JSON} | jq -r ."Value") \
&& echo "
Name:\t${VALIDATION_RECORD_NAME}
Type:\t${VALIDATION_RECORD_TYPE}
Value:\t${VALIDATION_RECORD_VALUE}"
実行結果
Name:   _01bca14fa7d9xxxxxxxxxxxxxxxxxxxx.hugo.<your-domain>.
Type:   CNAME
Value:  _10c99aaddfd2xxxxxxxxxxxxxxxxxxxx.wggjkglgrm.acm-validations.aws.

これらの情報を元に、まずは RecordSet を削除します。使用するコマンドは、 RecordSet 作成時と同じ route53 change-resource-record-sets で、 ActionDELETE にして実行します。

aws route53 change-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--change-batch \
"{
  \"Changes\": [
    {
      \"Action\": \"DELETE\",
      \"ResourceRecordSet\": {
        \"Name\": \"${VALIDATION_RECORD_NAME}\",
        \"Type\": \"${VALIDATION_RECORD_TYPE}\",
        \"TTL\": 300,
        \"ResourceRecords\": [{\"Value\": \"${VALIDATION_RECORD_VALUE}\"}]
      }
    }
  ]
}"
実行結果
{
    "ChangeInfo": {
        "Id": "/change/C0511374XADT3XZRSQ2D",
        "Status": "PENDING",
        "SubmittedAt": "2020-12-07T15:15:06.439000+00:00"
    }
}

route53 list-resource-record-sets コマンドで、対象の RecordSet が削除されていることを確認します。

aws route53 list-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--query "ResourceRecordSets[?Name=='${VALIDATION_RECORD_NAME}.']"
実行結果
[]

これでドメイン認証用に作成した RecordSet が削除できました。

さて、これで最後です。 ACM の Certificate を削除します。削除するには acm delete-certificate コマンドを使用します。

aws acm delete-certificate \
--certificate-arn ${SSL_ARN} \
--region us-east-1

出力は特にありません。

acm get-certificate コマンドで対象の Certificate が存在しないことを確認します。

aws acm get-certificate \
--certificate-arn ${SSL_ARN}
実行結果
An error occurred (ResourceNotFoundException) when calling the GetCertificate operation: Could not find certificate arn:aws:acm:us-east-1:************:certificate/fd93963c-91da-4cda-abcd-1234xx9999xx.

対象の Certificate が存在しないので ResourceNotFoundException エラーになりました。

以上で、今回作成したリソースの削除が完了しました。

まとめ

だいぶ長くなってしまいましたが、 AWS CLI だけで Hugo のホスティング環境を構築してみた話でした。

冒頭にも書いたように、 AWS CLI は AWS で各サービス、リソースを扱っていく上で、それらに対してより理解を深めるためには最高のツールです。なので、特に AWS を触り始めてまだ間もない方にこそ使ってほしいツールかなと思います。いきなり全てを CLI でやろうと思うと辛いので、 AWS CLI での操作をマネジメントコンソールで都度確認しながらやるスタイルで使ってみてください。

今回、このようなハンズオンっぽい形で手順を書くのは初めてだったので、何かわかりにくい点などあればコメントなり Twitter なりにフィードバックいただけると幸いです!

以上、 弁護士ドットコム Advent Calendar 2020 の 12 日目の記事は AWS CLI で Hugo のホスティング環境を作ってみる話でした。

明日の担当は @enkdsn です。お楽しみに 👋


  1. CloudFront 経由で S3 にアクセスする際、対象の S3 Bucket の直下にある index.html のみ省略が可能で、それ以降の階層にアクセスするためには明示的に index.html にアクセスする必要があります。記事のパーマリンクを post-title.html として設定するように archetypes/default.md を編集しています。 

  2. An error occurred (DistributionNotDisabled) when calling the DeleteDistribution operation: The distribution you are trying to delete has not been disabled. 

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
What you can do with signing up
0
Help us understand the problem. What are the problem?