CLOVER🍀

That was when it all began.

Goのxerrorsパッケージを試す

これは、なにをしたくて書いたもの?

Goのエラー処理に関する情報を見ているとxerrorsというものがよく出てくるので、1度見ておこうかなと思いまして。

xerrorsパッケージ

xerrorsパッケージは、Goのエラーハンドリングのためのパッケージです。

xerrors · pkg.go.dev

GitHub - golang/xerrors

このパッケージの提案内容は、こちら。

Proposal: Go 2 Error Inspection

これが標準パッケージ、errorsに取り込まれたのがGo 1.13のようです。

errors - The Go Programming Language

errorsパッケージについては、以前このエントリで試してみました。

Goのエラーに関するAPIを学ぶ - CLOVER🍀

xerrorsパッケージが持っている機能のうち、ほとんどのものはerrorsパッケージに導入されたようです。
でも、導入されなかったものもあるようです。

それが呼び出し元のフレームを保存する機能のようですね。

今回は、xerrorsパッケージを試してみたいと思います。

環境

今回の環境は、こちらです。

$ go version
go version go1.16 linux/amd64
$ go mod init xerrors-example
go: creating new go.mod: module xerrors-example

go.mod

module xerrors-example

go 1.16

require golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect

go.sum

golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

errorsとxerrorsを比べながら使ってみる

では、使っていってみましょう。標準パッケージerrorsの元になっただけあって、とてもとても良く似ています。

errors - The Go Programming Language

xerrors · pkg.go.dev

以下の対比になるようです。

  • errors.New - xerros.New
  • fmt.Errorf - xerrors.Errorf
  • errors.Is - xerrors.Is
  • errors.As - xerrors.As
  • errors.Unwrap - xerrors.Unwrap

エラーの原因を含むものについては、errorsパッケージではなくfmtパッケージを使いますね。

また、errorsと対応先のないものとして、xerrors.Opaqueというのもあります。

今回は、NewおよびErrorfを中心に使っていきましょう。他はだいたい同じようなので。

作成したソースコードは、こちらです。呼び出し位置の表示があることもあって、最初は行番号付きで表示しておきます。

$ cat -n main.go

     1 package main
     2  
     3 import (
     4     "errors"
     5     "fmt"
     6     "runtime"
     7  
     8     "golang.org/x/xerrors"
     9 )
    10  
    11 func main() {
    12     fmt.Println("Case1:\n")
    13  
    14     fmt.Printf("New error as errors: %v\n", errors.New("Oops!!"))
    15     fmt.Println()
    16     fmt.Printf("New error as errors: %+v\n", errors.New("Oops!!"))
    17  
    18     printSeparator()
    19  
    20     fmt.Printf("New error as xerrors: %v\n", xerrors.New("Oops!!"))
    21     fmt.Println()
    22     fmt.Printf("New error as xerrors: %+v\n", xerrors.New("Oops!!"))
    23  
    24     fmt.Println("\nCase2:\n")
    25  
    26     fmt.Printf("call error as errors: %+v\n", callErrors())
    27  
    28     printSeparator()
    29  
    30     fmt.Printf("call error as xerrors: %+v\n", callXerrors())
    31  
    32     fmt.Println("\nCase3:\n")
    33  
    34     fmt.Printf("wrap error as errors: %+v\n", wrapErrors())
    35  
    36     printSeparator()
    37  
    38     fmt.Printf("wrap error as xerrors: %+v\n", wrapXerrors())
    39  
    40     fmt.Println("\nCase4:\n")
    41  
    42     fmt.Printf("unwrap as errors: %+v\n", errors.Unwrap(wrapErrors()))
    43  
    44     printSeparator()
    45  
    46     fmt.Printf("unwrap as xerrors: %+v\n", xerrors.Unwrap(wrapXerrors()))
    47  
    48     fmt.Println()
    49  
    50     fmt.Printf("unwrap as xerrors / errors.Unwrap: %+v\n", errors.Unwrap(wrapXerrors()))
    51  
    52     fmt.Println("\nCase5:\n")
    53  
    54     fmt.Printf("wrap error as errors: %+v\n", wrapErrors())
    55  
    56     printSeparator()
    57  
    58     fmt.Printf("wrap error as xerrors with opaque: %+v\n", xerrors.Opaque(wrapXerrors()))
    59  
    60     fmt.Println("\nCase6:\n")
    61  
    62     pc, file, line, ok := callRuntimeCaller0()
    63     fmt.Printf("%v, %s, %d, %t\n", pc, file, line, ok)
    64  
    65     printSeparator()
    66  
    67     pc, file, line, ok = callRuntimeCaller1()
    68     fmt.Printf("%v, %s, %d, %t\n", pc, file, line, ok)
    69  
    70     printSeparator()
    71  
    72     pcs := callRuntimeCallers0()
    73     frames := runtime.CallersFrames(pcs)
    74  
    75     for {
    76         frame, more := frames.Next()
    77         fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)
    78  
    79         if !more {
    80             break
    81         }
    82     }
    83  
    84     printSeparator()
    85  
    86     pcs = callRuntimeCallers1()
    87     frames = runtime.CallersFrames(pcs)
    88  
    89     for {
    90         frame, more := frames.Next()
    91         fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)
    92  
    93         if !more {
    94             break
    95         }
    96     }
    97  
    98     printSeparator()
    99  
   100     pcs = wrapCallRuntimeCaller1()
   101     frames = runtime.CallersFrames(pcs)
   102  
   103     for {
   104         frame, more := frames.Next()
   105         fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)
   106  
   107         if !more {
   108             break
   109         }
   110     }
   111 }
   112  
   113 func printSeparator() {
   114     fmt.Println()
   115     fmt.Println("======")
   116     fmt.Println()
   117 }
   118  
   119 func causeErrorUseErrors() error {
   120     return errors.New("Oops!!")
   121 }
   122  
   123 func callErrors() error {
   124     return causeErrorUseErrors()
   125 }
   126  
   127 func wrapErrors() error {
   128     return fmt.Errorf("Wrap Error!! cause: %w", causeErrorUseErrors())
   129 }
   130  
   131 func causeErrorUseXerrors() error {
   132     return xerrors.New("Oops!!")
   133 }
   134  
   135 func callXerrors() error {
   136     return causeErrorUseXerrors()
   137 }
   138  
   139 func wrapXerrors() error {
   140     return xerrors.Errorf("Wrap Error!! cause: %w", causeErrorUseXerrors())
   141 }
   142  
   143 func callRuntimeCaller0() (uintptr, string, int, bool) {
   144     return runtime.Caller(0)
   145 }
   146  
   147 func callRuntimeCaller1() (uintptr, string, int, bool) {
   148     return runtime.Caller(1)
   149 }
   150  
   151 func callRuntimeCallers0() []uintptr {
   152     var pcs [3]uintptr
   153     runtime.Callers(0, pcs[:])
   154     return pcs[:]
   155 }
   156  
   157 func callRuntimeCallers1() []uintptr {
   158     var pcs [3]uintptr
   159     runtime.Callers(1, pcs[:])
   160     return pcs[:]
   161 }
   162  
   163 func wrapCallRuntimeCaller1() []uintptr {
   164     return callRuntimeCallers1()
   165 }

