クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

Terraform AWSプロバイダv4アップグレードツールを作ろう

はじめに

SREチームの @minamijoyo です。趣味のTerraformで遊んでいたら、先日HashiCorpさんから「Core Contributor to HashiCorp Terraform for 2022」という名の、がんばったで賞をもらいました。対戦よろしくおねがいします。

crowdworks.jp のインフラはAWSで運用されており、インフラの管理にTerraformを使っています。先日ようやくTerraform AWSプロバイダv4アップグレードが完了しました。既にご存知の人も多いかもですが、v4ではS3バケット関連で大きな破壊的変更が入っており、アップグレード作業はなかなか大変です。もう皆さんアップグレードは終わりましたか? crowdworks.jp ではもうかれこれ6年以上Terraformを利用しており、Terraformの設定はおよそ7万行、tfstateの数は280個ぐらいの規模感です。さすがに数万行規模になってくると、手で大きな破壊的変更をやる気が起きなくて、最近Terraformのリファクタリング用のライブラリを作り始めました。そして最終的にいいかんじのアップグレードツールを書きました。

github.com

とりあえずツールの使い方だけ知りたいという人はリポジトリのREADMEを読んで下さい。(※現状aws_s3_bucketリファクタリングにしか対応してないことに注意)

この記事ではツールの使い方ではなく、仕組みの解説をします。一体誰得なんだと思いつつ、Terraform設定をいいかんじにプログラムで書き換えたいなという人向けです。最低限のTerraformの使い方とGoプログラミングは前提知識とします。逆にAWSに特化した知識はほとんどなくても読むのには支障はないと思うので、他のクラウドプロバイダをお使いの人も参考になるはず。

本稿執筆時の各種ソフトウェアのバージョンは以下のとおりです。

特にtfeditはまだ作りたてなので、インターフェースはしばらく不安定かもしれません。最新の情報は各READMEやCHANGELOGなどを参考にして下さい。

背景

2022年2月にAWSプロバイダのメジャーバージョンv4がリリースされ、aws_s3_bucketリソースに非常に大きな破壊的変更が入りました。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade

ざっくり言うと、v3まではaws_s3_bucketリソースの属性値として、ACLやライフサイクル、バージョニング、ロギング、暗号化などいろいろな機能が実装されていました。S3(=Simple Storage Service)とは...という定番のツッコミはさておき、肥大化しすぎて保守しづらいので、aws_s3_bucket_aclなどそれぞれ機能ごとに個別のリソースに切り出すという大規模なリファクタリングが実施されました。

Terraform設定の書き換えのイメージとしては、以下のようなリファクタリングが必要です。 ※後述しますが、実際には個別のリソースに切り出すだけではなく、データ構造にも多数の非互換な変更が入っているので、書き換えルールはもっと複雑です。

v3.tf

resource "aws_s3_bucket" "example" {
  bucket = "tfedit-test"
  acl    = "private"
}

v4.tf

resource "aws_s3_bucket" "example" {
  bucket = "tfedit-test"
}

resource "aws_s3_bucket_acl" "example" {
  bucket = aws_s3_bucket.example.id
  acl    = "private"
}

S3バケットはかなり古くから存在する基礎的な部品で、いろいろな用途で広く使われていることから、TerraformでAWSを管理しているほとんどの人が少なからず影響を受けたのではないでしょうか。またS3バケットはデータを保存するというその特性から、簡単に再作成できないものが多く、安全に移行するためには個別に切り出されたリソースのimportが必要で、気軽にアップグレードしづらい変更でした。

ところで、S3バケットにv4で大きな変更が入ることは、実はリリースの半年前ぐらいからアナウンスはされており、私も事前に認識してはいたのですが、

github.com

さすがにこんな大きな変更はβ版ぐらい出してくれるだろうと様子見をしていたら、2月に何のプレリリースもdeprecatedのサイクルもなく突如v4.0.0がリリースされ、かつ警告ではなくエラーで落ちる破壊的変更だったので、当時コミュニティは大混乱でした。その後、v3に一部バックポートされたり、v4を警告に戻すなど、二転三転あったのですが、当時の混乱の様子が以下のissueの盛り上がり具合で感じられます。

github.com

AWSプロバイダの長期的なメンテナンスの持続性の観点で、破壊的変更が必要だったことは理解できるものの、ユーザとしてどうやって手元の数万行規模のコードを書き換えるかというのは別問題でした。なんらか機械的に書き換える方法を考え始めました。

さいわい(?)私は HCL(HashiCorp Configuration Language) をコマンドで書き換えるCLIツール hcledit の作者だったので、まずはhcleditとシェルクスクリプトを組み合わせて書き換えできないかと考えましたが、v4アップグレードガイドを読んでこの案はすぐにボツになりました。というのも、HCLはTerraformの設定で使われている独自言語ですが、Terraform専用というわけではなく、HCL部分の仕様はかなり抽象的です。hcleditはできるだけTerraformに依存しないように意識しつつ、標準入出力をパイプで組み合わせることを想定した汎用的な単機能のツールとして設計/実装しています。一方で、v4での破壊的変更に伴うS3バケットリファクタリングのルールはそれほど単純ではありませんでした。これをシェルスクリプトで吸収しようとすると、非常に複雑なロジックがシェルスクリプト側に必要となり、ちょっと考えただけで嫌になりました。少なくともロジックはGoで実装した方が手堅そうでした。また今後メジャーバージョンアップの度に同じようなものを再実装するのも無駄だなと思い、純粋なHCLではなくTerraformに特化した、より高レベルなリファクタリング用の部品があると便利だなと思いました。

またtfstateの辻褄を合わせるために、大量のimportが必要なことが容易に想像できましたが、偶然にも(?)私はtfstateの操作をDBマイグレーションのように管理するツール tfmigrate の作者でもあったので、マイグレーションファイルもいいかんじに自動生成すればよいのではないかという構想もありました。

