Read OSS

Terraformのアーキテクチャ:コードベースの地図

中級

前提知識

  • Goの基礎知識(インターフェース、パッケージ、ゴルーチン)
  • Terraformの基本的な使用経験(リソース、プロバイダー、plan/applyのワークフロー)

Terraformのアーキテクチャ:コードベースの地図

Terraformは世界で最も広く使われているインフラツールの1つですが、その内部がどのように動いているかを実際に調べたエンジニアは意外と少ないものです。コードベースは単一のGoモジュール、いわゆるモノリス構成で、internal/ 配下におよそ65のパッケージが含まれています。このアーキテクチャを理解することで、プロバイダーの問題をデバッグしたり、パッチを貢献したり、Terraformの内部機能を活用したツールを構築したりする力が身につきます。

この記事では、シリーズの以降の記事で深く掘り下げていく際に必要となるメンタルモデルを解説します。main() からプロバイダープラグインの呼び出しまでの経路をたどりながら、主要なパッケージと抽象化の仕組みを整理していきましょう。

起動シーケンス:main.go

すべては main.go#L65-L352 から始まります。realMain() 関数は約290行の直線的な起動処理で、CLIコマンドへディスパッチする前にすべてのサブシステムを初期化します。処理の流れを簡略化すると次のようになります:

flowchart TD
    A["realMain()"] --> B["OpenTelemetry init"]
    B --> C["Logging & panic handler"]
    C --> D["terminal.Init() — detect TTY"]
    D --> E["cliconfig.LoadConfig() — read .terraformrc"]
    E --> F["disco.NewWithCredentialsSource() — service discovery"]
    F --> G["providerSource() — build provider chain"]
    G --> H["backendInit.Init() — register 14 backends"]
    H --> I["extractChdirOption — handle -chdir"]
    I --> J["initCommands() — wire ~40 commands"]
    J --> K["cli.CLI.Run() — dispatch to command"]

この起動シーケンスは意図的に直線的な構造になっています。テレメトリの初期化が最初(70行目)に行われるのは、トップレベルのスパンが実行全体を包み込むようにするためです。端末の検出(113行目)が早い段階で行われるのは、出力フォーマットがstdoutのTTY判定に依存しているためです。CLIの設定読み込み(139行目)は意図的に -chdir の処理よりに行われます。これにより、TERRAFORM_CONFIG_FILE の相対パスが実際のワーキングディレクトリを基準に解決されます。

細かいですが重要な点があります。212行目backendInit.Init(services) は、14個すべての組み込みバックエンドをグローバルマップに登録します。これは起動の早い段階で一度だけ行われ、以降バックエンドが変更されることはありません。バックエンドがプラグイン化されずハードコードされている理由については、Article 5で詳しく取り上げます。

ヒント: Terraformの起動をデバッグする際は、TF_LOG=TRACE を設定すると各ステップのログを確認できます。起動シーケンスには [INFO] および [TRACE] レベルの log.Printf 呼び出しが随所に組み込まれています。

コマンド登録と共有Meta

起動が完了すると、commands.go#L56-L114initCommands() が、すべてのコマンドで共有される command.Meta 構造体を一つ生成します:

meta := command.Meta{
    WorkingDir:          wd,
    Streams:             streams,
    View:                views.NewView(streams).SetRunningInAutomation(inAutomation),
    Services:            services,
    ProviderSource:      providerSrc,
    ProviderDevOverrides: providerDevOverrides,
    UnmanagedProviders:  unmanagedProviders,
    ShutdownCh:          makeShutdownCh(),
    // ...
}

この Meta は、Commands マップ(122〜451行目)のすべてのコマンドファクトリークロージャに渡されます。このマップには "state list""workspace new" などのサブコマンドを含む、約40のエントリが登録されています。

classDiagram
    class Meta {
        +WorkingDir
        +Streams
        +View
        +Services
        +ProviderSource
        +ShutdownCh
    }
    class PlanCommand {
        +Meta
        +Run(args) int
    }
    class ApplyCommand {
        +Meta
        +Destroy bool
        +Run(args) int
    }
    class InitCommand {
        +Meta
        +Run(args) int
    }
    Meta <|-- PlanCommand
    Meta <|-- ApplyCommand
    Meta <|-- InitCommand

特に巧みな設計として、commands.go#L135-L140 に見られるように、destroyDestroy: true を指定した ApplyCommand にすぎません:

"destroy": func() (cli.Command, error) {
    return &command.ApplyCommand{
        Meta:    meta,
        Destroy: true,
    }, nil
},

このように、振る舞いを制御するフラグを使ってコマンドの実装を再利用するパターンは、コードベース全体で広く使われています。豊富なCLI機能を提供しながら、コマンド構造体の数を抑えることができます。

パッケージ構成:internal/ ツリー

Terraformのパッケージは大まかにレイヤー構造で整理されています。主要なものを以下にまとめます:

レイヤー パッケージ 役割
CLI command/, command/views/, command/arguments/ フラグのパース、バックエンドの管理、出力のレンダリング
バックエンド backend/, backend/local/, backend/init/, cloud/ 状態の保存、オペレーションの実行
コアエンジン terraform/ グラフの構築・走査、plan/applyのオーケストレーション
グラフ dag/ 汎用DAGライブラリ:頂点、辺、並列ウォーク
設定 configs/, configs/configload/ HCLのパース、モジュールツリーの組み立て
状態 states/, states/statemgr/ インメモリ状態モデル、永続化、ロック
プラン plans/, plans/planfile/ 変更の追跡、planのシリアライズ
プロバイダー providers/, plugin/, grpcwrap/ プロバイダーインターフェース、gRPCブリッジ、プロトコル変換
ディスカバリー getproviders/ レジストリクライアント、ファイルシステムミラー、マルチソース
アドレス addrs/ 命名システム:Provider、Module、Resource など
診断 tfdiags/ ソース位置情報付きのリッチなエラー/警告
言語 lang/ HCL式の評価、組み込み関数
cty(外部) 設定値のための動的型システム