実行は、こちらで。

$ go run main.go

関連するソースコードの抜粋と、実行結果を使って書いてきます。

%+vと呼び出し元の表示

最初はこちら。New関数を使って、エラーを作成します。

Package errors / func New

xerrors / func New

    12     fmt.Println("Case1:\n")
    13  
    14     fmt.Printf("New error as errors: %v\n", errors.New("Oops!!"))
    15     fmt.Println()
    16     fmt.Printf("New error as errors: %+v\n", errors.New("Oops!!"))
    17  
    18     printSeparator()
    19  
    20     fmt.Printf("New error as xerrors: %v\n", xerrors.New("Oops!!"))
    21     fmt.Println()
    22     fmt.Printf("New error as xerrors: %+v\n", xerrors.New("Oops!!"))

実行結果。

Case1:

New error as errors: Oops!!

New error as errors: Oops!!

======

New error as xerrors: Oops!!

New error as xerrors: Oops!!:
    main.main
        /path/to/main.go:22

xerrors.Newを使い、かつエラー表示の書式に%+vを指定した場合にファイル名と行番号が表示されました。

  fmt.Printf("New error as xerrors: %+v\n", xerrors.New("Oops!!"))

errors.Newの方は、エラーの文字列表示のみです。

%+vの意味は、フィールドを加えたものです。

%v the value in a default format when printing structs, the plus flag (%+v) adds field names

fmt - The Go Programming Language

ソースコードを見ると、Frameを出力していることになりますね。

https://github.com/golang/xerrors/blob/5ec99f83aff198f5fbd629d6c8d8eb38a04218ca/errors.go#L12

これがxerrorsを使った時に、フレームと呼ばれているもののようです。

関数呼び出しを挟んでみる

次は、New関数と標準出力の間に、関数呼び出しを挟んでみましょう。

    24     fmt.Println("\nCase2:\n")
    25  
    26     fmt.Printf("call error as errors: %+v\n", callErrors())
    27  
    28     printSeparator()
    29  
    30     fmt.Printf("call error as xerrors: %+v\n", callXerrors())

呼び出し先の関数。

   119 func causeErrorUseErrors() error {
   120     return errors.New("Oops!!")
   121 }
   122  
   123 func callErrors() error {
   124     return causeErrorUseErrors()
   125 }

〜省略〜

   131 func causeErrorUseXerrors() error {
   132     return xerrors.New("Oops!!")
   133 }
   134  
   135 func callXerrors() error {
   136     return causeErrorUseXerrors()
   137 }

実行結果。

Case2:

call error as errors: Oops!!

======

call error as xerrors: Oops!!:
    main.causeErrorUseXerrors
        /path/to/main.go:132

どうも、エラーが生成されたところまでのコールスタックが表示されるようなものではなさそうです。
ここに表示されているのは、xerrors.Newを呼び出した場所そのものですね。

エラーをラップしてみる

次は、エラーをラップしてみましょう。fmt.Errorfおよびxerrors.Errorfと、書式文字列の%wを使います。

Package fmt / func Errorf

xerrors / func Errorf

    32     fmt.Println("\nCase3:\n")
    33  
    34     fmt.Printf("wrap error as errors: %+v\n", wrapErrors())
    35  
    36     printSeparator()
    37  
    38     fmt.Printf("wrap error as xerrors: %+v\n", wrapXerrors())

呼び出し先。

   119 func causeErrorUseErrors() error {
   120     return errors.New("Oops!!")
   121 }
   122  

〜省略〜

   126  
   127 func wrapErrors() error {
   128     return fmt.Errorf("Wrap Error!! cause: %w", causeErrorUseErrors())
   129 }


〜省略〜

   131 func causeErrorUseXerrors() error {
   132     return xerrors.New("Oops!!")
   133 }
   134  

〜省略〜

   138  
   139 func wrapXerrors() error {
   140     return xerrors.Errorf("Wrap Error!! cause: %w", causeErrorUseXerrors())
   141 }

結果。

Case3:

wrap error as errors: Wrap Error!! cause: Oops!!

======

wrap error as xerrors: Wrap Error!! cause:
    main.wrapXerrors
        /path/to/main.go:140
  - Oops!!:
    main.causeErrorUseXerrors
        /path/to/main.go:132

xerrors.Errorfの方は、%wを使ってネストさせた分のエラーも含めて表示されています。

このひとつ前の結果(単純にエラーを関数呼び出しで包んだ場合)ではコールスタックが増えなかったことから考えると、
他の言語でよく見るような関数呼び出しのコールスタックを再現したければ、各呼び出しごとにエラーをラップしていく
必要がありそうですね。

Unwrapしてみる

ネストされたエラーをUnwrapしてみましょう。

Package errors / func Unwrap

xerrors / func Unwrap

    40     fmt.Println("\nCase4:\n")
    41  
    42     fmt.Printf("unwrap as errors: %+v\n", errors.Unwrap(wrapErrors()))
    43  
    44     printSeparator()
    45  
    46     fmt.Printf("unwrap as xerrors: %+v\n", xerrors.Unwrap(wrapXerrors()))
    47  
    48     fmt.Println()
    49  
    50     fmt.Printf("unwrap as xerrors / errors.Unwrap: %+v\n", errors.Unwrap(wrapXerrors()))

エラーの作成に使っている関数は、先ほどと同じですね。

結果。

Case4:

unwrap as errors: Oops!!

======

unwrap as xerrors: Oops!!:
    main.causeErrorUseXerrors
        /path/to/main.go:132

unwrap as xerrors / errors.Unwrap: Oops!!:
    main.causeErrorUseXerrors
        /path/to/main.go:132

xerrorsの方は、Unwrapしても呼び出し元のフレームが表示されます。また、xerrorsで作成したエラーをerrorsの方でUnwrapすることも
できますね。

xerrors.Opaqueを使ってみる

xerrors.Opaqueを使ってみましょう。