というわけで、アップグレードのための、アップグレードツールを書くための、リファクタリング用のライブラリを書くところからプロジェクトがスタートしたのでした。

HCLの基礎知識

多くのTerraformユーザは、HCLとTerraformの仕様の境界を普段意識することがあんまりないと思うので、この記事を理解する上で最低限必要そうな、純粋なHCL部分の仕様について先に補足しておきます。

厳密に言うと、HCLにはv1とv2がありますが、Terraform v0.12以降はHCL2が使われており、2022年現在のTerraformユーザがいまさらあえてv1を使うことはないと思うので、この記事では単にHCLと言った場合でもv2を指すものとします。

HCLの仕様と実装は以下のリポジトリで管理されています。

github.com

HCLの仕様は、構文に非依存な情報モデルと、HCLネイティブ構文とJSON構文として定義されています。

https://github.com/hashicorp/hcl/blob/v2.12.0/spec.md

HCLの情報モデルは、大雑把に言うと、トップレベルの要素はFileで、FileはBodyを持ちます。BodyはAttributeとBlockを持ち、BlockはBodyを持つという再帰的なデータ構造を定義しています。Terraformで使っている resource Blockや data Blockなどは、Terraformのアプリケーションが定義しているもので、HCLの仕様には含まれません。BlockはType Nameと0個以上のLabelを持つと定義されていますが、どのようなBlockがあり、どういう意味を持つのかというのはアプリケーションのスキーマに依存します。参考までにTerraformとしての言語仕様は以下で定義されています。

https://www.terraform.io/language/syntax/configuration

ところで、Terraformでは設定可能な引数をArgument、参照する場合はAttributeと呼び分けていますが、実運用上はどちらもAttributeと読んでいることも多く、この記事でも厳密には区別はしません。

AttributeはNameとValueを持ち、ValueはExpressionで設定します。Expressionの詳細まではこの記事では踏み込みませんが、 aws_s3_bucket.example.id のようなシンボルをたどる参照はTraversalと呼びます。

HCLの情報モデルのシリアライズ表現として、HCLネイティブ構文とJSON構文があります。

https://github.com/hashicorp/hcl/blob/v2.12.0/hclsyntax/spec.md https://github.com/hashicorp/hcl/blob/v2.12.0/json/spec.md

HCLネイティブ構文は人間が読み書きしやすいように、JSON構文はプログラムで読み書きしやすいようにと、1つの情報モデルに対して二種類の構文表現を持っています。TerraformユーザとしていわゆるHCLと言っている .tf ファイルがHCLネイティブ構文を使っており、 .tf.jsonJSON構文を使っています。

https://www.terraform.io/language/syntax/configuration https://www.terraform.io/language/syntax/json

Terraformの設定はJSONで書けると言うと、HCL => JSONに変換して、JSONで編集し、JSON => HCLに戻せばよいのではないかと多くの人が考えますが、これはよくある誤解です。JSON構文はHCLネイティブ構文のサブセットですべての機能を実装していないので、理論上HCL => JSONに変換した時点で一部の情報が欠落してしまいます。つまりHCL<=>JSONの相互変換というのは仕様上不可能です。

Terraformのリファクタリングというコンテキストで見た問題は、人間が読み書きしやすいHCLネイティブ構文をプログラムで読み書きする必要があるということです。

Terraformの設定をGoで書き換える

HCLをGoプログラムで読み書きするためのライブラリとして、HCLとGoの構造体をEncode/Decodeするgohclというパッケージがあります。 https://github.com/hashicorp/hcl/tree/v2.12.0/gohcl

シンプルな用途であればgohclで十分かもしれませんが、しかしながらgohclはコメントを維持できないので、今回のような既存のコードを編集するリファクタリングツールには不向きです。

また、Terraformのようにスキーマ情報がプロバイダ実装として分離されているような場合には、コンパイル時点でデータ構造がGoの構造体として定義できません。もう少し低レベルなhcldecというパッケージもありますが、これは読み込み専用です。 https://github.com/hashicorp/hcl/tree/v2.12.0/hcldec

HCLは設定ファイルのための言語なので、メインのユースケースは読み込みで、Terraformとして利用方法も基本的には読み込みです。例外は terraform fmt コマンドで、これはファイルを読み込んで整形するので、HCLを書き換えるプログラムと言えるでしょう。HCLにはこのfmtを実現するためのhclwriteというパッケージがあります。 https://github.com/hashicorp/hcl/tree/v2.12.0/hclwrite

hclwriteはトークンベースでHCLを書き換えるので、コメントも維持されますし、アプリケーションのスキーマも解釈不要です。リファクタリングツールという用途では、現時点ではhclwrite一択です。hcleditとtfeditもこのhclwriteをベースに実装されています。

hclwriteを使ったHCLの書き換え

まずはhclwriteを使ったHCLの書き換えのイメージを掴んでもらうために、冒頭のaws_s3_bucketaclaws_s3_bucket_aclに切り出すサンプルコードを書いてみましょう。入力と出力を再掲します。

v3.tf

resource "aws_s3_bucket" "example" {
  bucket = "tfedit-test"
  acl    = "private"
}

v4.tf

resource "aws_s3_bucket" "example" {
  bucket = "tfedit-test"
}

resource "aws_s3_bucket_acl" "example" {
  bucket = aws_s3_bucket.example.id
  acl    = "private"
}

素のhclwriteを使って、実装したサンプルコードは以下のとおりです。

go.mod

module sample

go 1.18

require github.com/hashicorp/hcl/v2 v2.12.0

require (
    github.com/agext/levenshtein v1.2.1 // indirect
    github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
    github.com/google/go-cmp v0.3.1 // indirect
    github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
    github.com/zclconf/go-cty v1.10.0 // indirect
    golang.org/x/text v0.3.5 // indirect
)

