🦉

NixとTaskで作るTerraform環境開発ガイド

に公開

対象読者

Nixの中級者以上を対象としています。Nixの説明は本記事の範囲外とさせていただきます。

Asahiさんの記事が非常に分かりやすいです。初心者の方は、次の記事を参照してから本記事を読むことを推奨します。

https://zenn.dev/asa1984/books/nix-introduction

https://zenn.dev/asa1984/books/nix-hands-on

本記事で何をするか

本記事ではNixの機能の一つである nix develop と Taskfile を用いて、Terraformの開発環境を構築します。すなわち、ステージング環境と本番環境のシェルを作成します。

これらの環境は分割されており、ステージング環境で本番環境用のコマンドが起動したり、本番環境でステージング環境用のコマンドが起動することはありません。

また、クラウドプロバイダーの認証を通すのは面倒です。シェルが起動したタイミングで認証を通すようにします。尚、本記事ではGoogle Cloudをプロバイダーとして用います。

最終的に以下のようなディレクトリ構成になります。

.
├── environments
│   ├── production
│   │   └── workspace_a
│   │       ├── main.tf
│   │       ├── providers.tf
│   │       └── terraform.tf
│   └── staging
│       └── workspace_a
│           ├── main.tf
│           ├── providers.tf
│           └── terraform.tf
├── flake.lock
├── flake.nix
├── modules
│   └── module_a
│       ├── README.md
│       └── main.tf
├── README.md
├── script
│   ├── core
│   │   └── logger.sh
│   ├── entry_point
│   │   ├── shell.sh
│   │   └── terraform.sh
│   └── setup
│       └── google.sh
├── Taskfile.yml
├── tasks
│   ├── production.yml
│   └── staging.yml
└── treefmt.toml

プリインストール

本記事のコードを実行するには次のソフトウェアが必要です。

Shellを作成する

それでは早速 Terraform用のShellを作成します。

{
  description = "Development environment for Terraform project";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
  };

  outputs = { self, nixpkgs }:
    let
      system = "aarch64-darwin"; # お手元のホストOS
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      devShells.${system}.default = pkgs.mkShell {
        # 追加するパッケージ
        packages = [
          pkgs.google-cloud-sdk
          pkgs.terraform
        ];
      };
    };
}

パッケージを追加するには、 packages にNixのパッケージ名称を記述します。Nixのパッケージは次の公式サイトから検索できます。

https://search.nixos.org/packages

上記の内容を flake.nix に記述して次のコマンドを実行するとDevShellが起動します。

nix develop --impure

--impure は純粋ではないことを表します。これはTerraformを利用するためです。

Google Cloudの認証をシェル起動時に通す

この章ではGoogle Cloudの認証をシェルの起動時に通す設定をします。具体的にはShellScriptを作成し、flake.nixmkShell にHookを追加します。

ロガーを追加

ShellScriptを使う上でLoggerがほしいので作成します。

#!/bin/bash -euo pipefail

log() {
  echo "$(date +'%Y-%m-%d %H:%M:%S') [INFO] 🚀 - $1"
}

ok() {
  echo "$(date +'%Y-%m-%d %H:%M:%S') [OK] ✅ - $1"
}

err() {
  echo "$(date +'%Y-%m-%d %H:%M:%S') [ERROR] 🚨 - $1"
}

Google認証

次に認証する関数を作成します。ここでは、認証、プロジェクトの設定をします。

尚、ここでの認証とは Google AccountやService Account, Workload Identityなどのプリンシパルに対する認証を指します。アプリケーションデフォルトログイン (ADC)とは異なることに注意ください。

#!/bin/bash
set -euo pipefail

source ./script/core/logger.sh

google_login() {
  log "Google Cloudにログインが必要か確認します"
  if gcloud auth list --filter=status:ACTIVE --format="value(account)" &>/dev/null; then
    ok "Google Cloudはすでにログイン状態でした。"
    return 0
  fi

  log "Google Cloudにログインします"
  if gcloud auth login; then
    ok "Google Cloudのログインが完了しました"
  else
    err "Google Cloudのログインに失敗しました"
    exit 1
  fi
}

google_set_project_id() {
  log "Google Cloudのプロジェクトを設定します: $PROJECT_ID"
  if gcloud config set project "$PROJECT_ID"; then
    ok "Google Cloudのプロジェクトを設定しました: $PROJECT_ID"
  else
    err "Google Cloudのプロジェクト設定に失敗しました: $PROJECT_ID"
    exit 1
  fi
}

エントリーポイントの作成

ここまででShellScriptの処理を担当する関数が完成したので、Entry Pointを作っていきます。

./script/entry_point/shell.sh を下記のように作成します。

#!/bin/bash
set -euo pipefail