xerrors / func Opaque

    52     fmt.Println("\nCase5:\n")
    53  
    54     fmt.Printf("wrap error as errors: %+v\n", wrapErrors())
    55  
    56     printSeparator()
    57  
    58     fmt.Printf("wrap error as xerrors with opaque: %+v\n", xerrors.Opaque(wrapXerrors()))

こちらを使うと、xerrorsを使った場合でも呼び出し元のフレームが出なくなります。

Case5:

wrap error as errors: Wrap Error!! cause: Oops!!

======

wrap error as xerrors with opaque: Wrap Error!! cause: Oops!!

つまり、errorsを使った時と同じになりましたね。ネストされたエラーは保持しているようです。

およそこんなところでしょうか。だいたい雰囲気はわかった気がします。

呼び出し元フレームの取得方法

ところで、xerrorsはどうやって呼び出し元のフレームを取得しているんでしょうか。

こちらのようです。

https://github.com/golang/xerrors/blob/5ec99f83aff198f5fbd629d6c8d8eb38a04218ca/frame.go#L24

runtimeパッケージを使っているようですね。

runtime - The Go Programming Language

また、他の言語でのコールスタックのようにならないのは、文字列に変換している時に1行分しか残していないからみたいですね。

https://github.com/golang/xerrors/blob/5ec99f83aff198f5fbd629d6c8d8eb38a04218ca/frame.go#L48

https://github.com/golang/xerrors/blob/5ec99f83aff198f5fbd629d6c8d8eb38a04218ca/frame.go#L31-L41

xerrorsが使っているのはruntime.Callersですが、

https://github.com/golang/xerrors/blob/5ec99f83aff198f5fbd629d6c8d8eb38a04218ca/frame.go#L24

まずはruntime.Callerを使ってみましょう。

runtime / func Caller

    60     fmt.Println("\nCase6:\n")
    61  
    62     pc, file, line, ok := callRuntimeCaller0()
    63     fmt.Printf("%v, %s, %d, %t\n", pc, file, line, ok)
    64  
    65     printSeparator()
    66  
    67     pc, file, line, ok = callRuntimeCaller1()
    68     fmt.Printf("%v, %s, %d, %t\n", pc, file, line, ok)

それぞれ、runtime.Callerに0と1を渡しています。

   143 func callRuntimeCaller0() (uintptr, string, int, bool) {
   144     return runtime.Caller(0)
   145 }
   146  
   147 func callRuntimeCaller1() (uintptr, string, int, bool) {
   148     return runtime.Caller(1)
   149 }

実行結果。

Case6:

4856742, /path/to/main.go, 144, true

======

4857125, /path/to/main.go, 67, true

0の方は、まさにこの位置を表示しています。

   143 func callRuntimeCaller0() (uintptr, string, int, bool) {
   144     return runtime.Caller(0)
   145 }

対して1の方は、呼び出し元のコードの位置を表示しています。

    67      pc, file, line, ok = callRuntimeCaller1()

つまり、runtime.Callerに与える値は、どれだけ呼び出し元までのフレームをスキップするかを意味します。

次にruntime.Callersを使ってみます。

Packages runtime / func Callers

こちらの第1引数は、どれだけフレームをスキップするかです。そして、第2引数にフレームを保存します。

この結果とruntime.CallersFramesを組み合わせることで、スタックトレースを再現できます。

Package runtime / func CallersFrames

    72     pcs := callRuntimeCallers0()
    73     frames := runtime.CallersFrames(pcs)
    74  
    75     for {
    76         frame, more := frames.Next()
    77         fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)
    78  
    79         if !more {
    80             break
    81         }
    82     }
    83  
    84     printSeparator()
    85  
    86     pcs = callRuntimeCallers1()
    87     frames = runtime.CallersFrames(pcs)
    88  
    89     for {
    90         frame, more := frames.Next()
    91         fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)
    92  
    93         if !more {
    94             break
    95         }
    96     }

ただ、どのくらいの数のフレームを取得するかは、固定で決めないといけなさそうですけどね。

   151 func callRuntimeCallers0() []uintptr {
   152     var pcs [3]uintptr
   153     runtime.Callers(0, pcs[:])
   154     return pcs[:]
   155 }
   156  
   157 func callRuntimeCallers1() []uintptr {
   158     var pcs [3]uintptr
   159     runtime.Callers(1, pcs[:])
   160     return pcs[:]
   161 }

結果。

  runtime.Callers(/usr/lib/go-1.16/src/runtime/extern.go:229)
  main.callRuntimeCallers0(/path/to/main.go:153)
  main.main(/path/to/main.go:72)

======

  main.callRuntimeCallers1(/path/to/main.go:159)
  main.main(/path/to/main.go:86)
  runtime.main(/usr/lib/go-1.16/src/runtime/proc.go:225)

runtime.Callersの第1引数を0にすると、runtime.Callers自身が含まれますね…。どこまでスキップするかを指定するのは、
重要な様子です。

フレームを取得するまでに、さらに関数呼び出しを挟んでみましょう。

   100     pcs = wrapCallRuntimeCaller1()
   101     frames = runtime.CallersFrames(pcs)
   102  
   103     for {
   104         frame, more := frames.Next()
   105         fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)
   106  
   107         if !more {
   108             break
   109         }
   110     }

こんな感じですね。

   157 func callRuntimeCallers1() []uintptr {
   158     var pcs [3]uintptr
   159     runtime.Callers(1, pcs[:])
   160     return pcs[:]
   161 }
   162  
   163 func wrapCallRuntimeCaller1() []uintptr {
   164     return callRuntimeCallers1()
   165 }

結果。それっぽくなっていますね。

  main.callRuntimeCallers1(/path/to/main.go:159)
  main.wrapCallRuntimeCaller1(/path/to/main.go:164)
  main.main(/path/to/main.go:100)

このあたりの雰囲気はわかった気がします。

とはいえ、xerrorsの方も保持しているフレームも3つ分だけのようですし、

https://github.com/golang/xerrors/blob/5ec99f83aff198f5fbd629d6c8d8eb38a04218ca/frame.go#L16

そもそもこういうスタックトレースのようなものに頼るような言語ではないのでしょうね。

このあたりの情報は、参考程度に覚えておこうかなと思います。

オマケ

最後に、行番号なしのソースコードを載せておきましょう。

main.go

package main

import (
    "errors"
    "fmt"
    "runtime"

    "golang.org/x/xerrors"
)