main.go

package main

import (
    "fmt"
    "io"
    "log"
    "os"

    "github.com/hashicorp/hcl/v2"
    "github.com/hashicorp/hcl/v2/hclwrite"
)

func main() {
    // 標準入力からバイト列を読み込み
    src, err := io.ReadAll(os.Stdin)
    if err != nil {
        log.Fatalf("failed to read stdin: %s", err)
    }

    // 入力をHCLとしてパース
    inFile, diags := hclwrite.ParseConfig(src, "-", hcl.Pos{Line: 1, Column: 1})
    if diags.HasErrors() {
        log.Fatalf("failed to parse input: %s", diags)
    }

    // すべてのブロックについてループ
    blocks := inFile.Body().Blocks()
    for _, b := range blocks {
        // resourceブロック以外は無視
        if b.Type() != "resource" {
            continue
        }

        // リソースタイプがaws_s3_bucket以外は無視
        labels := b.Labels()
        if labels[0] != "aws_s3_bucket" {
            continue
        }

        // acl属性を取得
        aclAttr := b.Body().GetAttribute("acl")

        // acl属性がなければ無視
        if aclAttr == nil {
            continue
        }

        // リソース名を取得
        name := labels[1]

        // 改行を追加
        inFile.Body().AppendNewline()

        // aws_s3_bucket_aclブロックを追加
        newblock := inFile.Body().AppendNewBlock("resource", []string{"aws_s3_bucket_acl", name})

        // aws_s3_bucket_aclブロックにbucket属性を追加し、
        // 元のaws_s3_bucket.name.idへの参照を設定
        newblock.Body().SetAttributeTraversal("bucket", hcl.Traversal{
            hcl.TraverseRoot{Name: "aws_s3_bucket"},
            hcl.TraverseAttr{Name: name},
            hcl.TraverseAttr{Name: "id"},
        })

        // aws_s3_bucket_aclブロックにacl属性をコピー
        aclAttrTokens := aclAttr.BuildTokens(nil)
        newblock.Body().AppendUnstructuredTokens(aclAttrTokens)

        // aws_s3_bucketブロックからacl属性を削除
        b.Body().RemoveAttribute("acl")
    }

    // fmtして標準出力に書き込み
    updated := inFile.BuildTokens(nil).Bytes()
    output := hclwrite.Format(updated)
    fmt.Fprint(os.Stdout, string(output))
}

インラインで補足コメントを書いたので、だいたい雰囲気が伝わるかと思いますが、パースしたHCLのトークンに対して条件にマッチした場合に、必要な加工をしてトークンを書き換えています。実装上の注意点としては、Attributeの右辺はリテラルであることは仮定できないので、文字列としては解釈せずに、そのままトークンを再利用しています。またコメントはAttributeに付随するので、新しいAttributeを自分で生成するよりも、可能な限り既存のトークンを再利用することでコメントを維持できます。

hcleditを使ったHCLの書き換え

HCLの入力、加工、出力というパターンは汎用的に使い回せるので、hcleditでは加工部分をFilterというインターフェースとして定義しています。

hcleditを使って先ほどのサンプルを書き直してみると以下のとおりです。

go.mod

module sample

go 1.18

require (
    github.com/hashicorp/hcl/v2 v2.12.0
    github.com/minamijoyo/hcledit v0.2.5
)

require (
    github.com/agext/levenshtein v1.2.1 // indirect
    github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
    github.com/google/go-cmp v0.3.1 // indirect
    github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
    github.com/zclconf/go-cty v1.10.0 // indirect
    golang.org/x/text v0.3.5 // indirect
)

main.go

package main

import (
    "log"
    "os"

    "github.com/hashicorp/hcl/v2"
    "github.com/hashicorp/hcl/v2/hclwrite"
    "github.com/minamijoyo/hcledit/editor"
)

// AWSS3BucketACLFilter はFilterインターフェースを実装する
type AWSS3BucketACLFilter struct{}

var _ editor.Filter = (*AWSS3BucketACLFilter)(nil)

func (f *AWSS3BucketACLFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) {
    // すべてのブロックについてループ
    blocks := inFile.Body().Blocks()
    for _, b := range blocks {
        // resourceブロック以外は無視
        if b.Type() != "resource" {
            continue
        }

        // リソースタイプがaws_s3_bucket以外は無視
        labels := b.Labels()
        if labels[0] != "aws_s3_bucket" {
            continue
        }

        // acl属性を取得
        aclAttr := b.Body().GetAttribute("acl")

        // acl属性がなければ無視
        if aclAttr == nil {
            continue
        }

        // リソース名を取得
        name := labels[1]

        // 改行を追加
        inFile.Body().AppendNewline()

        // aws_s3_bucket_aclブロックを追加
        newblock := inFile.Body().AppendNewBlock("resource", []string{"aws_s3_bucket_acl", name})

        // aws_s3_bucket_aclブロックにbucket属性を追加し、
        // 元のaws_s3_bucket.name.idへの参照を設定
        newblock.Body().SetAttributeTraversal("bucket", hcl.Traversal{
            hcl.TraverseRoot{Name: "aws_s3_bucket"},
            hcl.TraverseAttr{Name: name},
            hcl.TraverseAttr{Name: "id"},
        })

        // aws_s3_bucket_aclブロックにacl属性をコピー
        aclAttrTokens := aclAttr.BuildTokens(nil)
        newblock.Body().AppendUnstructuredTokens(aclAttrTokens)

        // aws_s3_bucketブロックからacl属性を削除
        b.Body().RemoveAttribute("acl")
    }

    return inFile, nil
}

func main() {
    // hcleditの初期化
    o := &editor.Option{
        InStream:  os.Stdin,
        OutStream: os.Stdout,
        ErrStream: os.Stderr,
    }
    c := editor.NewClient(o)

    // Filterのインスタンスを生成して呼び出し
    filter := &AWSS3BucketACLFilter{}
    err := c.Edit("-", false, filter)

    if err != nil {
        log.Fatalf("failed to edit: %s", err)
    }
}

