エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Terraformの謎のdiffと戦っている話

この記事はエムスリー SRE がお届けするブログリレーの4日目です。

こんにちは。マルチデバイスチームでチーム内SREを担当している大和です。 普段はアプリ向けのAPIサーバについてJava/KotlinおよびTerraformでアプリとインフラを構築しています。

Terraformあるあるだと思うのですが、ときどき意図しないdiffが出てきてドキドキすることがあります。 今回は解決のために terraform-provider-aws のコードを読んでdiffが出てきた原因を確認した例について、簡単に紹介したいと思います。

(チーム内SREについては次の記事に説明があります: SREの民主化とクラウド移行 - エムスリーテックブログ)

f:id:daiwa_home:20210115151049p:plain
謎のdiffと戦うエンジニアのイメージ (画像を提供していただいた山本さんの記事はこちら: SREを麻雀に例えたら(哭き派とメンチン派の争い) - エムスリーテックブログ)

注意

以下はterraform-provider-aws 3.0より前のversionで発生していた問題です (現在は解決済み)。

あらまし

私が所属するチームでは、APIサーバで使用する証明書の管理に aws_acm_certificate を使用しています。 このAPIサーバは開発および検証用に複数環境用意するので、 subject_alternative_names (SAN) に複数ドメインを設定しています *1