func main() {
    fmt.Println("Case1:\n")

    fmt.Printf("New error as errors: %v\n", errors.New("Oops!!"))
    fmt.Println()
    fmt.Printf("New error as errors: %+v\n", errors.New("Oops!!"))

    printSeparator()

    fmt.Printf("New error as xerrors: %v\n", xerrors.New("Oops!!"))
    fmt.Println()
    fmt.Printf("New error as xerrors: %+v\n", xerrors.New("Oops!!"))

    fmt.Println("\nCase2:\n")

    fmt.Printf("call error as errors: %+v\n", callErrors())

    printSeparator()

    fmt.Printf("call error as xerrors: %+v\n", callXerrors())

    fmt.Println("\nCase3:\n")

    fmt.Printf("wrap error as errors: %+v\n", wrapErrors())

    printSeparator()

    fmt.Printf("wrap error as xerrors: %+v\n", wrapXerrors())

    fmt.Println("\nCase4:\n")

    fmt.Printf("unwrap as errors: %+v\n", errors.Unwrap(wrapErrors()))

    printSeparator()

    fmt.Printf("unwrap as xerrors: %+v\n", xerrors.Unwrap(wrapXerrors()))

    fmt.Println()

    fmt.Printf("unwrap as xerrors / errors.Unwrap: %+v\n", errors.Unwrap(wrapXerrors()))

    fmt.Println("\nCase5:\n")

    fmt.Printf("wrap error as errors: %+v\n", wrapErrors())

    printSeparator()

    fmt.Printf("wrap error as xerrors with opaque: %+v\n", xerrors.Opaque(wrapXerrors()))

    fmt.Println("\nCase6:\n")

    pc, file, line, ok := callRuntimeCaller0()
    fmt.Printf("%v, %s, %d, %t\n", pc, file, line, ok)

    printSeparator()

    pc, file, line, ok = callRuntimeCaller1()
    fmt.Printf("%v, %s, %d, %t\n", pc, file, line, ok)

    printSeparator()

    pcs := callRuntimeCallers0()
    frames := runtime.CallersFrames(pcs)

    for {
        frame, more := frames.Next()
        fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)

        if !more {
            break
        }
    }

    printSeparator()

    pcs = callRuntimeCallers1()
    frames = runtime.CallersFrames(pcs)

    for {
        frame, more := frames.Next()
        fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)

        if !more {
            break
        }
    }

    printSeparator()

    pcs = wrapCallRuntimeCaller1()
    frames = runtime.CallersFrames(pcs)

    for {
        frame, more := frames.Next()
        fmt.Printf("  %s(%s:%d)\n", frame.Function, frame.File, frame.Line)

        if !more {
            break
        }
    }
}

func printSeparator() {
    fmt.Println()
    fmt.Println("======")
    fmt.Println()
}

func causeErrorUseErrors() error {
    return errors.New("Oops!!")
}

func callErrors() error {
    return causeErrorUseErrors()
}

func wrapErrors() error {
    return fmt.Errorf("Wrap Error!! cause: %w", causeErrorUseErrors())
}

func causeErrorUseXerrors() error {
    return xerrors.New("Oops!!")
}

func callXerrors() error {
    return causeErrorUseXerrors()
}

func wrapXerrors() error {
    return xerrors.Errorf("Wrap Error!! cause: %w", causeErrorUseXerrors())
}

func callRuntimeCaller0() (uintptr, string, int, bool) {
    return runtime.Caller(0)
}

func callRuntimeCaller1() (uintptr, string, int, bool) {
    return runtime.Caller(1)
}

func callRuntimeCallers0() []uintptr {
    var pcs [3]uintptr
    runtime.Callers(0, pcs[:])
    return pcs[:]
}

func callRuntimeCallers1() []uintptr {
    var pcs [3]uintptr
    runtime.Callers(1, pcs[:])
    return pcs[:]
}

func wrapCallRuntimeCaller1() []uintptr {
    return callRuntimeCallers1()
}

Terragruntを使って、複数のTerraformモジュールの操作を1回のコマンド実行で行う

これは、なにをしたくて書いたもの?

Terragruntを学ぶシリーズ。今回でひと区切りの予定です。

最後は、Terragruntを使って複数のTerraformモジュールの操作を1回のコマンド実行で行ってみます。

Terragruntで、Terraformモジュールの操作を1度に行う

Terraformで環境を作っていく際に、適用する(ルート)モジュールが複数あると、その数だけterraform applyを実行してリソースを
作成したり、あるいはterraform destroyして環境を破棄したりすることになります。

Terragruntのドキュメントの記載例ですが、以下の記載例だとterraformコマンドを少なくとも5回は実行することになります。
また、各モジュールの間には依存関係もあるでしょう。

root
├── backend-app
│   └── main.tf
├── frontend-app
│   └── main.tf
├── mysql
│   └── main.tf
├── redis
│   └── main.tf
└── vpc
    └── main.tf

Terragruntは、このような状況下でのTerraformの実行を簡単にしてくれるようです。

Execute Terraform commands on multiple modules at once

今回は、こちらの機能を試してみたいと思います。

環境

今回の環境は、こちらです。

$ terraform version
Terraform v0.14.7


$ terragrunt -v
terragrunt version v0.28.7

Terraform Providerは、MySQL用のものを使用します。

Provider: MySQL - Terraform by HashiCorp

使用するMySQLは8.0.23とし、172.17.0.2で動作しているものとします。

最後にオマケで、Consulも追加します。

お題

MySQL Providerを使った、以下の3つのルートモジュールを定義します。

  • データベース
  • ユーザー
  • 権限

これらの3つのモジュールに、依存関係を定義しつつTerragruntを使って一括で操作してみましょう。

Terragruntなしで構成する

最初は、TerragruntなしでシンプルにTerraformのみで構築してみたいと思います。

各モジュール用のディレクトリを作成。

$ mkdir database users roles

中身は、こんな感じになりました。

$ tree
.
├── database
│   ├── main.tf
│   └── terraform.tfvars
├── grants
│   ├── main.tf
│   └── terraform.tfvars
└── users
    ├── main.tf
    └── terraform.tfvars

3 directories, 6 files

データベース用モジュール。

$ cd database

main.tf

terraform {
  required_version = "0.14.7"

  required_providers {
    mysql = {
      source  = "terraform-providers/mysql"
      version = "1.9.0"
    }
  }
}

provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"
}

variable "database_name" {
  type = string
}

resource "mysql_database" "app" {
  name                  = var.database_name
  default_character_set = "utf8mb4"
  default_collation     = "utf8mb4_ja_0900_as_cs_ks"
}

output "database_name" {
  value = mysql_database.app.name
}

変数は、あらかじめ用意しておきます。

terraform.tfvars

database_name = "my_database"

ユーザー用モジュール。

$ cd ../users

main.tf

terraform {
  required_version = "0.14.7"

  required_providers {

    mysql = {
      source  = "terraform-providers/mysql"
      version = "1.9.0"
    }
  }
}

provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"
}

variable "administrator_username" {
  type = string
}

variable "administrator_password" {
  type = string
}

variable "administrator_allow_host" {
  type = string
}

variable "application_user_username" {
  type = string
}

variable "application_user_password" {
  type = string
}

variable "application_user_allow_host" {
  type = string
}

resource "mysql_user" "administrator_user" {
  user               = var.administrator_username
  plaintext_password = var.administrator_password
  host               = var.administrator_allow_host
}

resource "mysql_user" "application_user" {
  user               = var.application_user_username
  plaintext_password = var.application_user_password
  host               = var.application_user_allow_host
}

output "administrator_username" {
  value = mysql_user.administrator_user.user
}

output "administrator_password" {
  value     = mysql_user.administrator_user.plaintext_password
  sensitive = true
}

output "administrator_allow_host" {
  value = mysql_user.administrator_user.host
}

output "application_user_username" {
  value = mysql_user.application_user.user
}

output "application_user_password" {
  value     = mysql_user.application_user.plaintext_password
  sensitive = true
}

output "application_user_allow_host" {
  value = mysql_user.application_user.host
}

terraform.tfvars

administrator_username   = "admin"
administrator_password   = "admin_password"
administrator_allow_host = "%"

application_user_username   = "appuser"
application_user_password   = "appuser_password"
application_user_allow_host = "%"

権限用モジュール。

$ cd ../grants

main.tf

terraform {
  required_version = "0.14.7"

  required_providers {

    mysql = {
      source  = "terraform-providers/mysql"
      version = "1.9.0"
    }
  }
}

provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"
}

variable "database_name" {
  type = string
}

variable "administrator_username" {
  type = string
}

variable "administrator_allow_host" {
  type = string
}

variable "application_user_username" {
  type = string
}

variable "application_user_allow_host" {
  type = string
}

resource "mysql_grant" "administrator_user" {
  user       = var.administrator_username
  host       = var.administrator_allow_host
  database   = var.database_name
  privileges = ["ALL"]
}

resource "mysql_grant" "application_user" {
  user       = var.application_user_username
  host       = var.application_user_allow_host
  database   = var.database_name
  privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]
}

output "administrator_username" {
  value = mysql_grant.administrator_user.user
}

output "administrator_allow_host" {
  value = mysql_grant.administrator_user.host
}

output "administrator_privileges" {
  value = mysql_grant.administrator_user.privileges
}

output "application_user_username" {
  value = mysql_grant.application_user.user
}

output "application_user_allow_host" {
  value = mysql_grant.application_user.host
}

output "application_user_privileges" {
  value = mysql_grant.application_user.privileges
}

terraform.tfvars

database_name = "my_database"

administrator_username   = "admin"
administrator_allow_host = "%"

application_user_username   = "appuser"
application_user_allow_host = "%"

いずれも、リソース定義と必要なVariableは用意してあるので、単純にinitしてapplyすればリソースが構築されます。

$ cd ../database
$ terraform init
$ terraform apply


$ cd ../users
$ terraform init
$ terraform apply


$ cd ../grants
$ terraform init
$ terraform apply

リソースができました。

mysql_database.app: Creating...
mysql_database.app: Creation complete after 0s [id=my_database]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

database_name = "my_database"


mysql_user.administrator_user: Creating...
mysql_user.application_user: Creating...
mysql_user.administrator_user: Creation complete after 0s [id=admin@%]
mysql_user.application_user: Creation complete after 0s [id=appuser@%]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

administrator_allow_host = "%"
administrator_password = <sensitive>
administrator_username = "admin"
application_user_allow_host = "%"
application_user_password = <sensitive>
application_user_username = "appuser"


mysql_grant.administrator_user: Creating...
mysql_grant.application_user: Creating...
mysql_grant.administrator_user: Creation complete after 0s [id=admin@%:`my_database`]
mysql_grant.application_user: Creation complete after 0s [id=appuser@%:`my_database`]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

administrator_allow_host = "%"
administrator_privileges = toset([
  "ALL",
])
administrator_username = "admin"
application_user_allow_host = "%"
application_user_privileges = toset([
  "DELETE",
  "INSERT",
  "SELECT",
  "UPDATE",
])
application_user_username = "appuser"

動作確認したので、いったんリソースを破棄しましょう。

$ cd ../grants
$ terraform destroy


$ cd ../users
$ terraform destroy


$ cd ../database
$ terraform destroy

この時、applyの逆順で実行しているわけですが、データベースとユーザーは独立しているのですが、権限の方は先に他のモジュールが
適用されていることが実は前提になっていたりします。

それに、こう何回もapplyしたりdestroyしたりするのは面倒に思うかもしれません。このあたりをなんとかしようとするのが
Terragruntの機能のひとつです。

Terragruntを使って一括実行する

では、Terragruntを使っていきましょう。

先ほどのTerraformのみで実行した構成をまるっとコピーして、Terragrunt用のファイルを加えた以下の構成を作りました。

$ tree
.
├── database
│   ├── main.tf
│   └── terragrunt.hcl
├── grants
│   ├── main.tf
│   └── terragrunt.hcl
├── terragrunt.hcl
└── users
    ├── main.tf
    └── terragrunt.hcl

3 directories, 7 files

トップレベルのディレクトリにあるterragrunt.hclでは、Provider定義をまとめておきました。

terragrunt.hcl

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"
}
EOF
}

これで、各モジュールの定義からはProviderの定義を削除できます。代表で、データベース用のモジュールだけ載せておきましょう。

database/main.tf

terraform {
  required_version = "0.14.7"

  required_providers {
    mysql = {
      source  = "terraform-providers/mysql"
      version = "1.9.0"
    }
  }
}

# provider "mysql" {
#   endpoint = "172.17.0.2:3306"
#   username = "root"
#   password = "password"
# }

variable "database_name" {
  type = string
}

resource "mysql_database" "app" {
  name                  = var.database_name
  default_character_set = "utf8mb4"
  default_collation     = "utf8mb4_ja_0900_as_cs_ks"
}

output "database_name" {
  value = mysql_database.app.name
}

ディレクトリ内のterragrunt.hclには、上位ディレクトリにあるterragrunt.hclファイルへの参照と、Variablesをまとめました。
なので、いずれのディレクトリからもterraform.tfvarsファイルは削除しています。

database/terragrunt.hcl

include {
  path = find_in_parent_folders()
}

inputs = {
  database_name = "my_database"
}

ユーザー用モジュール。

users/terragrunt.hcl

include {
  path = find_in_parent_folders()
}