入出力まわりの実装をhcleditに追い出して、加工ロジックに集中できるようになり、ちょっとだけスッキリしました。

tfeditを使ったTerraform設定の書き換え

HCLの仕様の範囲内では、そもそもTerraformのresourceブロックという概念がありません。ブロックラベルの1つめがリソースタイプ名、2つめがリソース名というドメイン知識もなく、どうしてもHCLの加工処理が煩雑になりがちです。ここでは例としてaws_s3_bucketacl属性を使っていますが、実はこれは一番書き換えルールが単純で、説明しやすいのでサンプルとして使っているだけで、AWSプロバイダのv4アップグレードに必要なルールはもっと複雑です。

tfeditではTerraformに特化することで、素のhclwrite.Blockよりも高レベルなtfwrite.Resourceを定義し、これを入出力に受け取るtfeditor.ResourceFilterを定義しています。これを使うと、特定のリソースタイプにだけ作用する加工ロジックがさらに簡単に書けます。また今回アップグレードツールで書き換えに必要なルールはacl属性だけではありません。対象の属性それぞれに加工ロジックが必要です。tfeditでは複数のResourceFilterを1つのResourceFilterに合成するMultiResourceFilterを定義しています。これらを使うことで、Unixのパイプのように単純なResourceFilterを組み合わせてより複雑なルールを組み立てることができます。

tfeditを使って書き直してみたサンプルは以下のとおりです。

go.mod

module sample

go 1.18

require (
    github.com/hashicorp/hcl/v2 v2.12.0
    github.com/minamijoyo/hcledit v0.2.5
    github.com/minamijoyo/tfedit v0.1.1
)

require (
    github.com/agext/levenshtein v1.2.1 // indirect
    github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
    github.com/google/go-cmp v0.5.8 // indirect
    github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
    github.com/zclconf/go-cty v1.10.0 // indirect
    golang.org/x/text v0.3.7 // indirect
)

main.go

package main

import (
    "log"
    "os"

    "github.com/hashicorp/hcl/v2/hclwrite"
    "github.com/minamijoyo/hcledit/editor"
    "github.com/minamijoyo/tfedit/tfeditor"
    "github.com/minamijoyo/tfedit/tfwrite"
)

// AWSS3BucketFilter はFilterインターフェースを実装する
type AWSS3BucketFilter struct {
    filters []tfeditor.ResourceFilter
}

var _ editor.Filter = (*AWSS3BucketFilter)(nil)

func NewAWSS3BucketFilter() editor.Filter {
    // acl用のResourceFilterを登録
    filters := []tfeditor.ResourceFilter{
        &AWSS3BucketACLFilter{},
    }

    return &AWSS3BucketFilter{filters: filters}
}

func (f *AWSS3BucketFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) {
    // aws_s3_bucketリソースにだけマッチするResourceFilterを生成する
    m := tfeditor.NewResourcesByTypeFilter("aws_s3_bucket", f)
    return m.Filter(inFile)
}

func (f *AWSS3BucketFilter) ResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) {
    // 複数のResourceFilterを1つのResourceFilterに合成
    m := tfeditor.NewMultiResourceFilter(f.filters)
    return m.ResourceFilter(inFile, resource)
}

// AWSS3BucketACLFilter はResourceFilterインターフェースを実装する
type AWSS3BucketACLFilter struct{}

var _ tfeditor.ResourceFilter = (*AWSS3BucketACLFilter)(nil)

func (f *AWSS3BucketACLFilter) ResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) {
    // acl属性を取得
    aclAttr := resource.GetAttribute("acl")

    // acl属性がなければ無視
    if aclAttr == nil {
        return inFile, nil
    }

    // リソース名を取得
    resourceName := resource.Name()

    // aws_s3_bucket_aclブロックを追加
    newResource := tfwrite.NewEmptyResource("aws_s3_bucket_acl", resourceName)
    inFile.AppendResource(newResource)

    // aws_s3_bucket_aclブロックにbucket属性を追加し、
    // 元のaws_s3_bucket.name.idへの参照を設定
    newResource.SetAttributeByReference("bucket", resource, "id")

    // aws_s3_bucket_aclブロックにacl属性をコピー
    newResource.AppendAttribute(aclAttr)

    // aws_s3_bucketブロックからacl属性を削除
    resource.RemoveAttribute("acl")

    return inFile, nil
}

func main() {
    // hcleditの初期化
    o := &editor.Option{
        InStream:  os.Stdin,
        OutStream: os.Stdout,
        ErrStream: os.Stderr,
    }
    c := editor.NewClient(o)

    // Filterのインスタンスを生成して呼び出し
    filter := NewAWSS3BucketFilter()
    err := c.Edit("-", false, filter)

    if err != nil {
        log.Fatalf("failed to edit: %s", err)
    }
}

tfeditが提供する高レベルな部品を組み合わせることで、かなり直感的にTerraformのresourceブロックを操作でき、加工ロジックの実装に集中できるようになったことが分かります。

Terraform AWSプロバイダv4アップグレードのルールを実装する

単純なケースの加工ロジックの実装イメージができたので、あとは公式アップグレードガイドを眺めながら地道に必要なルールを実装していきます。具体的には、S3バケットリファクタリングの影響を受けるのは以下の属性です。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade

  • acceleration_status
  • acl
  • cors_rule
  • grant
  • lifecycle_rule
  • logging
  • object_lock_configuration rule
  • policy
  • replication_configuration
  • request_payer
  • server_side_encryption_configuration
  • versioning
  • website

