Read OSS

設定ファイルの読み込みと式の評価:HCLから cty.Value へ

中級

前提知識

  • 第1回:アーキテクチャとコードベースの読み方
  • 第2回:グラフエンジンと DAG ウォーク(評価タイミングの理解に必要)
  • HCL 構文の基本的な知識

設定ファイルの読み込みと式の評価:HCL から cty.Value へ

このシリーズでは、グラフの構築やウォークが始まる前に設定がすでに存在しているものとして話を進めてきました。しかし実際には、ディスク上の .tf ファイルからプロバイダー呼び出しを駆動する cty.Value が得られるまでには、洗練されたパイプラインが存在します。そのパイプラインは、仮想ファイルシステム抽象化を経由した HCL パース、モジュールツリーの組み立て、豊富な Go 型モデルによる設定表現から構成されています。そして何より重要なのは、パース時ではなくグラフウォーク中に行われる式の遅延評価です。

この遅延評価こそが、Terraform の依存関係システムを支える仕組みです。リソースの設定はすぐに AST としてパースされますが、その設定内の式が持つは、グラフウォークがそのリソースのノードに到達するまで解決されません。そのタイミングまでには、上流の依存関係がすべて評価済みになっています。

Parser:HCL と仮想ファイルシステム

設定の読み込みは、internal/configs/parser.go#L20-L30 にある Parser から始まります。

type Parser struct {
    fs               afero.Afero
    p                *hclparse.Parser
    allowExperiments bool
}

afero.Afero という仮想ファイルシステム抽象化を採用しているのは意図的な設計です。これにより、インメモリファイルシステムを使ったテスト、アーカイブからの設定読み込み(プランファイルには設定が埋め込まれています)、そして将来的なリモート設定ソースへの対応——これらすべてをパースロジックを変えることなく実現できます。

flowchart TD
    FS["Filesystem (afero)"] -->|"ReadFile"| Parser["configs.Parser"]
    Parser -->|".tf files"| HCLNative["hclparse.ParseHCL"]
    Parser -->|".tf.json files"| HCLJSON["hclparse.ParseJSON"]
    HCLNative --> Body["hcl.Body"]
    HCLJSON --> Body
    Body --> Decode["Decode into Go structs"]
    Decode --> File["configs.File"]
    File -->|"multiple files"| Module["configs.Module"]

実際の HCL パースは hclparse.Parser に委譲されます。59〜80行目LoadHCLFile メソッドは、ファイルの拡張子に基づいてフォーマットを判別します。.json ファイルは HCL の JSON 構文として、それ以外はネイティブの HCL 構文として処理されます。Terraform がネイティブ HCL と JSON のどちらでも設定を書ける理由はここにあります。パーサーが両方を同じ hcl.Body 表現に正規化するのです。

allowExperiments フラグは、試験的な言語機能へのアクセスを制御します。アルファリリースのビルド時には true に設定され、for_each のモジュール対応(正式リリース前)や新機能が利用可能になります。このフラグはパース時と使用時の両方でチェックされており、多層的な防御を実現しています。

ヒント: パーサーは hclparse.Parser を通じて読み込んだファイルをすべて内部キャッシュに保持しています。このキャッシュは後にエラーメッセージのソースコードスニペット生成に使われます。エラーメッセージにソースコードの抜粋が表示されるのは、このキャッシュのおかげです。

Module モデル:単一ディレクトリの内容

個々のファイルをパースした後、それらは internal/configs/module.go#L19-L63Module にマージされます。

type Module struct {
    SourceDir            string
    CoreVersionConstraints []VersionConstraint
    ActiveExperiments    experiments.Set
    Backend              *Backend
    StateStore           *StateStore
    CloudConfig          *CloudConfig
    ProviderConfigs      map[string]*Provider
    ProviderRequirements *RequiredProviders
    Variables            map[string]*Variable
    Locals               map[string]*Local
    Outputs              map[string]*Output
    ModuleCalls          map[string]*ModuleCall
    ManagedResources     map[string]*Resource
    DataResources        map[string]*Resource
    EphemeralResources   map[string]*Resource
    ListResources        map[string]*Resource
    Actions              map[string]*Action
    Moved                []*Moved
    Removed              []*Removed
    Import               []*Import
    Checks               map[string]*Check
    Tests                map[string]*TestFile
}
classDiagram
    class Module {
        +SourceDir string
        +Backend *Backend
        +ProviderConfigs map[string]*Provider
        +Variables map[string]*Variable
        +Locals map[string]*Local
        +Outputs map[string]*Output
        +ModuleCalls map[string]*ModuleCall
        +ManagedResources map[string]*Resource
        +DataResources map[string]*Resource
        +EphemeralResources map[string]*Resource
        +ListResources map[string]*Resource
        +Actions map[string]*Action
        +Moved []*Moved
        +Removed []*Removed
        +Import []*Import
    }
    class Resource {
        +Mode ResourceMode
        +Name string
        +Type string
        +Config hcl.Body
        +Count hcl.Expression
        +ForEach hcl.Expression
        +ProviderConfigRef *ProviderConfigRef
        +DependsOn []hcl.Traversal
    }
    class Variable {
        +Name string
        +Type cty.Type
        +Default cty.Value
        +Validation []*CheckRule
        +Ephemeral bool
    }
    Module --> Resource
    Module --> Variable