inputs = {
  administrator_username   = "admin"
  administrator_password   = "admin_password"
  administrator_allow_host = "%"

  application_user_username   = "appuser"
  application_user_password   = "appuser_password"
  application_user_allow_host = "%"
}

権限用モジュール。

grants/terragrunt.hcl

include {
  path = find_in_parent_folders()
}

inputs = {
  database_name = "my_database"

  administrator_username   = "admin"
  administrator_allow_host = "%"

  application_user_username   = "appuser"
  application_user_allow_host = "%"
}

ここで、トップディレクトリにいる状態で

$ ll
合計 24
drwxrwxr-x 5 xxxxx xxxxx 4096  2月 27 15:12 ./
drwxrwxr-x 5 xxxxx xxxxx 4096  2月 27 15:05 ../
drwxrwxr-x 3 xxxxx xxxxx 4096  2月 27 15:16 database/
drwxrwxr-x 3 xxxxx xxxxx 4096  2月 27 15:16 grants/
-rw-rw-r-- 1 xxxxx xxxxx  209  2月 27 15:15 terragrunt.hcl
drwxrwxr-x 3 xxxxx xxxxx 4096  2月 27 15:16 users/

run-allを使うことで、コマンドを一気に実行できます。たとえばplan

$ terragrunt run-all plan

こんな感じで、複数のモジュールに対するplanが一気に実行されます。

INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [])
  => Module /path/to/users (excluded: false, dependencies: []) 

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # mysql_grant.administrator_user will be created
  + resource "mysql_grant" "administrator_user" {
      + database   = "my_database"
      + grant      = false
      + host       = "%"
      + id         = (known after apply)
      + privileges = [
          + "ALL",
        ]
      + table      = "*"
      + tls_option = "NONE"
      + user       = "admin"
    }

  # mysql_grant.application_user will be created
  + resource "mysql_grant" "application_user" {
      + database   = "my_database"
      + grant      = false
      + host       = "%"
      + id         = (known after apply)
      + privileges = [
          + "DELETE",
          + "INSERT",
          + "SELECT",
          + "UPDATE",
        ]
      + table      = "*"
      + tls_option = "NONE"
      + user       = "appuser"
    }

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

Changes to Outputs:
  + administrator_allow_host    = "%"
  + administrator_privileges    = [
      + "ALL",
    ]
  + administrator_username      = "admin"
  + application_user_allow_host = "%"
  + application_user_privileges = [
      + "DELETE",
      + "INSERT",
      + "SELECT",
      + "UPDATE",
    ]
  + application_user_username   = "appuser"

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.


An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # mysql_database.app will be created
  + resource "mysql_database" "app" {
      + default_character_set = "utf8mb4"
      + default_collation     = "utf8mb4_ja_0900_as_cs_ks"
      + id                    = (known after apply)
      + name                  = "my_database"
    }

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

Changes to Outputs:
  + database_name = "my_database"

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.


An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # mysql_user.administrator_user will be created
  + resource "mysql_user" "administrator_user" {
      + host               = "%"
      + id                 = (known after apply)
      + plaintext_password = (sensitive value)
      + tls_option         = "NONE"
      + user               = "admin"
    }

  # mysql_user.application_user will be created
  + resource "mysql_user" "application_user" {
      + host               = "%"
      + id                 = (known after apply)
      + plaintext_password = (sensitive value)
      + tls_option         = "NONE"
      + user               = "appuser"
    }

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

Changes to Outputs:
  + administrator_allow_host    = "%"
  + administrator_password      = (sensitive value)
  + administrator_username      = "admin"
  + application_user_allow_host = "%"
  + application_user_password   = (sensitive value)
  + application_user_username   = "appuser"

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

INFO[0003] 
Initializing the backend...

Initializing provider plugins...
- Finding terraform-providers/mysql versions matching "1.9.0"...
- Installing terraform-providers/mysql v1.9.0...
- Installed terraform-providers/mysql v1.9.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary. 
INFO[0003] 
Initializing the backend...

Initializing provider plugins...
- Finding terraform-providers/mysql versions matching "1.9.0"...
- Installing terraform-providers/mysql v1.9.0...
- Installed terraform-providers/mysql v1.9.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary. 
INFO[0003] 
Initializing the backend...

Initializing provider plugins...
- Finding terraform-providers/mysql versions matching "1.9.0"...
- Installing terraform-providers/mysql v1.9.0...
- Installed terraform-providers/mysql v1.9.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary. 

一緒にinitも実行されています。

結果、provider.tfも自動生成されました。

$ tree
.
├── database
│   ├── main.tf
│   ├── provider.tf
│   └── terragrunt.hcl
├── grants
│   ├── main.tf
│   ├── provider.tf
│   └── terragrunt.hcl
├── terragrunt.hcl
└── users
    ├── main.tf
    ├── provider.tf
    └── terragrunt.hcl

3 directories, 10 files

database/provider.tf

# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"
}

run-all applyしてみましょう。

$ terragrunt run-all apply

うまくリソースが構築できたようです。

INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [])
  => Module /path/to/users (excluded: false, dependencies: []) 
Are you sure you want to run 'terragrunt apply' in each folder of the stack described above? (y/n) y
mysql_user.administrator_user: Creating...
mysql_user.application_user: Creating...
mysql_grant.administrator_user: Creating...
mysql_grant.application_user: Creating...
mysql_user.application_user: Creation complete after 0s [id=appuser@%]
mysql_database.app: Creating...
mysql_user.administrator_user: Creation complete after 0s [id=admin@%]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

administrator_allow_host = "%"
administrator_password = <sensitive>
administrator_username = "admin"
application_user_allow_host = "%"
application_user_password = <sensitive>
application_user_username = "appuser"
mysql_database.app: Creation complete after 0s [id=my_database]
mysql_grant.administrator_user: Creation complete after 0s [id=admin@%:`my_database`]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

database_name = "my_database"
mysql_grant.application_user: Creation complete after 0s [id=appuser@%:`my_database`]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

administrator_allow_host = "%"
administrator_privileges = toset([
  "ALL",
])
administrator_username = "admin"
application_user_allow_host = "%"
application_user_privileges = toset([
  "DELETE",
  "INSERT",
  "SELECT",
  "UPDATE",
])
application_user_username = "appuser"

今度は、run-all destroyで一気に破棄。

$ terragrunt run-all destroy

すると、エラーになりました。権限モジュールがうまくdestroyできないようです。

INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [])
  => Module /path/to/users (excluded: false, dependencies: []) 