数の多さにこの時点で既にお腹いっぱいですが、前述のとおり、aws_s3_bucketacl属性の書き換えは一番簡単な例です。私も実装当初はそれぞれ個別のリソースに切り出せばよいんだよねという程度の牧歌的な理解で実装し始めましたが、実装を進めるにつれて問題がそれほど単純ではないことに気が付きました。

例えば以下は、アップグレードガイドに載っていたgrant属性の書き換え例です。

v3.tf

resource "aws_s3_bucket" "example" {
  bucket = "yournamehere"

  # ... other configuration ...
  grant {
    id          = data.aws_canonical_user_id.current_user.id
    type        = "CanonicalUser"
    permissions = ["FULL_CONTROL"]
  }

  grant {
    type        = "Group"
    permissions = ["READ_ACP", "WRITE"]
    uri         = "http://acs.amazonaws.com/groups/s3/LogDelivery"
  }
}

v4.tf

resource "aws_s3_bucket" "example" {
  bucket = "yournamehere"

  # ... other configuration ...
}

resource "aws_s3_bucket_acl" "example" {
  bucket = aws_s3_bucket.example.id

  access_control_policy {
    grant {
      grantee {
        id   = data.aws_canonical_user_id.current_user.id
        type = "CanonicalUser"
      }
      permission = "FULL_CONTROL"
    }

    grant {
      grantee {
        type = "Group"
        uri  = "http://acs.amazonaws.com/groups/s3/LogDelivery"
      }
      permission = "READ_ACP"
    }

    grant {
      grantee {
        type = "Group"
        uri  = "http://acs.amazonaws.com/groups/s3/LogDelivery"
      }
      permission = "WRITE"
    }

    owner {
      id = data.aws_canonical_user_id.current_user.id
    }
  }
}

違いが分かりましたか?まるで間違い探しですが、

  • aws_s3_bucketリソースにgrantブロックがあった場合は、aws_s3_bucket_aclaccess_control_policyブロックに変換する
  • grantブロックはそのままコピーすればよいわけではなく、granteeブロックとpermission属性に分解する必要がある。
  • v3になかったgranteeブロックとは?って思いながらよく見ると、v3ではgrantブロックのpermissionsは文字列の配列だったが、permission属性は文字列で型が変わっている(!)
  • つまりpermissionsの配列を分解して、granteeブロックをpermissionsの要素数だけ生成する必要がある。
  • permissionsがリテラルならパースできなくはないが、変数参照としてモジュールの外から渡されたらそもそも書き換え無理じゃん。
  • あれ、最後のownerブロックどこから出てきたの?って思いながらドキュメントを見ると、v3に存在しないownerブロックがv4でシレッと必須になってる(!)
  • ownerはAWSAPIコールなしに知る方法はないが、moduleのメンテナとユーザが同じであることは暗黙に仮定できないので、とりあえず固定のプレースホルダを埋めて一旦お茶を濁すしかないかなこれ。

というハイコンテキストなかんじですが、公式アップグレードガイドは代表的なケースについてのv3からv4への書き換え例のみで、残念ながらすべての変更点を文章として説明しておらず、網羅もしていません。大変きびしい。

このようにS3バケットリファクタリングに関しては、破壊的変更を入れるついでにデータ構造にも多数の非互換な変更が入っています。AWSプロバイダチームは新規のリソースタイプを追加する場合、できるだけAWSAPIのデータ構造と揃えようとしているようですが、aws_s3_bucketのような古くから存在するリソースタイプは歴史的経緯でこのルールに従っていないものも多々あります。何度も破壊的変更を入れるよりも、この機会にまとめて変更してしまおうという意図は理解できますが、アップグレードツールを書く人泣かせです。泣いた。

実際の各種フィルタ実装は以下にあります。興味がある人は眺めてみて下さい。個人的なオススメはlifecycle_ruleですが、データ構造が違いすぎてまだ気づいていないバグがいっぱいありそうです。

https://github.com/minamijoyo/tfedit/tree/v0.1.1/filter/awsv4upgrade

ブロックの構造や属性名がリネームされたものは対応関係をマッピングしてやればよいのですが、有効な値の範囲が変わったものや、任意が必須になったものなど、どうにもならないものもいくつかありました。気づいたものはREADMEにKnown limitationsとして書いておきましたが、まだ気づいてないものがあるかもです。何か見つけたらissueでご報告下さい。

https://github.com/minamijoyo/tfedit/tree/v0.1.1#known-limitations

前述のとおり、どうしようもない非互換がいくつかあり、アップグレードツールは理論上完璧にはなりえませんが、それでも何も前提知識なしで手で書き換えて各自で無駄に時間を溶かすよりかは、アップグレードツールに知見が溜まっていく方がマシだとは思います。

tfstateのマイグレーション

S3バケットリファクタリングでTerraform設定の書き換えは問題の半分でしかありません。新しく切り出されたリソースをimportする必要があります。しかしながら公式の terraform import コマンドはその場でリモートのtfstateを更新してしまいます。これはCI/CDフレンドリーではありませんし、特にチーム開発をしている場合は、tfファイルの変更のマージ前にはtfstateは更新したくないでしょう。一方で、Terraform設定とimport操作の妥当性の検証という観点で、import後のterraform plan差分がないことをマージ前に確認したいという矛盾があります。私はこの問題を解決するために、Terraformのstate操作をDBマイグレーションのように管理するtfmigrateというツールを開発しています。

github.com

tfmigrateを使うと、terraform stateコマンドやimportコマンドをマイグレーションファイルに記述し、CI/CDに組み込むことができます。また、tfmigrate planコマンドにより、リモートのtfstateを更新することなく、state操作後のplan差分を確認することができるので、安全にリファクタリングができます。

今回は大量のimportが必要だったので、tfmigrateのマイグレーションファイルを自動で生成できないかと考えました。