Module は単一ディレクトリの内容を表します。同じディレクトリ内のすべての .tf ファイルが一つの Module にマージされます。複数のファイルにリソースを分散して書いても互いに参照し合えるのはこのためです。

注目すべきは、Resource.Config が デコード済みの Go 構造体ではなく hcl.Body であるという点です。これが遅延評価の鍵で、リソースの属性値はこの段階では未評価の HCL 式のまま保持されます。実際に評価されるのはグラフウォーク中——つまり、参照している変数や他リソースの値が確定したタイミングです。

リソースが ManagedResourcesDataResourcesEphemeralResourcesListResources に分かれているのは、Terraform が現在サポートする4つのリソースモードを反映しています。それぞれライフサイクルのセマンティクスは異なりますが、設定の型としては同じ Resource を共有しています。

Config ツリー:モジュール階層の組み立て

個々の Module は、internal/configs/config.go#L32-L97 にある Config 型を通じてツリーとして組み立てられます。

type Config struct {
    Root     *Config
    Parent   *Config
    Path     addrs.Module
    Children map[string]*Config
    Module   *Module
    // source info, version constraints...
}
classDiagram
    class Config {
        +Root *Config
        +Parent *Config
        +Path addrs.Module
        +Children map[string]*Config
        +Module *Module
        +Version *version.Version
    }
    Config --> Config : Root, Parent
    Config --> Config : Children[name]
    Config --> Module : Module

ツリーの組み立てを担うのは config_build.go です。ルートモジュールの module 呼び出しを処理し、ローカルパス、レジストリ、その他のソースから子モジュールを再帰的に読み込みます。Root フィールドは常にルートの Config を指し、Parent は呼び出し元モジュールを指します。Path フィールド(型は addrs.Module)は静的なモジュールパスを保持します——例えば module.network.module.vpc のような形式です。

このツリー構造は静的なものです。設定として宣言されたモジュール階層を表しており、グラフウォーク時に countfor_each によって複数のインスタンスへ展開されると、動的な addrs.ModuleInstance パスが生成されます。この展開を担うのが、第2回で紹介した ModuleExpansionTransformer です。

アドレスシステム

addrs パッケージはコードベース全体の命名基盤を提供しています。プロバイダー、モジュール、リソース、変数、出力——Terraform のアドレス指定可能なオブジェクトにはそれぞれ対応する型があります。

用途
addrs.Provider registry.terraform.io/hashicorp/aws プロバイダーの識別と FQN
addrs.Module module.network.module.vpc 静的なモジュールパス
addrs.ModuleInstance module.network[0].module.vpc キーを含む動的なモジュールインスタンス
addrs.Resource aws_instance.web モジュール内のリソース
addrs.ResourceInstance aws_instance.web[0] count/for_each キーを持つインスタンス
addrs.AbsResourceInstance module.network.aws_instance.web[0] 完全修飾インスタンス
addrs.Reference 任意のアドレス可能オブジェクトへの参照 ReferenceTransformer で使用

internal/addrs/provider.go#L15 にある addrs.Provider 型は、外部パッケージ terraform-registry-addresstfaddr.Provider のエイリアスです。

type Provider = tfaddr.Provider

これらのアドレス型は、状態やグラフノードレジストリのマップキーとして、-target フラグのターゲット指定として、そして参照解決の基盤として使われます。人間が読める文字列への変換には String() が実装されており、等値比較もサポートしているためマップキーに適しています。

ヒント: Terraform のソースコードを読む際は、関数が受け取るのが addrs.Resource(静的、インスタンスキーなし)なのか addrs.AbsResourceInstance(モジュールパスとインスタンスキーを含む完全修飾)なのかに注意してください。アドレスのレベルを誤って渡すことは、バグの典型的な原因の一つです。

遅延式評価:lang.Scope と cty

Terraform の設定システムで最も重要な概念は、式はパース時に評価されないということです。リソースの設定、変数のデフォルト値、出力値などに格納されている hcl.Expression は、グラフウォーク中に lang.Scope 型を通じて遅延評価されます。

評価のパイプラインは次のようになっています。

