273
165

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

CloudFormation vs Terraform

Last updated at Posted at 2020-07-10

CloudFormation vs Terraform

はじめに

CloudFormationとTerraform、どっちが良いの?

「AWSでIaCしたい」 そんなときほぼ確実に発生する議題ではないでしょうか。
この議題に対しては、以下のようなことをチームで話し合うことになるでしょう(僕はなりました)。

「CloudFormationはベンダロックインするからTerraformにしよう、Terraformならマルチクラウドに対応してるってよ!」
「AWSしか使わないならサービス連携面で強い(らしい)CloudFormation使おうよ」
「でもCloudFormationってなんか書きにくいらしいよ?Terraformにしない?」
「CloudFormationはyamlで書けるから学習コスト低いけどTerraformの文法は独自記法だからCloudFormationの方が…」
「あーだ」
「こーだ」

この類の議論は昨今不毛とされています、好きな方でさっさと開発始めた方が良いからです。
ですが、先に色々と考えを巡らせておいたほうが良いのもまた事実です。規模が大きいシステムでは後戻りが難しくなるのでなおさらですね。
そこで本エントリーではCloudFormationとTerraformに、どういった特徴や差異があるのかを比較してみたいと思います。
開発スタートまでの議論を素早く進めるための情報源として活用頂ければ幸いです。

比較観点はもちろん(?)独断と偏見により決定します。
筆者はCloudFormationもTerraformもチョットカイタコトアル程度です。間違ってたらスマン。
※ やさしいマサカリお待ちしてます

情報の時期

2020/07/05 あたり

SW ver
terraform v0.12.26
awscli 1.18.79

独断と偏見による評価の凡例

凡例 意味
バッチリ!最高!
ヨシ!
ちょい微妙?
× 無理ゲー/未対応

↓比較してイクゾー!↓

UI

Terraform CloudFormation
CUI ( terraform コマンド) GUI (AWSコンソール)
CUI (AWSCLI)

Terraform

各OS用に terraform コマンドのバイナリが用意されており、それをダウンロードして使います。
Terraform CLIのバージョンマネージャである tfenv を使うと便利だったりします。

terraform_cli

CloudFormation

スタック管理を含む様々な機能をGUIから利用できます。

cloudformation_gui

AWSCLIでの操作もできます。

cloudformation_cli


状態の持ち方

CloudFormationもTerraformも「現在管理しているリソースの状態」を管理する仕組みとなっており、この状態管理の仕方に差異があります。

Terraform CloudFormation
ファイルで管理 AWSのサービスで(勝手に)管理

Terraform

状態管理用のファイルである terraform.tfstate ファイルが生成されます。
中身は以下のようなjsonです。