最初に思いついた素朴な案は、Terraformの設定ファイルを書き換えながら、対応するimport文を生成し、マイグレーションファイルとして出力することでした。これは非常にシンプルなユースケースであれば機能するかもしれませんが、moduleと組み合わせるとすぐに破綻することに気づきました。というのも、aws_s3_bucketリソースがmoduleの中に定義されている場合、importに必要なrootモジュールから見たモジュールインスタンスのリソースアドレスが分かりません。さらに、importに必要なリソースIDとして今回のケースだとs3バケット名が必要ですが、s3バケット名はmoduleに変数として渡される可能性があります。そしてmoduleのメンテナとユーザは異なる場合があります。つまりmoduleのメンテナがTerraform設定を書き換えるタイミングでは、明らかにimportに必要な情報を持っていません。moduleのユーザ側でなんらかマイグレーションファイルを生成する必要があります。

ところで、tfmigrateをユーザとして使っていると、terraform plan結果を見ながら、plan差分がなくなるようにマイグレーションをファイルを書いていることが多いのですが、terraform plan結果をパースして、いいかんじにマイグレーションファイルを自動生成できないかと考えました。

terraform planコマンドの標準出力を直接パースできなくもないですが、一度planファイルとして保存すると、JSON形式に変換できます。JSONのフォーマットは以下で定義されており、これがパースしやすそうです。

https://www.terraform.io/internals/json-format

またバイナリ形式のplanファイルは実装詳細としてパーサは公開されていませんが、このJSON形式の型定義はGoのライブラリとして以下に切り出されており、簡単にパースできます。

https://github.com/hashicorp/terraform-json

具体的なイメージを掴みやすいように、ちょっと試しにterraform planをJSON形式で出力してみましょう。

$ terraform plan -out=tmp.tfplan
$ terraform show -json tmp.tfplan | jq .

以下は関連する箇所の抜粋です。

{
  "format_version": "1.0",
  ...
  "resource_changes": [
    {
      "address": "aws_s3_bucket.example",
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "example",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": [
          "no-op"
        ]
        ...
      }
    },
    {
      "address": "aws_s3_bucket_acl.example",
      "mode": "managed",
      "type": "aws_s3_bucket_acl",
      "name": "example",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": [
          "create"
        ],
        "before": null,
        "after": {
          "acl": "private",
          "bucket": "tfedit-test",
          "expected_bucket_owner": null
        },
        "after_unknown": {
          "access_control_policy": true,
          "id": true
        }
        ...
      }
    }
  ]
  ...
}

resource_changesの中でchange.actionsが変更区分です。no-opになっていないものがいわゆるplan差分で、createになっているものをimportすればよさそうです。importに必要なリソースアドレスはaddressから、リソースIDはchange.afterで生成予定のリソースの属性から組み立てればよさそうです。

1点注意点として、importに必要なリソースIDはリソースタイプによって異なります。aws_s3_bucket_aclの場合、単純なS3バケットtfedit-test ではなく、 tfedit-test,private というように、acl属性の値も必要で、カンマで結合するという特殊な指定方法になっています。

https://registry.terraform.io/providers/hashicorp/aws/4.16.0/docs/resources/s3_bucket_acl#import

先ほどのaws_s3_bucketリソースのacl属性を、aws_s3_bucket_aclリソースに切り出す例で言うと、具体的には以下のようなimport文を生成する必要があります。

$ terraform import aws_s3_bucket_acl.example tfedit-test,private

残念ながら、現状このimportに必要なリソースIDのフォーマットはプロバイダの各リソースタイプごとのドキュメントに記載はあるものの、プロバイダのスキーマ定義には含まれておらず、機械的に生成する方法が見当たりませんでした。なので汎用的な仕組みとして実装することは不可能なのですが、今回のアップグレードツールのような特定のリソースタイプにスコープを限定してよければ、リソースIDの生成ルールをロジックとしてハードコーディングできそうです。また必要な属性がunknownでapplyするまで分からないような場合には、plan結果からimportを自動生成することはできませんが、今回のケースではバケット名がリソースIDで、unknownではないので特に問題ありませんでした。

terraform-jsonを使ったマイグレーションの自動生成

まずはterraform-jsonを直接使ってplanファイルをパースし、必要なマイグレーションを生成してみます。実装例は以下のとおりです。

go.mod

module sample

go 1.18

require github.com/hashicorp/terraform-json v0.14.0

require (
    github.com/hashicorp/go-version v1.5.0 // indirect
    github.com/zclconf/go-cty v1.10.0 // indirect
    golang.org/x/text v0.3.5 // indirect
)

main.go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "os"
    "text/template"

    tfjson "github.com/hashicorp/terraform-json"
)

func main() {
    // 標準入力からバイト列を読み込み
    b, err := io.ReadAll(os.Stdin)
    if err != nil {
        log.Fatalf("failed to read stdin: %s", err)
    }

    // 入力をplanファイルとしてパース
    var plan tfjson.Plan
    if err := json.Unmarshal(b, &plan); err != nil {
        log.Fatalf("failed to parse plan file: %s", err)
    }

    // すべてのresource_changesについてループ
    var migrateActions []string
    for _, rc := range plan.ResourceChanges {
        // change.actionsがcreateの場合
        if rc.Change.Actions.Create() {
            // import文を組み立て
            address := rc.Address
            after := rc.Change.After.(map[string]interface{})
            id := fmt.Sprintf("%s,%s", after["bucket"], after["acl"])
            migrateAction := fmt.Sprintf("import %s %s", address, id)
            migrateActions = append(migrateActions, migrateAction)
        }
    }

    // tfmigrateのマイグレーションファイルとしてrender
    migrationTemplate := `migration "state" "fromplan" {
  actions = [
  {{- range . }}
    "{{ . }}",
  {{- end }}
  ]
}
`

    tpl := template.Must(template.New("migration").Parse(migrationTemplate))
    var output bytes.Buffer
    if err := tpl.Execute(&output, migrateActions); err != nil {
        log.Fatalf("failed to render migration file: %s", err)
    }

    fmt.Fprint(os.Stdout, output.String())
}