# 利用する関数が記述されているファイルを読みこみ
source ./script/core/logger.sh
source ./script/setup/google.sh

# Google Cloudのセットアップ
log "Google SDK の準備を開始します"
google_login
google_set_project_id
echo "🎉 セットアップが完了しました"

# ツールが入っているか確認 (任意)
TOOLS=(gcloud terraform)
for tool in "${TOOLS[@]}"; do
    ok "$tool => $($tool --version)"
done

DevShell起動時にエントリーポイントを発火

Entry Pointを作成できたので、DevShellの起動時に発火するようにします。mkShell の中で shellHook にエントリーポイントの発火を登録します。

また、Google Cloudプロジェクトを環境変数で利用するのでexportを事前にしておきましょう。

{
  # ...
  outputs = { self, nixpkgs }:
    let
      # ...
    in {
      devShells.${system}.default = pkgs.mkShell {
        # ...
        # ここにエントリーポイントを追加
        shellHook = ''
          export PROJECT_ID="your_google_cloud_project_id"
          bash script/entry_point/shell.sh
        '';
      };
    };
}

これで、DevShellの起動時にGoogle認証とプロジェクト設定が自動で走ります。

速学: Task

Taskはタスクランナーまたはビルドツールです。

TaskはGNU Make などのツールの代替として、よりシンプルで使いやすいことを目指して設計されています。具体的には、シングルバイナリで動作や、YAML形式での記述といった特徴があります。

この章では本記事で利用するTaskファイルを読みとくために必要な要素を紹介します。

実行方法の例

Taskfile.yml にタスクを定義した後、ターミナルで task コマンドを使って実行します。

Taskfile.yml の例:

version: '3'

tasks:
  hello:
    desc: "Hello Worldを出力するタスク" # タスクの説明
    cmds:
      - echo 'Hello World from Task!'

実行コマンド:

task hello

タスク一覧を見たい場合は、task --list または task -l を実行します(desc に記述した説明も表示されます)。

コマンドライン引数

CLIではコマンドラインから値を受けとりたいことが多いと思います。Taskでは {{ .CLI_ARGS }} で取得します。

version: '3'
tasks:
  format:
    desc: "フォーマッターを実行し、ファイル名を引数として渡す"
    cmds:
      # format: の後に渡された引数 (例: 'src/main.js') が ${{.CLI_ARGS}} に展開される
      - prettier --write ${{.CLI_ARGS}}

他のTaskfileを参照

Taskの場合、includes 機能を使うと他のファイルを参照できます。

version: '3'

includes:
  docs: ./documentation # will look for ./documentation/Taskfile.yml
  docker: ./DockerTasks.yml

includes の要素 (docs, docker) はそれぞれ コマンドのprefixになります。(task docs:hoge, task docker:fuga)

ステージング環境と本番環境を分割

ここまでで、プロジェクトが単一のプロジェクトで起動するようになりました。次はステージング環境とプロダクション環境で起動するようにしてみましょう。

複数の環境を切り替え

まず、flake.nix を編集して、環境変数で切り替え可能にします。(impureな操作です)

{
  # ...
  outputs = { self, nixpkgs }:
    let
      system = "aarch64-darwin";
      pkgs = nixpkgs.legacyPackages.${system};
      project_id = builtins.getEnv "GOOGLE_CLOUD_PROJECT_ID"; # 環境変数の読み撮り
    in {
      devShells.${system}.default = pkgs.mkShell {
        packages = [
          pkgs.go-task # 追加
          pkgs.google-cloud-sdk
          pkgs.terraform
        ];

        shellHook = ''
          export PROJECT_ID=${project_id} # 環境変数を起動したシェルに登録
          bash script/entry_point/shell.sh
        '';
      };
    };
}

環境変数で切り替えができるようになりましたが、その環境変数を export コマンドで切り替えるのは面倒です。そこで、Taskfileを使いコマンド化します。

version: '3'

env:
  NIXPKGS_ALLOW_UNFREE: 1

includes:
  stg: tasks/staging.yml
  prd: tasks/production.yml

Taskfileのエントリーポイントでは、tasks/staging.yml, tasks/production.yml のimportと共通で使う環境変数である NIXPKGS_ALLOW_UNFREE を利用します。

tasks/production.yml は次のようにします。

version: '3'

tasks:
  shell:
    desc: "devshell for production"
    env:
      GOOGLE_CLOUD_PROJECT_ID: "shunsock-production"
    cmds:
      - nix develop --impure
    silent: true

tasks/staging.yml は次のようにします。

version: '3'

tasks:
  shell:
    desc: "devshell for staging"
    env:
      GOOGLE_CLOUD_PROJECT_ID: "shunsock-staging"
    cmds:
      - nix develop --impure
    silent: true

これらのファイルを作成したら、コマンドラインで task を動かしてみてください。次のように表示されれば成功です。