internal/terraform/ パッケージはTerraformの中核です。このパッケージには、すべてのオペレーションを調整する Context 型、依存関係グラフを構築するグラフビルダー、グラフウォーク中に実行されるノード型、そしてプロバイダーや状態へのアクセスをノードに提供する EvalContext インターフェースが含まれています。

flowchart LR
    CLI["command/"] --> Backend["backend/local/"]
    Backend --> Core["terraform/Context"]
    Core --> GraphBuilder["terraform/GraphBuilder"]
    GraphBuilder --> DAG["dag/"]
    Core --> Providers["providers/Interface"]
    Providers --> GRPC["plugin/GRPCProvider"]
    Core --> State["states/SyncState"]
    Core --> Configs["configs/Config"]

ヒント: addrs パッケージは早めに理解しておく価値があります。その型はコードベース全体で、マップキー、グラフノードの識別子、ターゲティング基準として随所に登場します。addrs.AbsResourceInstanceaddrs.Provider を把握しておくと、ほぼすべてのパッケージを読む際に理解が大幅に深まります。

エンドツーエンドのリクエストフロー:terraform plan

ユーザーが terraform plan を実行したときに何が起きるかを追ってみましょう。このフローはアーキテクチャのすべてのレイヤーに関わるため、最も重要な流れです。

ステップ1:CLIのパース。 internal/command/plan.go#L22-L118PlanCommand.Run()arguments パッケージを使ってフラグをパースし、ビューを作成してバックエンドを準備し、オペレーションリクエストを構築します。

ステップ2:バックエンドへのディスパッチ。 オペレーションはバックエンドの Operation() メソッドに送られます。ローカル実行の場合は、internal/backend/local/backend_plan.go#L23-L27local.opPlan() に処理が渡ります。

ステップ3:コンテキストの生成。 opPlan が設定と状態を読み込み、NewContext()terraform.Context を構築してから Context.Plan() を呼び出します。

ステップ4:グラフの構築。 Plan()internal/terraform/context_plan.go#L180-L194PlanAndEval() に処理を委譲し、PlanGraphBuilder を生成して Build() を呼び出します。

ステップ5:グラフのウォーク。 構築されたグラフは dag.Walker を使って並列にウォークされます。各頂点は Execute() メソッドを実行します。

ステップ6:プロバイダーの呼び出し。 NodePlannableResourceInstance などのリソースノードが、差分を計算するためにgRPC経由で provider.PlanResourceChange() を呼び出します。

sequenceDiagram
    participant User
    participant CLI as PlanCommand
    participant Backend as local.opPlan
    participant Ctx as terraform.Context
    participant Graph as PlanGraphBuilder
    participant Walker as dag.Walker
    participant Node as NodePlannableResourceInstance
    participant Provider as GRPCProvider

    User->>CLI: terraform plan
    CLI->>Backend: RunOperation(opReq)
    Backend->>Ctx: NewContext(opts)
    Backend->>Ctx: Plan(config, state, planOpts)
    Ctx->>Graph: Build(rootModule)
    Graph-->>Ctx: *Graph
    Ctx->>Walker: graph.Walk(walker)
    Walker->>Node: Execute(evalCtx, op)
    Node->>Provider: PlanResourceChange(req)
    Provider-->>Node: planned state + diff
    Node-->>Walker: diagnostics
    Walker-->>Ctx: accumulated changes
    Ctx-->>Backend: *plans.Plan
    Backend-->>CLI: RunningOperation
    CLI-->>User: plan output

このフローがTerraformの骨格です。applyのフローもほぼ同じで、ApplyGraphBuilder を使い provider.ApplyResourceChange() を呼び出す点が異なるだけです。validate、import、refresh もすべて同じパターンに従います:グラフを構築し、ウォークし、結果を収集する、という流れです。

コマンドパターン

すべてのオペレーションコマンド(planapplyrefreshimport)は、同じ構造的パターンに従っています:

  1. 型付きの arguments 構造体にフラグをパースする
  2. コマンド固有のビュー(人間向けまたはJSON)を作成する
  3. PrepareBackend() でバックエンドを準備する
  4. OperationRequest()Operation リクエストを構築する
  5. RunOperation() で実行する
  6. 結果に基づいて終了コードを返す

この一貫した構造のおかげで、1つのコマンドを深く理解すれば、他のコマンドも読み解けるようになります。違いは、使用するグラフビルダー、グラフに追加されるノード型、呼び出されるプロバイダーのメソッドだけです。

この先のシリーズ

この記事では全体の地図を示しました。シリーズの残りの記事では、それぞれの領域を深く掘り下げていきます:

  • Article 2 — グラフエンジンの詳細な解説。汎用DAGライブラリ、並列ウォーカー、planとapplyのグラフを構築するトランスフォーマーパイプライン
  • Article 3 — リソースインスタンスがplan〜applyのライフサイクル全体をどのように処理されるかの追跡
  • Article 4 — プロバイダープラグインシステムとそのgRPCベースのアーキテクチャの探索
  • Article 5 — 状態管理とバックエンドの抽象化の詳細
  • Article 6 — CLIレイヤー、ビューシステム、診断システムの解析
  • Article 7 — 設定の読み込みと式の評価

各記事はここで構築したメンタルモデルを土台としているので、このパッケージマップとリクエストフローの図を手元に置きながら読み進めることをおすすめします。