インライン補足コメントを書いたので、なんとなくイメージが掴めたでしょうか?これだけでもplanファイルの解析パターンを増やしていけば、もっと複雑なパターンに対応できそうです。

tfeditを使ったマイグレーションの自動生成

実際のtfeditの実装は対応するリソースタイプを簡単に拡張できるように、importに必要なリソースIDを算出する関数ImportIDFuncを登録できるようになっていたり、将来的にimport以外のマイグレーションも自動で生成したいので、もう少し汎用的な仕組みになっています。

単純にimportに対応するリソースタイプを増やすだけであれば簡単です。tfeditで定義されている部品を組み合わせて、自分でimportに必要なリソースIDを算出する関数を登録する例は以下のとおりです。

go.mod

module sample

go 1.18

require github.com/minamijoyo/tfedit v0.1.1

require (
    github.com/agext/levenshtein v1.2.1 // indirect
    github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
    github.com/google/go-cmp v0.5.8 // indirect
    github.com/hashicorp/go-version v1.5.0 // indirect
    github.com/hashicorp/hcl/v2 v2.12.0 // indirect
    github.com/hashicorp/terraform-json v0.14.0 // indirect
    github.com/kr/pretty v0.2.0 // indirect
    github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
    github.com/zclconf/go-cty v1.10.0 // indirect
    golang.org/x/text v0.3.7 // indirect
)

main.go

package main

import (
    "fmt"
    "io"
    "log"
    "os"

    "github.com/minamijoyo/tfedit/migration"
    "github.com/minamijoyo/tfedit/migration/schema"
)

// aws_s3_bucket_aclリソースのimportに必要なリソースIDを算出する関数を定義
func importIDFuncAWSS3BucketACL(r schema.Resource) (string, error) {
    // bucketとacl属性を,で連結
    // 説明を単純化するためgrantのケースは省略
    return schema.ImportIDFuncByMultiAttributes([]string{"bucket", "acl"}, ",")(r)
}

func main() {
    // 標準入力からバイト列を読み込み
    b, err := io.ReadAll(os.Stdin)
    if err != nil {
        log.Fatalf("failed to read stdin: %s", err)
    }

    // 入力をplanファイルとしてパース
    plan, err := migration.NewPlan(b)
    if err != nil {
        log.Fatalf("failed to parse plan file: %s", err)
    }

    // importに必要なリソースIDを算出する関数を登録
    dictionary := schema.NewDictionary()
    dictionary.RegisterImportIDFuncMap(map[string]schema.ImportIDFunc{
        "aws_s3_bucket_acl": importIDFuncAWSS3BucketACL,
    })

    analyzer := migration.NewDefaultPlanAnalyzer(dictionary)
    migration, err := analyzer.Analyze(plan, "")
    if err != nil {
        log.Fatalf("failed to analyze plan file: %s", err)
    }

    output, err := migration.Render()
    if err != nil {
        log.Fatalf("failed to render a migration file: %s", err)
    }

    fmt.Fprint(os.Stdout, string(output))
}

ImportIDFuncはリソースタイプごとに指定する必要があるため、生成するためのヘルパ関数がいくつか定義されています。上記で使用しているImportIDFuncByMultiAttributes は、複数の属性を指定した区切り文字列で連結するものです。もっと単純に単一属性でよければ ImportIDFuncByAttribute を使って下さい。

前述のとおり、現状importに必要なリソースIDのフォーマットがプロバイダのスキーマ定義から機械的に取れないので、自前で算出ロジックを辞書として登録していくしかありませんが、今回のアップグレードツールのようにスコープを限定できれば、マイグレーションファイルの自動生成は可能で、実用上これで十分なケースもあるでしょう。

crowdworks.jpでのアップグレード作業

アップグレードツールができたので、あとは実際に運用しているTerraform設定に適用していきます。 現状tfeditはディレクトリをまとめて更新するような機能はないので、ちょっとだけ作業スクリプトを書きました。以下の作業スクリプトは、Terraform設定を管理しているリポジトリディレクトリ構造を暗黙に仮定しているので、たぶんそのままでは使えないとは思いますが、なにもないよりかはマシかなと思うので、参考までに貼っておきます。適宜読み替えて下さい。

awsv4upgrade.sh

#!/bin/bash
set -euo pipefail

# awsv4upgrade.sh: awsプロバイダをv4にアップグレードする作業スクリプト
#
# 公式のアップグレードガイド
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade

# インストール
# tfeditコマンドに依存しています。なければインストールして下さい。
# $ brew install minamijoyo/tfedit/tfedit
#
# 使い方
# 引数で処理対象のコマンドとディレクトリを指定します。
# tfファイルの書き換え
# $ ./awsv4upgrade.sh filter dir1
# tfファイルのバリデーション
# $ ./awsv4upgrade.sh validate dir1
# マイグレーションファイルの生成
# $ ./awsv4upgrade.sh migration dir1
#
# モジュールはtfファイルの書き換えのみであるのに対し、
# マイグレーションは読み込み元なのでコマンドが分かれています。
# バリデーションもterraform initが必要なので読み込み元で実行する想定。
# 複数のディレクトリをまとめて更新するにはxargsと組み合わせて下さい。
# 以下は特定のディレクトリ構造を暗黙に仮定しています。適宜読み替えて下さい。
# $ find . -type f -name '*.tf' \
#     | xargs -I {} dirname {} | sort | uniq \
#     | grep -v -e "^.$" -e "^./tfmigrate" \
#     | xargs -n 1 -I {} sh -c './awsv4upgrade.sh filter {} || exit 255'
#
# $ find . -type f -name '*.tf' \
#     | xargs -I {} dirname {} | sort | uniq \
#     | grep -v -e "^.$" -e "^./tfmigrate" -e "modules/" \
#     | xargs -n 1 -I {} sh -c './awsv4upgrade.sh validate {} || exit 255'
#
# またマイグレーションファイルの生成にはterraform planが必要で、
# 関係ないディレクトリで全部実行するのは非効率です。
# aws_s3_bucketを使用してそうなrootモジュールに限定して実行するには、
# いいかんじの正規表現で対象を検索します。
# 以下はmodule sourceも暗黙に仮定しています。適宜読み替えて下さい。
# $ git grep -l -E '(resource "aws_s3_bucket")|(source\s+=\s+.*\/modules\/s3\/)' \
#     | xargs dirname | sort | uniq | grep -v "modules/" \
#     | xargs -n 1 -I {} sh -c './awsv4upgrade.sh migration {} || exit 255'
#
# このスクリプトはtfmigrate planまでは実行しません。
# tfmigrate planは遅い&微調整しながら試行錯誤する可能性が高いため。
#
# 現状の制限
# tfeditはすべてのケースを完全にはカバーしきれていません。
# 自動での書き換えがうまくいかない場合は適宜手動で調整して下さい。
# 既知の制限についてはREADMEを参照して下さい。
# https://github.com/minamijoyo/tfedit