$ task
task: Available tasks for this project:
* prd:shell:       devshell for production
* stg:shell:       devshell for staging

Terraformコマンドを起動する

Terraform Commandはプロジェクトルートではなくワークスペースで起動しなけばなりません。そこで、ShellScriptの引数にワークスペースを取るようにしておきます。

#!/bin/bash
set -euo pipefail

source ./script/core/logger.sh

main() {
  WORKSPACE="environments/$INPUT_ENVIRONMENT/$1"
  shift 1 # $@ (`List[str]`) を1つShift

  log "ワークスペースを特定しました: $WORKSPACE"
  cd "$WORKSPACE"

  if terraform "$@"; then
    ok "リソース操作に成功しました"
    return 0
  else
    err "リソース操作に失敗しました"
    return 1
  fi
}

main "$@"

あとは、Taskfileから呼び出せるようにしておきましょう。task/production.ymlにも同様の変更を追加してください。

version: '3'

tasks:
  # ...
  init:
    desc: "terraform init on staging"
    cmds:
      - bash script/entry_point/terraform.sh {{ .CLI_ARGS }} "init"
    silent: true

  plan:
    desc: "terraform plan on staging"
    cmds:
      - bash script/entry_point/terraform.sh {{ .CLI_ARGS }} "plan"
    silent: true

  apply:
    desc: "terraform apply on staging"
    cmds:
      - bash script/entry_point/terraform.sh {{ .CLI_ARGS }} "apply"
    silent: true

これで task stg:init -- workspace でTerraformコマンドを動かせるようになりました。

補足: $@

$@は、現在設定されている位置パラメータ($1, $2, $3, ...)のすべてを表す特別な変数です。ダブルクォートで囲った場合 ("$@"): 各位置パラメータが個々の引用された文字列として展開されます。

例えば、$1="a b", $2="c" の場合、"$@""a b""c" の2つの引数として渡されます。

補足: shift

shiftコマンドは、位置パラメータを左にずらす(移動させる)ために使用します。shiftを実行すると、現在の $1 の値が破棄され、$2 が新しい $1 に、$3 が新しい $2 に、というように番号が振り直されます。

これにより、スクリプト内で処理済みの引数を「削除」できます。また、shiftの後に数値を指定することで、その数だけshift処理を実行できます(例: shift 2)。

ステージング環境で本番環境用のタスクコマンドを不発させる

今のままだと 開発環境で本番環境のコマンドを起動できてしまいます。具体的にはstg:shell した後に prd:apply ができます。事故が起きかねないので修正しましょう。

「TaskfileでDevShellを起動する際に設定する環境変数」と「TaskfileでTerraformを起動する際に使うシェルの引数」を比較します。

version: '3'

tasks:
  shell:
    desc: "devshell for staging"
    env:
      GOOGLE_CLOUD_PROJECT_ID: "shunsock-staging"
      # TaskfileでDevShellを起動する際に設定する環境変数
      SHELL_ENVIRONMENT: "staging"
    cmds:
      - nix develop --impure
    silent: true

  init:
    desc: "terraform init on staging"
    cmds:
      # TaskfileでTerraformを起動する際に使うシェルの引数 を追加
      - bash script/entry_point/terraform.sh "staging" {{ .CLI_ARGS }} "init"
    silent: true

このようにすれば task stg:shell で起動したShellには環境変数 SHELL_ENVIRONMENT=staging が登録されます。

また、task stg:init -- workspace_name で起動した terraform.sh の第一引数は staging です。

最後に terraform.sh 内部で比較しましょう。

#!/bin/bash
set -euo pipefail

source ./script/core/logger.sh

main() {
  INPUT_ENVIRONMENT="$1"
  WORKSPACE="environments/$INPUT_ENVIRONMENT/$2"
  shift 2

  # Shell環境と操作対象のTerraform環境が一致しているか確認
  if [ "$INPUT_ENVIRONMENT" != "$SHELL_ENVIRONMENT" ]; then
    err "Shellの環境($INPUT_ENVIRONMENT) とTerraform($SHELL_ENVIRONMENT) の環境が一致していません"
    exit 1
  fi

  log "ワークスペースを特定しました: $WORKSPACE"
  cd "$WORKSPACE"

  if terraform "$@"; then
    ok "リソース操作に成功しました"
    return 0
  else
    err "リソース操作に失敗しました"
    return 1
  fi
}

main "$@"

これでShellとTerraformの環境が一致したときのみ、Terraformコマンドが起動するようになりました。

おわりに

依存はNixとTaskのみと最小限ながら強力な開発環境を作成できました。また、各モジュールは疎結合を保っているため拡張も容易です。

最高の環境を簡単に作れるので是非挑戦してみてください。

Discussion