WARNING: Are you sure you want to run `terragrunt destroy` in each folder of the stack described above? There is no undo! (y/n) y
mysql_database.app: Destroying... [id=my_database]
mysql_user.application_user: Destroying... [id=appuser@%]
mysql_user.administrator_user: Destroying... [id=admin@%]
mysql_grant.administrator_user: Destroying... [id=admin@%:`my_database`]
mysql_grant.application_user: Destroying... [id=appuser@%:`my_database`]
mysql_database.app: Destruction complete after 0s
mysql_user.application_user: Destruction complete after 0s
mysql_user.administrator_user: Destruction complete after 0s

Destroy complete! Resources: 1 destroyed.

Error: error revoking GRANT (REVOKE GRANT OPTION ON `my_database`.* FROM 'appuser'@'%'): Error 1141: There is no such grant defined for user 'appuser' on host '%'



Error: error revoking GRANT (REVOKE GRANT OPTION ON `my_database`.* FROM 'admin'@'%'): Error 1141: There is no such grant defined for user 'admin' on host '%'


ERRO[0002] Module /path/to/grants has finished with an error: Hit multiple errors:
Hit multiple errors:
exit status 1  prefix=[/path/to/grants] 

Destroy complete! Resources: 2 destroyed.
ERRO[0002] Encountered the following errors:
Hit multiple errors:
Hit multiple errors:
exit status 1 

これは、破棄するリソースの順番に依存関係があるからですね。現時点だと、Terragruntはその依存関係を認識していないのです。

仕方ないので、1度applyしなおして

$ terragrunt run-all apply

各モジュールを順を追って個別にdestroyします。

$ cd grants
$ terragrunt destroy


$ cd ../users
$ terragrunt destroy


$ cd ../database
$ terragrunt destroy

トップディレクトリへ戻ります。

$ cd ..

モジュール間に依存関係を作る+Outputを引き継ぐ

先ほどdestroyが失敗したのは、Terragruntがモジュール間の依存関係を認識しておらず、他のモジュールが必要とするリソースを
先に破棄してしまったことが原因でした。

よって、Terragruntに依存関係を教える必要があります。

Execute Terraform commands on multiple modules at once / Dependencies between modules

現在のモジュール間の依存関係はないので、graph-dependenciesで見るとそれぞれが独立していることがわかります。

$ terragrunt graph-dependencies
digraph {
    "database" ;
    "grants" ;
    "users" ;
}

図にした場合。

$ terragrunt graph-dependencies | dot -Tsvg > graph.svg

f:id:Kazuhira:20210227153632p:plain

ドキュメントを参考に、権限用モジュールに対して、データベースおよびユーザー用のモジュールへの依存関係を単純に
定義してみましょう。dependenciesを使い、pathsで依存するモジュールを指定します。

grants/terragrunt.hcl

include {
  path = find_in_parent_folders()
}

dependencies {
  paths = ["../database", "../users"]
}

inputs = {
  database_name = "my_database"

  administrator_username   = "admin"
  administrator_allow_host = "%"

  application_user_username   = "appuser"
  application_user_allow_host = "%"
}

dependenciesでは、依存するモジュールを複数定義できます。

Configuration Blocks and Attributes / dependencies

依存グラフは、このように変化しました。

$ terragrunt graph-dependencies
digraph {
    "database" ;
    "grants" ;
    "grants" -> "database";
    "grants" -> "users";
    "users" ;
}

ところで、権限用モジュールのVariableはデータベースおよびユーザー用のモジュールのOutputの値をそのまま使うことができます。

これをうまく利用する方法も、Terragrantは備えています。

Execute Terraform commands on multiple modules at once / Passing outputs between modules

dependenciesdependencyに変更し、依存するモジュールに対して名前を与えます。モジュールへのパスはconfig_path
指定するように変更します。

Configuration Blocks and Attributes / dependency

grants/terragrunt.hcl

include {
  path = find_in_parent_folders()
}

dependency "database" {
  config_path = "../database"
}

dependency "users" {
  config_path = "../users"
}

inputs = {
  database_name = dependency.database.outputs.database_name

  administrator_username   = dependency.users.outputs.administrator_username
  administrator_allow_host = dependency.users.outputs.administrator_allow_host

  application_user_username   = dependency.users.outputs.application_user_username
  application_user_allow_host = dependency.users.outputs.application_user_allow_host
}

dependenciesと異なり、dependencyでは他のモジュールのOutputを利用することができます。

他のモジュールのOutputを利用するには、dependency.[dependencyの名前].outputs.[Output名]で指定できます。

  database_name = dependency.database.outputs.database_name

依存グラフを、もう1度見てみましょう。

$ terragrunt graph-dependencies
digraph {
    "database" ;
    "grants" ;
    "grants" -> "database";
    "grants" -> "users";
    "users" ;
}

図にもしてみます。こちらにも、依存関係が表現されていますね。Terragruntは、この依存関係の下から適用していくことになります。

$ terragrunt graph-dependencies | dot -Tsvg > graph.svg

f:id:Kazuhira:20210227154612p:plain

再度、run-all planを実行。

$ terragrunt run-all plan

こちらにも、依存関係が反映されています。

INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [/path/to/database, /path/to/users])
  => Module /path/to/users (excluded: false, dependencies: []) 

その他の内容。

An execution plan has been generated and is shown below.
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # mysql_database.app will be created
  # mysql_user.administrator_user will be created
  + resource "mysql_database" "app" {
  + resource "mysql_user" "administrator_user" {
      + host               = "%"
      + default_character_set = "utf8mb4"
      + id                 = (known after apply)
      + default_collation     = "utf8mb4_ja_0900_as_cs_ks"
      + id                    = (known after apply)
      + name                  = "my_database"
    }

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

Changes to Outputs:
  + database_name = "my_database"

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

      + plaintext_password = (sensitive value)
      + tls_option         = "NONE"
      + user               = "admin"
    }

  # mysql_user.application_user will be created
  + resource "mysql_user" "application_user" {
      + host               = "%"
      + id                 = (known after apply)
      + plaintext_password = (sensitive value)
      + tls_option         = "NONE"
      + user               = "appuser"
    }

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

Changes to Outputs:
  + administrator_allow_host    = "%"
  + administrator_password      = (sensitive value)
  + administrator_username      = "admin"
  + application_user_allow_host = "%"
  + application_user_password   = (sensitive value)
  + application_user_username   = "appuser"

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