usage()
{
  cat << EOF
  Usage: $(basename "$0") <command> <target_dir>
  Arguments:
    command: A name of step to run. Valid values are:
             filter | validate | migration
    target_dir: A path to work directory
EOF
}

# tfファイルの書き換え
filter()
{
  find . -type f -name '*.tf' -print0 | xargs -0 -I {} tfedit filter awsv4upgrade -u -f {}
}

# tfファイルのバリデーション
validate()
{
  terraform init -input=false -no-color
  terraform validate -json -no-color
  count=$(terraform validate -json -no-color | jq '[.error_count, .warning_count] | add')
  if [[ $count -ne 0 ]]; then
    echo "expected to no error, but got $count errors"
    exit 1
  fi
}

# マイグレーションファイルの生成
migration()
{
  # 一時的なtfplanファイルのパス
  local tfplan_file="$TMP_DIR/tmp.tfplan"
  # 現在時刻UTC
  timestamp=$(date -u +%Y%m%d%H%M%S)
  # ディレクトリ階層の区切りを / => _ に置換
  flatten=${TARGET_DIR//\//_}
  # 生成するマイグレーションファイル名
  local migration_file="${timestamp}_${flatten}_awsv4upgrade.hcl"

  terraform init -input=false -no-color
  terraform plan -input=false -no-color -out="$tfplan_file"
  terraform show -json "$tfplan_file" | tfedit migration fromplan -d "$TARGET_DIR" -o="$MIGRATION_DIR/$migration_file"

  rm -f "$tfplan_file"
}

# main

# 必須引数の数チェック
if [[ $# -ne 2 ]]; then
    usage
    exit 1
fi

set -x

# サブコマンド
COMMAND=$1
# 処理対象のディレクトリ
TARGET_DIR=$2

# トレイリングスラッシュの削除して正規化
TARGET_DIR=${TARGET_DIR%/}
echo "TARGET_DIR: $TARGET_DIR"
# リポジトリルート
REPO_ROOT_DIR="$(git rev-parse --show-toplevel)"
# マイグレーションファイルのディレクトリ
MIGRATION_DIR="$REPO_ROOT_DIR/tfmigrate"
# 作業ディレクトリがなければ作成
TMP_DIR="$REPO_ROOT_DIR/tmp/awsv4upgrade"
mkdir -p "$TMP_DIR"

pushd "$TARGET_DIR"

case "$COMMAND" in
  filter | validate | migration )
    "$COMMAND"
    RET=$?
    ;;
  *)
    usage
    RET=1
    ;;
esac

popd
exit $RET

アップグレード作業の流れは、まずは検証ブランチで全部書き換えてみて最終形を確認し、適宜レビューしやすい粒度に分割して順次マージしていくことで、通常の開発をブロックすることなくv4へのアップグレード作業を無事に完了しました。

補足として、AWSプロバイダv4の変更点はS3バケットリファクタリング以外にもいろいろあります。多くの人が影響を受けそうなところで、AWSの認証情報の読み込み優先順位が変わったことがありますが、詳細はアップグレードガイドを参照して下さい。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#changes-to-authentication

またいくつかアップグレードガイドに載ってない微妙な非互換も踏みましたが、どのリソースタイプを使っているかにも依存するので、ここでは個別具体的な話は割愛します。適宜AWSプロバイダのCHANGELOGなども参考にして下さい。

おわりに

この記事では、Terraform設定をいいかんじにプログラムで書き換える方法について説明し、tfeditが提供するリファクタリング用の部品を組み合わせて、AWSプロバイダv4アップグレードツールを作る方法について説明しました。Terraformのリファクタリングをしたいものの、既存のコードベースが巨大で困ってる誰かの参考になれば幸いです。

tfeditはCLIツールとしては現状AWSプロバイダv4アップグレードしかできませんが、プロジェクトのスコープは特定のユースケースに特化したものではなく、どちらかというとTerraformリファクタリングに必要な部品をライブラリとして揃えていくプロジェクトにしたいなと考えています。もし気に入ったらスター☆してくれると、今後の開発の励みになります |ω・`)チラッ

github.com

現状はさしあたり必要な機能を実装しただけで、まだまだ機能は全然足りてないです。GitHubでIssueを立てるほどでもないのだがというレベルのご意見ご感想なども歓迎なので、なにがしかTwitterなどで @minamijoyo までフィードバックいただけるとうれしいです。もしくは terraform-jp のコミュニティのSlackにも生息しているので、どちらでも構いません。

最後になりましたが、クラウドワークスではTerraformが好きなSREを募集しています。

www.wantedly.com

© 2016 CrowdWorks, Inc., All rights reserved.