{
  "version": 4,
  "terraform_version": "0.12.26",
  "serial": 4,
  "lineage": "f5d048e9-224c-865d-60f3-5e64f9871e89",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "aws_instance",
      "name": "sample",
      "provider": "provider.aws",
      "instances": [
        {
          "schema_version": 1,
          "attributes": {
            "ami": "ami-0a1c2ec61571737db",
            "arn": "<ec2-arn>",
            "associate_public_ip_address": false,
...省略...

生成先は何も指定しなければローカルになりますが、backendを指定することでS3などに格納することもできます。
チーム開発時はこのようにクラウドストレージに格納するのが良いでしょう。

terraform {
  backend "s3" {
    bucket = "tfstate-bucket"
    key = "terraform.tfstate"
    region = "ap-northeast-1"
  }
}

通常、このファイルを直接いじることはありませんが、状況によってはtfstateを直接修正するようなオペレーションが必要になるかもしれません(トラブル時など)。Terraformを扱う上では、tfstateの中身はいつか理解しなければならないものと捉えておいた方が良いでしょう。
参考 → 「一回くらいローカルでterraform applyしてもいいだろう。その時はそう思ってました。」 tfstateをダウングレードしなくてはいけないときの指南書

状態をファイルとして管理しなければならないのは初学者にとっては不便に感じるかもしれませんし、管理対象が増えるのは望むところではないと思います。
しかしファイルを自分たちで管理できるということは、「中身さえ理解すれば柔軟に運用できる」ということでもあるため、一概に悪いことばかりではないです。
実際、↑で紹介したブログではtfstateファイルの内容を理解して変更することにより、当初の目的を達成しつつトラブルを解決することができています。

また、tfstateの存在により運用に柔軟性を持たせることができている例として以下のようなものもあります。
Terraform職人入門: 日々の運用で学んだ知見を淡々とまとめる - tfファイルをリファクタリングする
この例では、本来ならば(?)あるリソースを再作成しないと実施できなかった変更が、tfstateファイルをいじることによりサービス影響無く実施できています。

CloudFormation

状態はAWSサービス側で持ってくれます。AWSコンソールやAWSCLIから参照できます。
現在のリソースの状態以外にも、イベントや適用元であるテンプレートの内容などが確認でき、CloudFormationの方がより多くの情報を保持していることが分かります。
スタック生成時に勝手に状態管理し始めてくれるため、Terraformのように状態ファイルの格納場所などを気にする必要はありません。

CloudFormation Stack 画面

CloudFormationの各リソースにはリンクが付いていて該当サービスの画面に飛べたりするので便利です(たまに飛べないのもあるけど)。
また、AWSコンソールでCloudFormationの画面を確認すれば、どういうリソースをどういうテンプレートで管理しているのかを一元的に確認できます。


リソースのタグ付け

AWSリソースにタグを付与するための仕組みについてです。

Terraform CloudFormation
コードとして表現する必要がある スタック生成時のオプションで実現可能

Terraform

全リソース分、コードとして明示的にタグを付与する必要があります。

CloudFormation

スタック生成時のオプションを用いて、スタック内のすべてのリソースに一貫したタグを付与することができます。
CloudFormationの力で行ってくれるので非常に便利です。
もちろんコード上表現することでリソース個別に付与することも可能です。


変更内容のチェック

コードを実機状態に反映する前に「何が変更されるのか」を確認する方法についてです。

Terraform CloudFormation
非常に見やすい 場合によっては見づらい

Terraform

terraform plan を実行することで確認できます。出力例を以下に記載しておきます。

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_instance.sample will be updated in-place
  ~ resource "aws_instance" "sample" {
        ami                          = "ami-0a1c2ec61571737db"
        arn                          = "arn:aws:ec2:ap-northeast-1:521635626865:instance/i-09ecc4b25e3e55b30"
        associate_public_ip_address  = false
        availability_zone            = "ap-northeast-1d"
        cpu_core_count               = 1
        cpu_threads_per_core         = 1
        disable_api_termination      = false
        ebs_optimized                = false
        get_password_data            = false
        hibernation                  = false
        id                           = "i-09ecc4b25e3e55b30"
        instance_state               = "running"
      ~ instance_type                = "t2.micro" -> "t3.micro"
        ipv6_address_count           = 0
        ipv6_addresses               = []
        key_name                     = "test"
        monitoring                   = false
        primary_network_interface_id = "eni-0cfb6130b577006f4"
        private_dns                  = "ip-10-2-0-88.ap-northeast-1.compute.internal"
        private_ip                   = "10.2.0.88"
        security_groups              = []
        source_dest_check            = true
        subnet_id                    = "subnet-0b237836abbd38b06"
        tags                         = {}
        tenancy                      = "default"
        volume_tags                  = {}
        vpc_security_group_ids       = [
            "sg-09c58fba174e8af21",
        ]

        credit_specification {
            cpu_credits = "standard"
        }

        metadata_options {
            http_endpoint               = "enabled"
            http_put_response_hop_limit = 1
            http_tokens                 = "optional"
        }

        root_block_device {
            delete_on_termination = true
            device_name           = "/dev/xvda"
            encrypted             = false
            iops                  = 100
            volume_id             = "vol-04a65178ac2a8ada0"
            volume_size           = 8
            volume_type           = "gp2"
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

CloudFormation

変更セット という機能を使って確認できます。

changeset_abst

ざっくりと視覚的に確認可能です。

changeset_json

具体的な変更内容の確認も確認できますが、結局は修正前後のテンプレートの差分を確認する必要があったりします。

総合すると以下のような感じでしょうか。

  • 「どのリソースが追加/変更/削除されるのか」くらいのざっくりした確認で良い場合は便利
  • ↑に加えて「どのリソースにどのような変更が加わるのか」といった詳細まで確認したい場合はちょっと不便

また、後述する「ネストされたスタック」を用いて構成管理する場合は、これ以上に差分が見づらい状態になります。


一部リソースの再作成

まぁまぁな頻度で発生しがちな、「このインスタンスだけ再作成したいなー」という要望への対応可否についてです。

Terraform CloudFormation
×
taintで簡単に実現可能 めんどくさい

Terraform

以下のような方法があります。

  • terraform taint
    taintedにマークされたリソースは、次回apply時に再作成される挙動となります。
    参考 → 公式ドキュメント - Command: taint

  • terraform destroy
    terraform destroy コマンドのオプションに -target があります。
    以下の様にリソースの種別と論理名を与えることで、特定のリソースだけ選んで削除することができます。

    $ terraform destroy -target=aws_instance.sample
    

どちらの場合でも、この後 apply を行えば削除したインスタンスは再作成されるので、非常に簡単に対応できます。
ちなみに特に理由がなければ taint の方がおすすめです。

CloudFormation

該当リソースに関する記述を削除したテンプレートを用いてスタックを更新し、その後元のテンプレートで改めてスタックを更新する必要がある気がします。

★便利なやり方知ってる方いたら教えて頂けると嬉しいです


外部の設定変更に対する柔軟性

トラブルシュートなどを起因として、「実機が正、構成管理ツールが持っている情報が誤」という状態が発生することもあるかもしれません。
このような状況に対処するときの柔軟性や難易度の観点の比較です。

Terraform CloudFormation
×
柔軟に対応できる 基本「手動変更の内容をコードにあわせて戻す」

ちなみにできるからといって、手動変更とIaCでの構成管理を並行して実施するのは全くおすすめしません。というかアンチパターンです、絶対にやめましょう!!!

Terraform

以下のような手順で、手動で行った変更内容をコード側に反映させる形での対処が可能です。

  • 手動変更による差分を terraform plan で確認します
  • 手動変更の内容をコードに反映し、 terraform plan を再度実行します
  • 手動変更したパラメータと実機に差分があれば更に修正し、望む形まで修正したら terraform apply を実行します
    • コード-実機間の差分が存在する場合は、ここでコード側を正として更新される動作となります

また、Terraform外での変更をそもそも許容したいようなケースは、コード側で lifecycle ブロックの ignore_changes を用いることで実現できます。
参考 → Terraform職人入門: 日々の運用で学んだ知見を淡々とまとめる - リソースの差分を無視する

CloudFormation

構成差分は以下のようなドリフトステータスとして確認できます。

drift_status

ドリフトが発生していて、それがCloudFormation外の何か行われている場合の対処は基本的に、「元のテンプレートにあわせて実体のリソースの方を修正する」となります。
実際に、CloudFormationで作成したEC2インスタンスを削除後に同じテンプレートでスタックを更新しようとすると、「テンプレートに差分が無いぞ」と怒られ、スタックの更新を行うことはできません。

ドリフトが発生したリソースやどういった差分なのかにより、対処方法は様々です。
参考 → CloudFormaitonで作成したStackを手動で変更した時のメモ

Terraformの lifecycle.ignore_changes ように、パラメータレベルで差分を無視する方法については見つけられませんでした。
★知ってる方いたら教えて下さい


リソースの削除対策

IaCでリソース管理を行う場合絶対に避けたい「消えちゃいけないリソース消えちゃいました、てへぺろ(・ω<)」についてです。

Terraform CloudFormation
terraformの仕組みも一応あるが、それ以外も併せて検討しないとキツい 色々な方法で保護がかけられてナイス

Terraform

lifecycleprevent_destroytrue を指定することで、plan時にそのオブジェクトを削除するようなケースをエラーで止めることができます。

Error: Instance cannot be destroyed

  on sample.tf line 10:
  10: resource "aws_subnet" "private" {

Resource aws_subnet.private has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed. To avoid this error and continue with
the plan, either disable lifecycle.prevent_destroy or reduce the scope of the
plan using the -target flag.

ただし、この方法では対象リソースが消えるようなplanに対しては全て処理が止まるため、望んだ構成変更により副次的に対象リソースが再作成されるようなケースも、 terraform destroy で全リソースを削除するようなケースも、どちらもapplyできなくなります。
無闇に使うと満足な運用ができなくなってしまう恐れがあるため、使い所はしっかりと考えたほうが良いでしょう。

なお、このフラグが効くのはコード上リソースに対して設定されている場合のみであり、コード内の resource ブロックが丸々消えていたりすると普通に削除されます。
オペミス対策というよりは絶対に消したくないリソースを守る手段の1つと捉えたほうが正しいと思います。

CloudFormation

AWS CloudFormation スタックのリソースが削除または更新されないようにするには、どうすれば良いですか? にあるような方法でスタックやリソースに対する保護をかけることができます。

方法 効果
削除保護フラグ スタックの削除操作をブロックできる
スタックポリシー スタック内のリソースレベルで更新/削除が走るようなケースをブロックできる。スタックに対して行う設定でありCloudFormationテンプレートとは別のソースになる。ポリシーには、「このリソースは更新して良い/ダメ」みたいな規則を書いていく感じ
DeletionPolicy スタック削除時にもリソースを保持する。CloudFormationテンプレート内、各リソースのアトリビュートとして設定する

複数のレイヤで対策が行えるため安心感があって良いですね。

適用失敗時のロールバック

リソースの構成変更に失敗した場合は、自動的に元の状態に戻しておいてくれると嬉しい!

Terraform CloudFormation
×

Terraform

ロールバック機能はありません。
発生した問題を解消するか過去のバージョンのコードに戻し、再度 apply を実行する必要があります。

CloudFormation

変更失敗時は自動的に以前の状態にロールバックされます。


手動作成リソースの取り込み

管理外のリソースを生きた状態で管理下におきたいような場合に対応できるか?という話。

Terraform CloudFormation

両ツールともに、同じようなやり方で既存リソースをインポートすることができます。

Terraform

terraform import により実現できます。対応していないリソースも存在しますが、最悪tfstateをガチャガチャすればなんとかできるようです。
参考 → Terraform職人入門: 日々の運用で学んだ知見を淡々とまとめる - Terraform管理外の既存のリソースをTerraform管理下に入れる

CloudFormation

CloudFormationスタックに既存リソースをインポートする機能があります(2019/11 GA)。
参考 → Amazon Web Services ブログ - 新機能 – CloudFormation スタックへの既存リソースのインポート


環境の分離性

本番環境/ステージング環境みたいな感じの環境分離について。

Terraform CloudFormation

ちなみに当たり前の話ですが、どちらのツールでも環境間で差分となるような項目(例:VPC CIDRなど)はパラメタライズしてコードを作成し、環境ごとに別の値を使用しながらリソースを生成する必要があります。

Terraform

状態管理ファイルであるtfstateを分離することで環境分離を実現できます。
通常はディレクトリ(コード群)に対して1つのtfstateしか生成できませんが、 workspace という機能を使用することで、複数のtfstateを管理することができます。

CloudFormation

CloudFormationスタックを分離することで環境分離を実現できます。


依存関係の実現

リソース間の依存関係や初期処理の完了待ちが実装できるか。

Terraform CloudFormation
リソース間の依存関係のみ リソース間の依存関係も初期処理の完了を待つ実装もOK

Terraform

depends_on によりリソースの依存関係を表現できます。
しかし、リソース作成の依存しか表現できないので、例えばインスタンス生成後にUserDataなどで実行する初期処理の完了を待つことはできません。

CloudFormation

Terraform同様、リソース間の依存関係を実装する方法として DependsOn属性 が存在します。
これに加え、 WaitConditionCreationPolicy属性 を使うと、例えばUserDataの処理完了待ちを実現可能です。


認証、権限

リソースの作成/削除のためには非常に強い権限が必要です。基本的にはあらゆるリソースに対するフルアクセスレベルの権限が必要となるでしょう。
この動作を実現できるアカウントやアクセスキーなどの情報は流出させるわけにはいきません。その辺の話です。

Terraform CloudFormation
Terraformの世界だけでは無理!別途考慮が必要 スタックにRoleを付与する機能を使っていい感じに権限分離できる

Terraform

AWS Providerの認証 は複数の方法が用意されていますが、どれを使おうと、結局強い権限がついた認証情報をterraformコマンド実行環境に持たせる必要があることは変わりません。
よって、アクセスキーの流出対策については別途考慮する必要があります(例えば以下のような感じでしょうか)。

  • 権限をなるべく絞り、アクセスキーの管理方式を考える
  • そもそも開発者に強い権限を持たせない仕組みを作る(CI/CDパイプラインを作ってローカルではterraformコマンドを実行しない、とか)

CloudFormation

スタック作成時のユーザにCloudFormationのスタック作成権限+作成したいリソースに関する更新系の権限を持たせる以外に、スタック作成時のパラメータとしてIAM Roleを指定する方法があります。

cfn_stack_iam_role

この方法の場合ユーザに必要な権限はCloudFormation周りのみで良くなるため、かなり権限の範囲を狭く絞ることができ、Terraformと比較してセキュアになります。
参考 → いつの間にかCloudFormationがIAM Roleに対応していました!

「CloudFormationは他のAWSサービスといい感じに連携する」とか「セキュリティ周りで考えることが少ない」 というのは、この辺の話が起因しているのかもしれません。


機能追随の早さ

Terraform CloudFormation
開発が活発なOSSなので素早く実装される AWSのサービスなので素早く実装される

Terraform

現状、AWSに新機能が追加されたときの機能追随は十分素早く行われていますが、OSS故に状況は変わる可能性があります。もちろん機能追随の早さが期待するレベルにならない可能性もあるでしょう。OSSであるTerraformを扱う上で重要なことは、そのような変化を許容しなければならない認識を持つことです。
ちなみにあきらめる以外の方法として、自身やチームのコントリビュートによって期待するレベルを担保するという選択肢もあります。

CloudFormation

CloudFormationはAWSのサービス故、素早い機能追随と安定した開発が見込めます。
新機能実装時、たまにCloudFormationがサポートされていないことがある点には注意が必要です。


マルチクラウド性

Terraform CloudFormation
×

Terraform

同じファイルやフォルダ内で複数のリソースを管理できます。
例えばAWSとAzureを併用するマルチクラウドなシステムに対して、以下のようにまとめてコードを記述でき、Terraformという一元的な手法でリソースの管理を行うことができます。

# AWS
provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_vpc" "main" {
  cidr_block = "10.2.0.0/24"
  enable_dns_hostnames = true
}

resource "aws_subnet" "private" {
  vpc_id = aws_vpc.main.id
  cidr_block = "10.2.0.0/25"
  map_public_ip_on_launch = false
}

resource "aws_instance" "sample" {
  ami = "ami-0a1c2ec61571737db"
  subnet_id = aws_subnet.private.id
  key_name = "test"
  instance_type = "t2.micro"

  lifecycle {
    ignore_changes = [
      tags["Name"],
    ]
  }
}

# Azure
provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "test" {
  name = "test-resources"
  location = "japaneast"
}

resource "azurerm_virtual_network" "private" {
  name = "test-network-private"
  resource_group_name = azurerm_resource_group.test.name
  location = azurerm_resource_group.test.location
  address_space = ["10.2.0.0/25"]
}

ただし、もちろんAWSにリソースを作成するコードとAzureにリソースを作成するコードは別物になります。「Terraformを使えば(何もしなくても)マルチクラウドに対応できる」とかいううまい話はありません。AWSに対してはAWSのリソースを作成するためのコードを書く必要があるし、Azureに対してはAzureのリソースを作成するためのコードを書く必要があります。

なおTerraformはパブリッククラウド以外にVMwareやk8s、Openstackなどの環境も扱うことができますので、ハイブリッドクラウドなシステムに対しても有効性を発揮します。

CloudFormation

AWSにベンダロックインします。AWS以外のリソースの作成を行うことはできません。
※ 一応カスタムリソースなるものもあるようですが、そんな実装をするくらいだったら別のツールを使ったほうが良いでしょう


ドキュメントや情報の充実度

Terraform CloudFormation
公式な情報は英語 日本語情報でも充実っぷりがすごい

基本的にどちらも有名なツールなので、非公式な情報(ブログなど)は非常に多いです。大体ググればなんとかなります。ただしAWSサービスはそもそもアップデートが早いので、そのままだと動かないコードも結構遭遇したりします。
よって安定した情報である公式ドキュメントと併せて情報収集することが多くなるでしょう。ここには差分があります。

Terraform

公式ドキュメントがありますが英語のみです。

CloudFormation

公式ドキュメントが日本語にローカライズされています。
サービス名 Cloudformation でググればすぐ公式ドキュメントが出てきます。
ために日本語ローカライズ版は情報が古かったりするので、英語版を読まないといけない時もある点は注意です。


文法の理解のしやすさ

Terraform CloudFormation
HCL yaml or json

基本的な文法については細かい書き方は違えど学習コストに差異があるようなものではありません。
リソース作成時に設定するパラメータ(EC2インスタンスなら例えばSubnet IDやAMI IDなどの指定が必要、とか)を調べて記述するのがコーディングのメイン作業となりますが、それはどちらにせよやらなければならないことです。

なので、文法の学習コストをキーに「こっちの方が良い!」を決めるのは正直おすすめしません。

Terraform

HCL(Hashicorp Configuration Language)というDSLを用いてコードを記述します。
Hashicorp社の独自文法で、Terraformだけでなく同社の他SWでも使用します。

# comment
<type> <labels>... {
  <key> = <value>

  # nested block
  <type> <labels>... {
    # ...
  }
}

基本的に↑のようなブロックを列挙していく文法です。

また、3項演算子やループなどの、少々高度な文法もあったりします。

CloudFormation

jsonまたはyamlでコードを記述します(yamlの方が書きやすいと思うので本エントリではyamlを使用しています)。

# comment
- <key>:
    <key>: <value>
    <key>:
      - <value1>
      - <value2>
      - <value3>

基本的に↑のようなブロックを列挙していく文法です。

参考 → Ansible Documentation - YAML Syntax

文法レベルで繰り返しなどの制御構文は無いはずです。
マクロを使うと色々とできるみたいです(使ったことはない)。
参考 → AWS CloudFormation のマクロ機能を使ってみた


コードの記述量

Terraform CloudFormation

若干CloudFormationの方が長くなると思います。
例として、VPCとEC2インスタンスを作成するコードを並べてみます。

Terraform

provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_vpc" "main" {
  cidr_block = "10.2.0.0/24"
  enable_dns_hostnames = true
}

resource "aws_subnet" "private" {
  vpc_id = aws_vpc.main.id
  cidr_block = "10.2.0.0/25"
  map_public_ip_on_launch = false
}

resource "aws_instance" "sample" {
  ami = "ami-0a1c2ec61571737db"
  subnet_id = aws_subnet.private.id
  key_name = "test"
  instance_type = "t2.micro"
}

22行、419文字

  • AWS Providerの利用宣言を行う (1〜3行目)
  • resource "aws_vpc" "main" のように、リソース定義(種別を含む)と論理名を同じ行に書ける
  • リソースに紐づくパラメータは {} 内に表現される
  • リソースの参照は <モジュール名>.<論理名>.<属性> で行うため、論理名にはリソース種別を識別できる情報を盛り込まなくて良い ( main_vpc のようにしなくて良い)
  • スネークケースな感じ

CloudFormation

---
AWSTemplateFormatVersion: 2010-09-09

Resources:
  mainVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.2.1.0/24
      EnableDnsHostnames: true

  privateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref mainVpc
      CidrBlock: 10.2.1.0/25
      MapPublicIpOnLaunch: false

  sampleInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-0a1c2ec61571737db
      SubnetId: !Ref privateSubnet
      KeyName: test
      InstanceType: t2.micro

25行、498文字

  • キー Type: に記載する値は、AWSリソースの場合 AWS:: が付くことになっていて若干冗長
  • リソースに紐づくパラメータは Properties: の要素として記述される
  • リソースの参照は !Ref <論理名> で行うため、論理名にはリソース種別を識別できる情報を盛り込まないとメンテナンス性が下がる
  • キャメルケースな感じ

ファイル分割の柔軟性

見通しが良いコードを書くには、リソースの分割(どのファイルにどのリソースを書くか)を柔軟に決定できる必要があります。

Terraform CloudFormation
そもそも分割が許容されているSW設計 やれないことはないが制約が多い

Terraform

Terraformはカレントディレクトリをスキャンし、 .tf という拡張子が付いているファイルを全て実行対象とします。
よって、リソースをまとめて書こうが、ファイルを分割して書こうが、実行内容は同じになります。ファイル間の紐付きをコード上表現する必要はありません。実行時に一緒にロードされれば(つまり同じディレクトリに格納されていれば)、どのファイルに書いてあっても良いのです。
これはTerraformの素晴らしい点であり、例えば後からリソースの記述場所を変更したい場合でも、対象リソースに関する記述を目的のファイルに移動するだけで達成することができます。

上述の、VPCとEC2インスタンスを作成するコードは、以下のように分割できます。
※ 単にファイルが分かれただけで、コードの総量は変化していないことに着目して下さい

  • provider.tf

    provider "aws" {
      region = "ap-northeast-1"
    }
    
  • network.tf

    resource "aws_vpc" "main" {
      cidr_block = "10.2.0.0/24"
      enable_dns_hostnames = true
    }
    
    resource "aws_subnet" "private" {
      vpc_id = aws_vpc.main.id
      cidr_block = "10.2.0.0/25"
      map_public_ip_on_launch = false
    }
    
    resource "aws_instance" "sample" {
      ami = "ami-0a1c2ec61571737db"
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t2.micro"
    }
    
  • compute.tf

    resource "aws_instance" "sample" {
      ami = "ami-0a1c2ec61571737db"
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t2.micro"
    }
    

CloudFormation

CloudFormationがスタックを作成するときに受け付けるテンプレートは1つです。Terraformの様に直接複数ファイルを扱うことができません。
ではどのようにテンプレートを分割するの?と思いますが、あるスタックのリソースを他のスタックから参照させる仕組みを使うことで実現できます。
実装パターンとしては以下の2つの方法があります。

クロススタック参照

クロススタック参照とは、別のCloudFormationスタックのリソースにおける「出力」を参照するCloudFormationの機能です。この機能により、1つのスタックに全てのリソースを入れることなくシステムを構成できます。

以下のように Export フィールドを使って出力の値を設定し、CloudFormationの組み込み関数である ImportValue を使ってその値をインポートします。

  • コード

    • network.cfn.yml

      ---
      AWSTemplateFormatVersion: 2010-09-09
      
      Resources:
        mainVpc:
          Type: AWS::EC2::VPC
          Properties:
            CidrBlock: 10.2.1.0/24
            EnableDnsHostnames: true
      
        privateSubnet:
          Type: AWS::EC2::Subnet
          Properties:
            VpcId: !Ref mainVpc
            CidrBlock: 10.2.1.0/25
            MapPublicIpOnLaunch: false
      
      ### ここでExport ###
      Outputs:
        privateSubnet:
          Value: !Ref privateSubnet
          Export:
            Name: test-PrivateSubnet
      
      
    • compute.cfn.yml

      ---
      AWSTemplateFormatVersion: 2010-09-09
      
      Resources:
        sampleInstance:
          Type: AWS::EC2::Instance
          Properties:
            ImageId: ami-0a1c2ec61571737db
            ### ここでImport ###
            SubnetId: !ImportValue test-PrivateSubnet
            KeyName: test
            InstanceType: t2.micro
      
  • デプロイ
    以下のように、テンプレートごとにデプロイを実施します。

    $ aws cloudformation deploy --template-file network.cfn.yml --stack-name test-stack-network
    $ aws cloudformation deploy --template-file compute.cfn.yml --stack-name test-stack-compute
    
  • 結果
    test-stack-network
    test-stack-compute
    export

  • 注意点

    • テンプレートごとにスタックのデプロイを行う必要があります
    • スタックの作成/削除の順序に制約が発生します
      • 参照したいExportが存在しないとスタック作成に失敗するし、参照されているExportがあるスタックは削除に失敗します
      • 機能追加等の際はこのあたりも気にして実装、反映させる必要があります
        • そのため、単純に「このスタックだけ再作成すりゃええやん」とならず、思ったより色々なスタックを再作成しないといけなくなったりします
        • この制約はクロススタック参照を使う限り一生発生し、参照の数が増えるにつれて管理がどんどん難しくなります
    • Export.Name は重複不可です、重複してるとデプロイ時にこけます
スタックのネスト

Type: AWS::CloudFormation::Stack を用いることで、あるスタックから別のスタックを生成し、スタックをネストさせることができます。
参考 → CloudFormationでスタックをネストする

スタックをネストさせる場合、リソース参照にはクロススタック参照(Export)を使わなくても良く、以下のコードのようにスタックのOutputを親テンプレートで参照することで実現します。

  • コード

    • main.cfn.yml (親テンプレート)

      ---
      AWSTemplateFormatVersion: 2010-09-09
      
      Resources:
        networkStack:
          Type: AWS::CloudFormation::Stack
          Properties:
            TemplateURL: ./network.cfn.yml
      
        computeStack:
          Type: AWS::CloudFormation::Stack
          Properties:
            TemplateURL: ./compute.cfn.yml
            Parameters:
              ### GetAttを使って、network.cfn.ymlで定義したOutputの情報を引っ張ってパラメータ渡し ###
              SubnetId: !GetAtt networkStack.Outputs.privateSubnet
      

      ここに記載するスタックの順序は重要ではありません。
      computeStacknetworkStack に依存しますが、逆順に書いても問題なく動作します。

    • network.cfn.yml

      ---
      AWSTemplateFormatVersion: 2010-09-09
      
      Resources:
        mainVpc:
          Type: AWS::EC2::VPC
          Properties:
            CidrBlock: 10.2.1.0/24
            EnableDnsHostnames: true
      
        privateSubnet:
          Type: AWS::EC2::Subnet
          Properties:
            VpcId: !Ref mainVpc
            CidrBlock: 10.2.1.0/25
            MapPublicIpOnLaunch: false
      
      Outputs:
        privateSubnet:
          Value: !Ref privateSubnet
      
    • compute.cfn.yml

      ---
      AWSTemplateFormatVersion: 2010-09-09
      
      ### 親テンプレートから渡されるパラメータを定義しておく
      Parameters:
        SubnetId:
          Type: String
      
      Resources:
        sampleInstance:
          Type: AWS::EC2::Instance
          Properties:
            ImageId: ami-0a1c2ec61571737db
            SubnetId: !Ref SubnetId
            KeyName: test
            InstanceType: t2.micro
      
  • デプロイ
    aws cloudformation package コマンドにより、ネストされたスタックを用いるCloudFormationテンプレートのパッケージングを行うことができます。

    コマンドを実行すると、以下の処理が自動的に実行されます。

    • ネストされるスタックのテンプレートをS3にアップロードする
    • 親テンプレートでローカル参照していた部分がS3のURLに置き換わったファイルを標準出力する
    $ aws cloudformation package --template ./main.cfn.yml --s3-bucket test-cloudformation-package --output json > packaged-template.yml
    
    • packaged-template.yml (上記コマンドの標準出力)

      AWSTemplateFormatVersion: 2010-09-09
      Resources:
        networkStack:
          Type: AWS::CloudFormation::Stack
          Properties:
            TemplateURL: https://s3.ap-northeast-1.amazonaws.com/test-cloudformation-package/c8c81c7010587919f6d3a354a7f3f8c2.template
        computeStack:
          Type: AWS::CloudFormation::Stack
          Properties:
            TemplateURL: https://s3.ap-northeast-1.amazonaws.com/test-cloudformation-package/c8c81c7010587919f6d3a354a7f3f8c2.template
            Parameters:
              SubnetId:
                Fn::GetAtt:
                - networkStack
                - Outputs.privateSubnet
      

    このテンプレートからスタックの生成を行います。
    クロススタック参照の時とは異なり、デプロイ対象のテンプレートは1つとなります。

    $ aws cloudformation deploy --template-file packaged-template.yml --stack-name test-stack-all
    
  • 結果
    test-stack-all
    test-stack-all-networkStack
    test-stack-all-computeStack

    スタックがネストされていることがコンソール上で分かります。
    親スタックの削除を行うと、子スタックも一緒に削除されます。

  • 注意点

    • デプロイのために追加のS3が必要になります
    • ある値(VPC CIDRなど)をパラメータ化したいとき、以下の箇所にパラメータに関する記述を行う必要があり、実装時に結構面倒な思いをします。
      • 上位テンプレートの Parameters
      • 下位テンプレートの Parameters
      • AWS::CloudFormation::Stack リソースの Properties.Paramters
    • ChangeSetは作成したところで子スタックの変更内容が全く分からず使い物になりません
    • デプロイエラー発生時、どこで何が起きているのか分かりにくいためトラブルシュートしづらいです

...
といった感じで、ファイル分割に対してCloudFormationは意外と弱いです。
「CloudFormationは最初に設計が必要」とよく言われるのは、この辺が主な理由かと思います。


モジュール化

実装した一部のコード(あるいは誰かが作って公開したコード)の再利用ができるか。

Terraform CloudFormation
ネイティブサポート、公式モジュールも存在 スタックのネストで実現できる

Terraform

複数リソースをひとまとめにする機能として、 module があります。
再利用性の高いリソースのセットをまとめておき、複数の箇所から呼び出すことができます。
Terraform Providerが公開しているモジュールのレジストリ も存在します。

CloudFormation

上述した、スタックのネスト機能を使えば実現できますが、発生する課題も同様です。
むしろ再利用したいリソースのまとまりを1つのテンプレートに切り出すことになるため細分化の単位が小さくなり、スタック間の依存関係の複雑化やパラメータ伝搬数の増加が発生し、デメリットが色濃く現れることになります。


コードバリデーション

syntax checkやlintingなど。

Terraform CloudFormation
標準のvalidateとOSSのlinterを併用するといい感じ 標準機能はゴミ。cfn-lintを使おう

Terraform

標準機能として validate が用意されていたり、 tflint というツールがあったりします。
これらのツールを使って、実際にいくつかのパターンでNG検出を試してみると以下のような結果になります。

  • リソースの重複

    resource "aws_instance" "sample" {
      ami = "ami-0a1c2ec61571737db"
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t3.micro"
    }
    
    # sample という名前のリソースが重複している
    resource "aws_instance" "sample" {
      ami = "ami-0a1c2ec61571737db"
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t3.micro"
    }
    
    • ○(検出可) terraform validate

      $ terraform validate
      
      Error: Duplicate resource "aws_instance" configuration
      
        on compute.tf line 8:
         8: resource "aws_instance" "sample" {
      
      A aws_instance resource named "sample" was already declared at
      compute.tf:1,1-33. Resource names must be unique per type in each module.
      
    • tflint

      $ docker run --rm -v (pwd):/data -t wata727/tflint
      Failed to load configurations. 1 error(s) occurred:
      
      Error: Duplicate resource "aws_instance" configuration
      
        on compute.tf line 9, in resource "aws_instance" "sample":
         9: resource "aws_instance" "sample" {
      
      A aws_instance resource named "sample" was already declared at compute.tf:1,1-33. Resource names must be unique per type in each module.
      
  • 必須パラメータの指定なし

    resource "aws_instance" "sample" {
      # ami = "ami-0a1c2ec61571737db"  # 必須パラメータであるAMI IDを与えない
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t3.micro"
    }
    
    • terraform validate

      $ terraform validate
      
      Error: Missing required argument
      
        on compute.tf line 1, in resource "aws_instance" "sample":
         1: resource "aws_instance" "sample" {
      
      The argument "ami" is required, but no definition was found.
      
    • × tflint

      $ docker run --rm -v (pwd):/data -t wata727/tflint
      #=> 出力無し(返り値0)
      
  • 存在しないパラメータを与える

    resource "aws_instance" "sample" {
      ami = "ami-0a1c2ec61571737db"
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t3.micro"
    
      hogehoge = "fugafuga"  # 存在しないパラメータを指定している
    }
    
    • terraform validate

      $ terraform validate
      
      Error: Unsupported argument
      
        on compute.tf line 7, in resource "aws_instance" "sample":
         7:   hogehoge = "fugafuga"  # 存在しないパラメータ
      
      An argument named "hogehoge" is not expected here.
      
    • × tflint

      $ docker run --rm -v (pwd):/data -t wata727/tflint
      #=> 出力無し(返り値0)
      
  • パラメータの内容誤り

    resource "aws_instance" "sample" {
      ami = "ami-0a1c2ec61571737db"
      subnet_id = aws_subnet.private.id
      key_name = "test"
      instance_type = "t3x.micro"  # t3x.micro というインスタンスタイプは存在しない
    }
    
    • × terraform validate

      $ terraform validate
      Success! The configuration is valid.
      
    • tflint

      $ docker run --rm -v (pwd):/data -t wata727/tflint
      1 issue(s) found:
      
      Error: "t3x.micro" is an invalid value as instance_type (aws_instance_invalid_type)
      
        on compute.tf line 5:
         5:   instance_type = "t3x.micro"  # t3x.micro というインスタンスタイプは存在しない
      

お互いに検出領域に違いがあることが分かるので、これらは併用するのが良さそうです。

CloudFormation

CloudFormationにも標準機能として、 validate があります。
また、 cfn-python-lint というlinterもあります。
※ js製の cfn-lint もあるようですが、全く別物らしいです

ではこちらも上述のパターンで実際にNG検出を試してみます。cfn-lintには、開発がより活発であるcfn-python-lintの方を使うことにします。

  • リソースの重複

    ---
    AWSTemplateFormatVersion: 2010-09-09
    
    Resources:
      sampleInstance:
        Type: AWS::EC2::Instance
        Properties:
          ImageId: ami-0a1c2ec61571737db
          SubnetId: !ImportValue test-PrivateSubnet
          KeyName: test
          InstanceType: t2.micro
      # sampleInstance という名前のリソースが重複している
      sampleInstance:
        Type: AWS::EC2::Instance
        Properties:
          ImageId: ami-0a1c2ec61571737db
          SubnetId: !ImportValue test-PrivateSubnet
          KeyName: test
          InstanceType: t2.micro
    
    • × cloudformation validate

      $ aws cloudformation validate-template --template-body file://./compute.cfn.yml
      {
          "Parameters": []
      }
      
    • cfn-lint

      $ cfn-lint compute.cfn.yml
      E0000 Duplicate resource found "sampleInstance" (line 12)
      compute.cfn.yml:12:3
      
  • 必須パラメータの指定なし

    ---
    AWSTemplateFormatVersion: 2010-09-09
    
    Resources:
      sampleInstance:
        Type: AWS::EC2::Instance
        Properties:
          # ImageId: ami-0a1c2ec61571737db  # 必須パラメータであるAMI IDを与えない
          SubnetId: !ImportValue test-PrivateSubnet
          KeyName: test
          InstanceType: t2.micro
    
    • × cloudformation validate

      $ aws cloudformation validate-template --template-body file://./compute.cfn.yml
      {
          "Parameters": []
      }
      
    • cfn-lint

      $ cfn-lint compute.cfn.yml
      E2522 At least one of [ImageId, LaunchTemplate] should be specified for Resources/sampleInstance/Properties
      compute.cfn.yml:7:5
      
  • 存在しないパラメータを与える

    ---
    AWSTemplateFormatVersion: 2010-09-09
    
    Resources:
      sampleInstance:
        Type: AWS::EC2::Instance
        Properties:
          ImageId: ami-0a1c2ec61571737db
          SubnetId: !ImportValue test-PrivateSubnet
          KeyName: test
          InstanceType: t2.micro
    
          HogeHoge: fugafuga  # 存在しないパラメータを指定している
    
    • × cloudformation validate

      $ aws cloudformation validate-template --template-body file://./compute.cfn.yml
      {
          "Parameters": []
      }
      
    • cfn-lint

      $ cfn-lint compute.cfn.yml
      E3002 Invalid Property Resources/sampleInstance/Properties/HogeHoge
      compute.cfn.yml:13:7
      
  • パラメータの内容誤り

    ---
    AWSTemplateFormatVersion: 2010-09-09
    
    Resources:
      sampleInstance:
        Type: AWS::EC2::Instance
        Properties:
          ImageId: ami-0a1c2ec61571737db
          SubnetId: !ImportValue test-PrivateSubnet
          KeyName: test
          InstanceType: t3x.micro  # t3x.micro というインスタンスタイプは存在しない
    
    • × cloudformation validate

      $ aws cloudformation validate-template --template-body file://./compute.cfn.yml
      {
          "Parameters": []
      }
      
    • cfn-lint

      $ cfn-lint compute.cfn.yml
      E3030 You must specify a valid value for InstanceType (t3x.micro).
      Valid values are ["a1.2xlarge", "a1.4xlarge", "a1.large", "a1.medium", "a1.metal", "a1.xlarge", "c1.medium", "c1.xlarge", "c3.2xlarge", "c3.4xlarge", "c3.8xlarge", "c3.large", "c3.xlarge", "c4.2xlarge", "c4.4xlarge", "c4.8xlarge", "c4.large", "c4.xlarge", "c5.12xlarge", "c5.18xlarge", "c5.24xlarge", "c5.2xlarge", "c5.4xlarge", "c5.9xlarge", "c5.large", "c5.metal", "c5.xlarge", "c5a.12xlarge", "c5a.16xlarge", "c5a.24xlarge", "c5a.2xlarge", "c5a.4xlarge", "c5a.8xlarge", "c5a.large", "c5a.xlarge", "c5d.12xlarge", "c5d.18xlarge", "c5d.24xlarge", "c5d.2xlarge", "c5d.4xlarge", "c5d.9xlarge", "c5d.large", "c5d.metal", "c5d.xlarge", "c5n.18xlarge", "c5n.2xlarge", "c5n.4xlarge", "c5n.9xlarge", "c5n.large", "c5n.metal", "c5n.xlarge", "c6g.12xlarge", "c6g.16xlarge", "c6g.2xlarge", "c6g.4xlarge", "c6g.8xlarge", "c6g.large", "c6g.medium", "c6g.metal", "c6g.xlarge", "cc2.8xlarge", "cr1.8xlarge", "d2.2xlarge", "d2.4xlarge", "d2.8xlarge", "d2.xlarge", "f1.16xlarge", "f1.2xlarge", "f1.4xlarge", "g2.2xlarge", "g2.8xlarge", "g3.16xlarge", "g3.4xlarge", "g3.8xlarge", "g3s.xlarge", "g4dn.12xlarge", "g4dn.16xlarge", "g4dn.2xlarge", "g4dn.4xlarge", "g4dn.8xlarge", "g4dn.metal", "g4dn.xlarge", "h1.16xlarge", "h1.2xlarge", "h1.4xlarge", "h1.8xlarge", "hs1.8xlarge", "i2.2xlarge", "i2.4xlarge", "i2.8xlarge", "i2.xlarge", "i3.16xlarge", "i3.2xlarge", "i3.4xlarge", "i3.8xlarge", "i3.large", "i3.metal", "i3.xlarge", "i3en.12xlarge", "i3en.24xlarge", "i3en.2xlarge", "i3en.3xlarge", "i3en.6xlarge", "i3en.large", "i3en.metal", "i3en.xlarge", "inf1.24xlarge", "inf1.2xlarge", "inf1.6xlarge", "inf1.xlarge", "m1.large", "m1.medium", "m1.small", "m1.xlarge", "m2.2xlarge", "m2.4xlarge", "m2.xlarge", "m3.2xlarge", "m3.large", "m3.medium", "m3.xlarge", "m4.10xlarge", "m4.16xlarge", "m4.2xlarge", "m4.4xlarge", "m4.large", "m4.xlarge", "m5.12xlarge", "m5.16xlarge", "m5.24xlarge", "m5.2xlarge", "m5.4xlarge", "m5.8xlarge", "m5.large", "m5.metal", "m5.xlarge", "m5a.12xlarge", "m5a.16xlarge", "m5a.24xlarge", "m5a.2xlarge", "m5a.4xlarge", "m5a.8xlarge", "m5a.large", "m5a.xlarge", "m5ad.12xlarge", "m5ad.24xlarge", "m5ad.2xlarge", "m5ad.4xlarge", "m5ad.large", "m5ad.xlarge", "m5d.12xlarge", "m5d.16xlarge", "m5d.24xlarge", "m5d.2xlarge", "m5d.4xlarge", "m5d.8xlarge", "m5d.large", "m5d.metal", "m5d.xlarge", "m5dn.12xlarge", "m5dn.16xlarge", "m5dn.24xlarge", "m5dn.2xlarge", "m5dn.4xlarge", "m5dn.8xlarge", "m5dn.large", "m5dn.metal", "m5dn.xlarge", "m5n.12xlarge", "m5n.16xlarge", "m5n.24xlarge", "m5n.2xlarge", "m5n.4xlarge", "m5n.8xlarge", "m5n.large", "m5n.metal", "m5n.xlarge", "m6g.12xlarge", "m6g.16xlarge", "m6g.2xlarge", "m6g.4xlarge", "m6g.8xlarge", "m6g.large", "m6g.medium", "m6g.metal", "m6g.xlarge", "p2.16xlarge", "p2.8xlarge", "p2.xlarge", "p3.16xlarge", "p3.2xlarge", "p3.8xlarge", "p3dn.24xlarge", "r3.2xlarge", "r3.4xlarge", "r3.8xlarge", "r3.large", "r3.xlarge", "r4.16xlarge", "r4.2xlarge", "r4.4xlarge", "r4.8xlarge", "r4.large", "r4.xlarge", "r5.12xlarge", "r5.16xlarge", "r5.24xlarge", "r5.2xlarge", "r5.4xlarge", "r5.8xlarge", "r5.large", "r5.metal", "r5.xlarge", "r5a.12xlarge", "r5a.16xlarge", "r5a.24xlarge", "r5a.2xlarge", "r5a.4xlarge", "r5a.8xlarge", "r5a.large", "r5a.xlarge", "r5ad.12xlarge", "r5ad.24xlarge", "r5ad.2xlarge", "r5ad.4xlarge", "r5ad.large", "r5ad.xlarge", "r5d.12xlarge", "r5d.16xlarge", "r5d.24xlarge", "r5d.2xlarge", "r5d.4xlarge", "r5d.8xlarge", "r5d.large", "r5d.metal", "r5d.xlarge", "r5dn.12xlarge", "r5dn.16xlarge", "r5dn.24xlarge", "r5dn.2xlarge", "r5dn.4xlarge", "r5dn.8xlarge", "r5dn.large", "r5dn.metal", "r5dn.xlarge", "r5n.12xlarge", "r5n.16xlarge", "r5n.24xlarge", "r5n.2xlarge", "r5n.4xlarge", "r5n.8xlarge", "r5n.large", "r5n.metal", "r5n.xlarge", "r6g.12xlarge", "r6g.16xlarge", "r6g.2xlarge", "r6g.4xlarge", "r6g.8xlarge", "r6g.large", "r6g.medium", "r6g.metal", "r6g.xlarge", "t1.micro", "t2.2xlarge", "t2.large", "t2.medium", "t2.micro", "t2.nano", "t2.small", "t2.xlarge", "t3.2xlarge", "t3.large", "t3.medium", "t3.micro", "t3.nano", "t3.small", "t3.xlarge", "t3a.2xlarge", "t3a.large", "t3a.medium", "t3a.micro", "t3a.nano", "t3a.small", "t3a.xlarge", "u-12tb1.metal", "u-18tb1.metal", "u-24tb1.metal", "u-6tb1.metal", "u-9tb1.metal", "x1.16xlarge", "x1.32xlarge", "x1e.16xlarge", "x1e.2xlarge", "x1e.32xlarge", "x1e.4xlarge", "x1e.8xlarge", "x1e.xlarge", "z1d.12xlarge", "z1d.2xlarge", "z1d.3xlarge", "z1d.6xlarge", "z1d.large", "z1d.metal", "z1d.xlarge"]
      compute.cfn.yml:11:7
      

…と、正直標準機能のvalidateはsyntaxレベルまでしか見ていないようなので使い物にならない気がします。
その代わり(?)、cfn-lintは優秀っぽいのでそちらを使いましょう。


参考

本エントリを記載するにあたってとんでもなく参考にさせていただいた情報です。先人たちのアウトプットに感謝します。

273
165
1

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
273
165

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?