WARN[0000] Could not parse remote_state block from target config /path/to/database/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 
WARN[0000] Could not parse remote_state block from target config /path/to/users/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 
ERRO[0000] Module /path/to/grants has finished with an error: /path/to/users/terragrunt.hcl is a dependency of /path/to/grants/terragrunt.hcl but detected no outputs. Either the target module has not been applied yet, or the module has no outputs. If this is expected, set the skip_outputs flag to true on the dependency block.  prefix=[/path/to/grants] 
INFO[0000]                                              
INFO[0000]                                              
INFO[0000]                                              
ERRO[0000] Encountered the following errors:
/path/to/users/terragrunt.hcl is a dependency of /path/to/grants/terragrunt.hcl but detected no outputs. Either the target module has not been applied yet, or the module has no outputs. If this is expected, set the skip_outputs flag to true on the dependency block. 
ERRO[0000] Unable to determine underlying exit code, so Terragrunt will exit with error code 1 

ちょっと警告が出ていますね…。

WARN[0000] Could not parse remote_state block from target config /path/to/database/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 
WARN[0000] Could not parse remote_state block from target config /path/to/users/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 

あと、エラーもあります。こちらは、planの時点では他のモジュールのOutputが決まっていない場合があるからです。
まだapplyもしていませんからね。

ERRO[0000] Module /path/to/grants has finished with an error: /path/to/users/terragrunt.hcl is a dependency of /path/to/grants/terragrunt.hcl but detected no outputs. Either the target module has not been applied yet, or the module has no outputs. If this is expected, set the skip_outputs flag to true on the dependency block.  prefix=[/path/to/grants] 

Terragruntは、この事象を回避するために、他のモジュールのOutputが利用できない時にモックを使う機能もあります。

Execute Terraform commands on multiple modules at once / Passing outputs between modules / Unapplied dependency and mock outputs

今回はこちらは気にせず、そのままrun-all applyしてみましょう。

$ terragrunt run-all apply

こちらは成功します。outputもまとめて見てみましょう。

$ terragrunt run-all output
INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [/path/to/database, /path/to/users])
  => Module /path/to/users (excluded: false, dependencies: []) 
administrator_allow_host = "%"
administrator_password = <sensitive>
administrator_username = "admin"
application_user_allow_host = "%"
application_user_password = <sensitive>
application_user_username = "appuser"
database_name = "my_database"
WARN[0000] Could not parse remote_state block from target config /path/to/users/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 
WARN[0000] Could not parse remote_state block from target config /path/to/database/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 
administrator_allow_host = "%"
administrator_privileges = toset([
  "ALL",
])
administrator_username = "admin"
application_user_allow_host = "%"
application_user_privileges = toset([
  "DELETE",
  "INSERT",
  "SELECT",
  "UPDATE",
])
application_user_username = "appuser"

こちらもplanの時などと同じく警告が出ています。
よくよく見てみると、「Remote Stateが使えないからOutputにフォールバックしたよ」と言っていますね。

WARN[0000] Could not parse remote_state block from target config /path/to/users/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 
WARN[0000] Could not parse remote_state block from target config /path/to/database/terragrunt.hcl  prefix=[/path/to/grants] 
WARN[0000] Falling back to terragrunt output.            prefix=[/path/to/grants] 

これは、Terragruntの依存関係の定義を使ってOutputを利用しようとすると、利用できるならRemote Stateを使い、そうでない場合は
Outputを利用する、ということを言っているようです。

とりあえずrun-all applyはできたので、run-all destroyします。依存関係を定義しなかった時とは違って、今度はうまくいきます。

$ terragrunt run-all destroy

これで、やりたいことは達成できました。

Remote Stateを導入する

最後に、警告されたままなのもなんなので、Remote Stateを導入してみましょう。

トップレベルのterragrunt.hclファイルに、ConsulをBackendとする定義を追加します。

terragrunt.hcl

remote_state {
  backend = "consul"

  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }

  config = {
    address = "172.17.0.3:8500"
    scheme  = "http"
    path    = "terraform/state/mysql/${path_relative_to_include()}"
    lock    = true
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "mysql" {
  endpoint = "172.17.0.2:3306"
  username = "root"
  password = "password"
}
EOF
}

今回はRemote Stateの保存先として、Consul 1.9.3を選びました。Consulが動作するサーバーは、172.17.0.3です。

Backend Type: consul - Terraform by HashiCorp

Backendが変わったので、init -reconfigureが必要になります。こちらも、run-allで一気に実行できます。

$ terragrunt run-all init -reconfigure

run-all apply

$ terragrunt run-all apply

(先ほどの例ではapplyの方は載せていませんが…)今度は、警告が出なくなります。

INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [/path/to/database, /path/to/users])
  => Module /path/to/users (excluded: false, dependencies: []) 
Are you sure you want to run 'terragrunt apply' in each folder of the stack described above? (y/n) y
mysql_database.app: Refreshing state... [id=my_database]
mysql_user.application_user: Refreshing state... [id=appuser@%]
mysql_user.administrator_user: Refreshing state... [id=admin@%]

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

database_name = "my_database"

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

administrator_allow_host = "%"
administrator_password = <sensitive>
administrator_username = "admin"
application_user_allow_host = "%"
application_user_password = <sensitive>
application_user_username = "appuser"
mysql_grant.administrator_user: Creating...
mysql_grant.application_user: Creating...
mysql_grant.administrator_user: Creation complete after 0s [id=admin@%:`my_database`]
mysql_grant.application_user: Creation complete after 0s [id=appuser@%:`my_database`]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

administrator_allow_host = "%"
administrator_privileges = toset([
  "ALL",
])
administrator_username = "admin"
application_user_allow_host = "%"
application_user_privileges = toset([
  "DELETE",
  "INSERT",
  "SELECT",
  "UPDATE",
])
application_user_username = "appuser"

outputも見てみましょう。

$ terragrunt run-all output
INFO[0000] Stack at /path/to:
  => Module /path/to/database (excluded: false, dependencies: [])
  => Module /path/to/grants (excluded: false, dependencies: [/path/to/database, /path/to/users])
  => Module /path/to/users (excluded: false, dependencies: []) 
administrator_allow_host = "%"
administrator_password = <sensitive>
administrator_username = "admin"
application_user_allow_host = "%"
application_user_password = <sensitive>
application_user_username = "appuser"
database_name = "my_database"
administrator_allow_host = "%"
administrator_privileges = toset([
  "ALL",
])
administrator_username = "admin"
application_user_allow_host = "%"
application_user_privileges = toset([
  "DELETE",
  "INSERT",
  "SELECT",
  "UPDATE",
])
application_user_username = "appuser"

警告が出なくなったので、スッキリしましたね。

後片付け。

$ terragrunt run-all destroy

まとめ

Terragruntを使って、複数のモジュールに対して一括でapply等の操作が行えることを試してみました。また、依存するモジュールの
Outputを使って、別のモジュールのVariableに利用できるのも良いですね。

ここまでで、Terragruntの基本的な使い方はわかったのかな、という気がします。