resource aws_acm_certificate https_certificate {
  domain_name               = "example.com"
  subject_alternative_names = ["api1.example.com", "api2.example.com", "api3.example.com"]
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

この設定でしばらく運用していましたが、たまに謎のdiffが出てしまうことがありました。

+/- resource "aws_acm_certificate" "https_certificate" {
      ~ arn                       = "arn:aws:acm:ap-northeast-1:[AWS Account ID]:certificate/foo" -> (known after apply)
        domain_name               = "example.com"
      ~ domain_validation_options = [
          - {
              - domain_name           = "api3.example.com"
              - resource_record_name  = "_foo.api3.example.com."
              - resource_record_type  = "CNAME"
              - resource_record_value = "_hoge.acm-validations.aws."
            },
          - {
              - domain_name           = "api2.example.com"
              - resource_record_name  = "_bar.api2.example.com."
              - resource_record_type  = "CNAME"
              - resource_record_value = "_fuga.acm-validations.aws."
            },
          - {
              - domain_name           = "api1.example.com"
              - resource_record_name  = "_baz.api1.example.com."
              - resource_record_type  = "CNAME"
              - resource_record_value = "_piyo.acm-validations.aws."
            },
        ] -> (known after apply)
      ~ id                        = "arn:aws:acm:ap-northeast-1:[AWS Account ID]:certificate/foo" -> (known after apply)
      ~ status                    = "ISSUED" -> (known after apply)
      ~ subject_alternative_names = [ # forces replacement
          + "api1.example.com",
          + "api2.example.com",
            "api3.example.com",
          - "api2.example.com",
          - "api1.example.com",
        ]
      ~ validation_emails         = [] -> (known after apply)
        validation_method         = "DNS"
      - options {
          - certificate_transparency_logging_preference = "ENABLED" -> null
        }
    }

-/+ resource "aws_acm_certificate_validation" "cert" {
      ~ certificate_arn         = "arn:aws:acm:ap-northeast-1:[AWS Account ID]:certificate/foo" -> (known after apply) # forces replacement
      ~ id                      = "2020-01-01 00:00:00 +0000 UTC" -> (known after apply)
      ~ validation_record_fqdns = [
          - "_bar.api2.example.com",
          - "_..example.com",
          - "_baz.api3.example.com",
          - "_foo.api1.example.com",
        ] -> (known after apply) # forces replacement
    }

-/+ resource "aws_route53_record" "cert_validation" {
        allow_overwrite = true
      ~ fqdn            = "_baz.api3.example.com" -> (known after apply)
      ~ id              = "_.api3.example.com._CNAME" -> (known after apply)
      ~ name            = "_baz.api3.example.com" -> (known after apply) # forces replacement
      ~ records         = [
          - "_piyo.acm-validations.aws.",
        ] -> (known after apply)
        ttl             = 60
      ~ type            = "CNAME" -> (known after apply)
        zone_id         = "zone"
    }
  :
  :

何度か出ているdiffを確認してみると、 subject_alternative_names が昇順になっていないときに出ていることがわかりました。 どうやら subject_alternative_names はlistで管理されており、何らかの理由で順序が変わるとdiffがある判定になるみたいです。

呼び出しているAPIを確認してみる

原因はおそらく subject_alternative_names がランダムで返ってくることだという推測はできますが、次にこれが本当かどうか検証しました。 取得するためにはAWSのAPIを実行しているはずで、それは terraform-provider-aws にあると考えられるのでAPI呼び出し部分を見てみます *2

aws_acm_certificate で検索してみるとそれっぽいファイルが出てきます (ファイルの命名規則が {data,resource}_resource_name.go になっていて一目瞭然でした)。

https://github.com/hashicorp/terraform-provider-aws/blob/v2.70.0/aws/resource_aws_acm_certificate.go

func resourceAwsAcmCertificate() *schema.Resource {
    return &schema.Resource{
        Create: resourceAwsAcmCertificateCreate,
        Read:   resourceAwsAcmCertificateRead,
        Update: resourceAwsAcmCertificateUpdate,
        Delete: resourceAwsAcmCertificateDelete,
        Importer: &schema.ResourceImporter{
            State: schema.ImportStatePassthrough,
        },
        Schema: map[string]*schema.Schema{
            "certificate_body": {
                Type:      schema.TypeString,
                Optional:  true,
                StateFunc: normalizeCert,
            },
                        // 以下はschema定義
                           :
            "tags": tagsSchema(),
        },
    }
}

resourceAwsAcmCertificate でresourceが定義されていますが、その中で今回見るべきはAPIから情報を取得する Read: resourceAwsAcmCertificateRead, の処理の部分です。

func resourceAwsAcmCertificateRead(d *schema.ResourceData, meta interface{}) error {
    acmconn := meta.(*AWSClient).acmconn
    ignoreTagsConfig := meta.(*AWSClient).IgnoreTagsConfig

    params := &acm.DescribeCertificateInput{
        CertificateArn: aws.String(d.Id()),
    }

    return resource.Retry(AcmCertificateDnsValidationAssignmentTimeout, func() *resource.RetryError {
        resp, err := acmconn.DescribeCertificate(params)

        if err != nil {
            if isAWSErr(err, acm.ErrCodeResourceNotFoundException, "") {
                d.SetId("")
                return nil
            }
            return resource.NonRetryableError(fmt.Errorf("Error describing certificate: %s", err))
        }

        d.Set("domain_name", resp.Certificate.DomainName)
        // 以下はresponseをResourceDataに設定しているコード
                   :
        return nil
    })
}

この部分ではAWS SDK経由でAPI ( acmconn.DescribeCertificate ) を実行し、 schema.ResourceData に詰め込んでいます。 APIを特定できたので、このAPIをAWS CLIで実行してみます *3

$ aws acm describe-certificate --certificate-arn arn:aws:acm:ap-northeast-1:[AWS Account ID]:certificate/foo | jq .Certificate.SubjectAlternativeNames
[
  "example.com",
  "api3.example.com",
  "api2.example.com",
  "api1.example.com"
]

何回か実行してみると、 SubjectAlternativeNames の順序は不定であることがわかります。

解決方法

原因はわかりましたが、これはterraformの記述の工夫で解決できる問題なのでしょうか? AWSのサポートに問い合わせた人によると順序が保証できないとサポートから回答があり、list形式の順序は無視できないため解決できませんでした。

https://github.com/hashicorp/terraform-provider-aws/issues/8531#issuecomment-493066917

私のチームでは本番環境に影響がないこと (環境が1つなため)、および terraform-provider-aws で修正予定とのことだったのでリリースを待つことにしました。

なお、現在は3.0で修正がリリースされており、versionを上げることで解決しました *4。 もし同じ問題に悩まされている方は terraform-provider-aws のversionを見直してみてください。

まとめ

aws_acm_certificate での例を通して terraform-provider-aws がどのAPIを実行しているかについて簡単ですが確認する方法を紹介しました。 問題の原因がterraformもしくはAWSにあるのかの切り分けは大変ですが、まずはAPIの仕様を確認することで解決のきっかけを得られることもあると思います。

We're hiring

弊社ではSREおよびチーム内SREについて募集しています! 各チームで管理するサービスの信頼性を担保するためには、そのサービスの知識が欠かせません。 サービスを開発・運用しながら改善していくことに興味のある方は是非以下のリンクを御覧ください!

open.talentio.com open.talentio.com jobs.m3.com

*1:費用削減のためALBを共有し、Host ヘッダを見て振り分けています

*2:実際はcertificateの情報を取得するAPIだというアタリはつきますが、解説にお付き合いいただけるとありがたいです

*3:https://docs.aws.amazon.com/cli/latest/reference/acm/describe-certificate.html

*4:https://github.com/hashicorp/terraform-provider-aws/commit/eb9a8fe887a6bafb03d842412b078847d2595f9a