sequenceDiagram
    participant Parser as configs.Parser
    participant Module as configs.Module
    participant Graph as PlanGraphBuilder
    participant Walk as Graph Walk
    participant Node as ResourceNode
    participant Scope as lang.Scope
    participant Provider as Provider

    Parser->>Module: Parse .tf files
    Note over Module: Stores hcl.Expression<br/>(unevaluated AST)
    Module->>Graph: Config input
    Graph->>Walk: Built graph
    Walk->>Node: Execute(evalCtx)
    Node->>Scope: EvalBlock(config, schema)
    Scope->>Scope: Resolve references<br/>(var.x, local.y, aws_vpc.main.id)
    Scope-->>Node: cty.Value (resolved)
    Node->>Provider: PlanResourceChange(resolvedConfig)

NodePlannableResourceInstance のようなグラフノードが実行されると、プロバイダーに送る「変更案の新状態」を生成するためにリソースの設定を評価する必要があります。この処理は EvalContext を通じて行われ、以下の情報を持つ lang.Scope にアクセスします。

  • 入力変数の値PlanOpts.SetVariables またはデフォルト値から解決
  • ローカル値 — それぞれの式から計算
  • 子モジュールの出力値 — そのモジュールの処理完了後に利用可能
  • リソースの属性 — 対応するリソースのノード完了後に利用可能
  • 組み込み関数length()lookup()format() など

internal/terraform/eval_variable.go での変数評価と、internal/terraform/evaluate_data.go でのデータ評価を見ると、この仕組みが具体的にどう動くかがわかります。重要なのは、グラフの依存エッジが評価順序を保証しているという点です。リソース B がリソース A を参照している場合、ReferenceTransformer が A のノードを B のノードより先に完了させます。B が式を評価するタイミングには、A の属性値がすでにスコープに存在しているわけです。

cty(「シーティ」と読みます)型システムは、Terraform の動的な値表現を提供する外部ライブラリです。Go の静的型システムとは異なり、cty.Value は文字列、数値、真偽値、リスト、マップ、オブジェクト、セットといった Terraform のあらゆる型を表現できます。さらに、cty.UnknownVal(apply 後にしか確定しない計算済み属性を表す)や cty.NullVal(値が存在しないことを表す)といった特殊な値もサポートしています。

特に興味深いのが Unknown 値です。プランニング中、多くのリソース属性は unknown のままになります。たとえば EC2 インスタンスの ID は apply 後にしか確定しません。Terraform はこの unknown を式評価を通じて伝播させます。別のリソースの設定で aws_instance.web.id を参照しており、その ID が unknown の場合、下流の式も unknown として評価されます。これにより、まだ作成されていないインフラに依存するリソースもプランできるのです。

まとめ

7本の記事を通じて、Terraform のアーキテクチャを外側のシェルから内側のコアまで追いかけてきました。

  1. 第1回 ではコードベース全体を俯瞰しました:起動シーケンス、コマンド登録、パッケージ構成、エンドツーエンドのリクエストフロー。
  2. 第2回 ではグラフエンジンを解剖しました:汎用 DAG ライブラリ、並列ウォーカー、Transformer パイプライン。
  3. 第3回 では一つのリソースがプランとアプライのライフサイクルをたどる過程を追いました:Context のオーケストレーション、ノードの実行、フックシステム。
  4. 第4回 ではプロバイダープラグインシステムを探索しました:gRPC 通信、プロトコルバージョン、3層の抽象化。
  5. 第5回 では状態とバックエンドを詳しく見ました:3層の状態モデル、バックエンドインターフェースの階層、マイグレーションフロー。
  6. 第6回 では CLI レイヤーを解説しました:コマンド構造、人間向け/JSON 向け出力の View、診断システム。
  7. 本記事 では設定の読み込みと遅延式評価によって全体像を締めくくりました。

これらすべてをつなぐ共通の軸はグラフです。設定はモジュールツリーとしてパースされ、グラフの構築に供給されます。グラフの頂点であるリソースノードは、ウォーク時に設定の式を遅延評価してプロバイダーを呼び出します。その結果は状態と変更セットに流れ込みます。CLI レイヤーはフックを通じて実行を観察し、View を通じて出力をレンダリングします。バックエンドが状態を永続化し、診断システムがあらゆるレイヤーを越えて豊富なエラー情報を運びます。

10年を超えるプロジェクトでありながら、Terraform のアーキテクチャは際立って一貫性を保っています。providers.InterfaceGraphTransformerEvalContextstatemgr.Fulltfdiags.Diagnostics といった抽象化は、明確に定義されたコントラクトのセットを形成しています。これらがコアエンジンの複雑さをコントロールしながら、Terraform を取り巻く巨大なエコシステムを支えています。このコントラクトを理解することが、このコードベースを効果的に扱うための鍵です。