🍱

Terraformでmoduleを使わずに複数環境を構築する

2023/09/26に公開
3

はじめに

Terraformを使って複数の環境を扱う代表的な方法として、環境ごとにディレクトリを分けつつ、そこから共通のmoduleを呼び出すというものがあります。

本記事ではこれとは異なり、moduleを使わずに複数の環境を取り扱うファイル構成例と、運用して感じている利点について紹介します。

なお、Terraformで取り扱う対象としてAWSを前提とした記述が各所に登場します。ご了承ください。

動作環境

  • Terraform v1.5.3
  • AWS Provider v5.9.0

moduleを使って複数環境を扱うファイル構成例

moduleを使わない構成を紹介する前に、まずmoduleを使う構成例を簡単に解説します。

ファイル構成は概ね以下の通りになるかと思います。

-- <project-name>/
   -- envs/
      -- dev/
         -- backend.tf
         -- providers.tf
         -- versions.tf
         -- main.tf # ここから各moduleを呼ぶ
      -- stg/
         -- backend.tf
         -- providers.tf
         -- versions.tf
         -- main.tf # ここから各moduleを呼ぶ
      -- prod/
         -- backend.tf
         -- providers.tf
         -- versions.tf
         -- main.tf # ここから各moduleを呼ぶ
   -- modules/
      -- <module-name>/
         -- main.tf # 各種resourceを定義
         -- variables.tf
         -- outputs.tf
         -- README.md
      -- (other modules/)

module内に各種resourceを定義しておき、環境別のmain.tfからそれらmoduleを呼び出します。

こうした構成が利用されている背景としては以下が挙げられると思います。

  • moduleを利用することでコードがDRYになるとともに、意図しない環境間の差異を無くせる
  • ローカルから手作業でterraformコマンドを実行するケースにおいて、workspacesを使った場合と比較して、誤って別の環境でコマンドを実行してしまうといった事故が起こりにくい(環境ごとに作業ディレクトリが分かれるため)

moduleを使わずに複数環境を扱うファイル構成例

それでは、ここからはmoduleを使わずに複数環境を取り扱うファイル構成例を紹介します。いくつか空行がありますが、見やすくするために入れているだけで、構成そのものに対する何かの意味はありません。

-- terraform.sh # {env}.tfbackendや{env}.tfvarsを読み込むラッパースクリプト 
-- <project-name>/
   -- backend.tf     # backendブロックを記述
   -- dev.tfbackend  # backendの具体的な設定値を管理
   -- stg.tfbackend  # 同上
   -- prod.tfbackend # 同上

   -- variables.tf # variableの定義を管理
   -- dev.tfvars   # variableの値を管理(Git管理しても問題の無いもののみ)
   -- stg.tfvars   # 同上
   -- prod.tfvars  # 同上

   -- terraform.sh -> ../terraform.sh # Symbolick link

   -- providers.tf
   -- versions.tf 

   -- vpc.tf      # 各種resource定義
   -- ecs.tf      # 同上
   -- ecs_iam.tf  # 同上
   -- (other tfs) # 同上

   -- <component-name>/ # tfstateを分けたいもの(詳細後述)があれば別ディレクトリにする
      -- (some files)      # ファイル構成の考え方は上位ディレクトリと同じ
   -- (other components/)

環境切り替えの実現方法

このファイル構成において、ディレクトリを分けずにどのように環境を切り替えるのかを解説します。

まず、backend.tfには、tfstate保存先としてS3を使うことのみを定義します。

backend.tf
terraform {
  backend "s3" {}
}

そして、tfstate保存先の具体的なS3のバケット名やオブジェクト名、その他backendとして必要な情報は{env}.tfbackendに定義します。

dev.tfbackend
bucket         = "example-dev-terraform-state"
key            = "example.tfstate"
encrypt        = true
profile        = "example-dev" # 使用するAWS Profile名は各開発者間で揃える前提
region         = "ap-northeast-1"
dynamodb_table = "terraform-state-lock"

backendに関しては以上です。

環境ごとの値の使い分けに関しては、variablesを利用します。例えば、envという変数を定義して利用する場合は以下のようになります。

variables.tf
variable "env" {
  type = string
}
# 略
dev.tfvars
env = "dev"
# 略

なお、Terraform向けの標準的な.gitignoreでは、*.tfvarsがGit管理対象外となるように設定されています。そのため、使用する{env}.tfvarsに関してはGit管理対象となるようにします。

.gitignore
  # 略
  *.tfvars
  *.tfvars.json
+ !dev.tfvars
+ !stg.tfvars
+ !prod.tfvars
  # 略

以上のような準備を行なった上で、terraform initコマンド実行時に-backend-configオプションで対応する{env}.tfbackendを読み込ませます。これによりbackendが切り替わります。

terraform init -backend-config=dev.tfbackend

さらにterraform planなどのコマンド実行時には-var-fileオプションで対応する{env}.tfvarsを読み込ませます。

terraform plan -var-file=dev.tfvars

このようにすることで、環境別ディレクトリとmoduleを使うことなく、複数の環境を取り扱うことができます。

複数環境を楽に安全に切り替えるためのラッパースクリプト(terraform.sh)

ただし、毎回-backend-config-var-fileを正しく指定するのは手間ですし、ミスも起こり得るため、複数環境を楽に安全に切り替えるための仕組みはあった方が良いと思います。

今回はTerraformコマンドをラップする50行程度のスクリプトを用意します。

スクリプトの詳細

https://gist.github.com/shonansurvivors/7d8af9f1cdffcd6439936deef1b8b34a

#!/bin/bash
set -euo pipefail

function usage() {
  cat <<EOF
Usage: [TF_SKIP_INIT=boolean ] $0 [-help] <env> <command> [args]
  env          : environment [stg/prd/...]
  command      : terraform command [plan/apply/state/...]
  args         : subcommand [e.g. state "mv"] and terraform command options (see : terraform <command> -help)
  TF_SKIP_INIT : skip "terraform init"
EOF
}

### =========================== Main ========================== ###

if [ "$1" = '-h' ] || [ "$1" = '-help' ] ; then
  usage
  exit 0
fi

# <command>以降が無い時はエラーとする
if [ $# -lt 2 ] ; then
  echo -e "[ERROR] Invalid parameters\n"
  usage
  exit 128
fi

TF_ENV=$1
TF_COMMAND=$2
TF_ARGS=${@:3}

# set -uしているので、${TF_SKIP_INIT-false}とすることで、${TF_SKIP_INIT}が未定義の場合でもエラーとならずfalseとして扱われるようにしている
if [ "${TF_SKIP_INIT-false}" = true ] ; then
  echo "[INFO] Skip init..."
else
  if [ "${TF_COMMAND}" = 'init' ] ; then
    # shellcheck disable=SC2086
    terraform init \
      -backend-config="${TF_ENV}.tfbackend" \
      -reconfigure \
      ${TF_ARGS} # ./terraform.sh <env> init [args] が実行された時の [args] は、init用向けに指定されたものと解釈し、ここで展開する
    exit 0 # ./terraform.sh <env> init が実行された時はここで終了させる
  else
    terraform init \
      -backend-config="${TF_ENV}.tfbackend" \
      -reconfigure # Do you want to copy existing state to the new backend? を非表示にするため
  fi
fi

# -var-fileオプションの無いコマンドに-var-fileを指定するとエラーになる場合があるので処理を分岐させる
case $TF_COMMAND in
  apply | console | destroy | import | plan | refresh)
    # shellcheck disable=SC2086
    # "${TF_ARGS}"が推奨だが複数引数を指定した時にエラーになるのでダブルクォートは外す
    terraform "${TF_COMMAND}" -var-file="${TF_ENV}.tfvars" ${TF_ARGS};;
  *)
    # shellcheck disable=SC2086
    terraform "${TF_COMMAND}" ${TF_ARGS};;
esac

このスクリプトでは、第一引数に環境名を取り、例えばdev環境でterraform planを行いたい場合は、以下のように実行します。

./terraform.sh dev plan

すると、最初にterraform init -backend-config=dev.tfbackendを実行し、続けてterraform plan -var-file=dev.tfvarsを実行します。

実行結果は以下のようになります。

$ ./terraform.sh dev plan
Initializing the backend...
# 略
Terraform has been successfully initialized!
# 略
No changes. Your infrastructure matches the configuration.
# 略

もしも環境名を省略したり、不正な値を指定したりすると、スクリプトは停止するため安全です。

$ ./terraform.sh plan
[ERROR] Invalid parameters
$ ./terraform.sh qa plan

Initializing the backend...
╷
│ Error: Failed to read file
│ 
│ The file "qa.tfbackend" could not be read.
╵

terraform initだけを行うこともできます。

./terraform.sh dev init

-targetなどの各種オプションも指定できます。

./terraform.sh dev plan -target aws_iam_role.foo

stateコマンドに対するmvなどの各種サブコマンドも使用できます。

./terraform.sh dev state mv aws_iam_role.old aws_iam_role.new

毎回初期処理としてterraform initが走るのが煩わしいと感じる場合は、スキップもできます。

TF_SKIP_INIT=true ./terraform.sh dev plan

なお、Terraformの運用として、ローカルからのplanやapplyを禁止し、本記事の最後に触れている自動plan/applyサービス(Terraform CloudやAtlantis)だけを使ってplanやapplyを行うようにするのであれば、このラッパースクリプトは用意しなくても問題はありません。

moduleを使わないことによって感じる利点

今回紹介した、moduleを使わずに環境を分ける構成を1年以上運用してみて、以下の利点を感じています。

  • 読むべき/書くべきファイル数やコード量が少なく、Terraform初学者やチームの新規参入者がキャッチアップしやすい
  • moduleを設計すること自体が難易度の高い行為であり、そこに労力をかけずに済む
  • terraform consoleをどのresourceに対しても使える

利点1: 読むべき/書くべきファイル数やコード量が少なく、Terraform初学者やチームの新規参入者がキャッチアップしやすい

moduleを使って環境を分ける構成は多くの開発現場で採用され、浸透しているかと思います。

一方で、moduleを使うことで付随する、以下のような要素に関してはこれといったスタンダードが無く、各開発現場によって様々である気がします。

  • moduleの粒度(再利用性をどの程度持たせて運用しているか)
  • moduleのvariablesやoutputsの命名規則やその用途
  • moduleのvariablesやoutputsの型にobjectを採用しているか否か
  • moduleからmoduleを呼び出すことを許容しているか否か

moduleを使わない場合、既存コードを読んだり変更したりする際に上記の要素が絡むことが無いため、その分だけTerraform初学者やチームの新規参入者がキャッチアップしやすくなると感じています。

利点2: moduleを設計すること自体が難易度の高い行為であり、そこに労力をかけずに済む

そもそも、使いやすく保守性の高いmoduleを設計すること自体が非常に難しく、高いスキルが求められる行為だと考えています。

Terraform Registoryで公開されているmoduleはOSSとしてメンテナンスされていることもあり洗練されて使いやすいものが多いですが、それに近いものを自作するには相当な設計力が必要だと思います。

組織でTerraformを運用する上で、moduleを自作することには手を出さずにその分の労力を他のことに使う、というのも1つの選択肢になるかと思います(なお、moduleの設計にチャレンジし、その運用経験から学び、設計力を高めていこうとすることを否定するつもりはありません)。

利点3: terraform consoleをどのresourceに対しても使える

terraform consoleはあまり多用するようなコマンドではなく、存在すら知らない人も多いと思うので、これは本当に些細な利点です。

terraform consoleでは、module内のresourceを見ることができません。

> module.example.aws_iam_role.this
╷
│ Error: Unsupported attribute
│ 
│   on <console-input> line 1:
│   (source code not available)
│ 
│ This object does not have an attribute named "aws_iam_role".
╵

参照するには、moduleからoutputを生やして、そちらを参照する必要があります。moduleを使っていなければ、そうした考慮が不要となります。

その他補足

DRY化の工夫(for_eachの利用)

moduleを利用する理由として、コードをDRYにしたり、組織のポリシー等に沿った設定を強制したりするという点が挙げられるかと思います。

例えば、S3バケットはその設定が様々なresource typeに分かれており、バケットごとに設定を変えないのであれば、各resource typeを1つのmoduleにまとめ、それを呼び出すようにすることは非常に有用です。

ただ、これもmoduleを使わずにDRYにすることは可能です。以下はS3バケット関連のresourceの定義をDRYにした例です。locals内の配列に要素を1つ足せば、同様の設定のS3バケットを新規作成できます。

s3.tf
locals {
  aws_s3_bucket = {
    private = [
      "alb_access_logs",
      "athena_integration_cloudformation_templates",
      "athena_query_results",
      "s3_batch_operations_reports",
      "session_manager_logs",
      "vpc_flow_logs",
    ]
  }
}

resource "aws_s3_bucket" "private" {
  for_each = toset(local.aws_s3_bucket.private)

  # Terraformリソース名はスネークケースで統一しているが
  # AWSリソース名はケバブケースで統一するルールとしているので変換する
  # (そもそもS3バケット名にはアンダースコアは使用不可)
  bucket = format("${var.project_name}-${var.env}-%s", replace(each.key, "_", "-"))
}

resource "aws_s3_bucket_ownership_controls" "private" {
  for_each = toset(local.aws_s3_bucket.private)

  bucket = aws_s3_bucket.private[each.key].id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "private" {
  for_each = toset(local.aws_s3_bucket.private)

  bucket = aws_s3_bucket.private[each.key].id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "private" {
  for_each = toset(local.aws_s3_bucket.private)

  bucket = aws_s3_bucket.private[each.key].id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }

    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_versioning" "private" {
  for_each = toset(local.aws_s3_bucket.private)

  bucket = aws_s3_bucket.private[each.key].id

  versioning_configuration {
    status = "Enabled"
  }
}

上記の例では、S3バケット名以外の設定は全て共通のため、S3バケット名(を構成する文字列)のみを配列とすることで実現できています(ただし、for_eachへの入力時にtoset関数を使う必要あり)。

もしも、全体としてはfor_eachを使ってDRYにしつつも、resource単位で一部設定を変えたい箇所があれば、localを配列ではなくobjectにすることで実現できます。

以下は、S3バケットポリシーに関してはバケット単位で異なる設定にする場合の例です。

s3.tf
locals {
  aws_s3_bucket = {
    private = {
      alb_access_logs = {
        policy = jsonencode(
	  {
            # 略
	  }
	)
      }
      athena_integration_cloudformation_templates = {
        policy = jsonencode(
	  {
            # 略
	  }
	)
      }
    }
  }
}

resource "aws_s3_bucket" "private" {
  for_each = local.aws_s3_bucket.private

  bucket = format("${var.project_name}-${var.env}-%s", replace(each.key, "_", "-"))
}

# 略

resource "aws_s3_bucket_policy" "private" {
  for_each = local.aws_s3_bucket.private

  bucket = aws_s3_bucket.private[each.key].id

  policy = each.value["policy"] # each.value.policyと記述してもOK
}

特定の環境にだけresourceを作りたい場合

countの利用

特定の環境にだけresourceを作りたい場合、対象が少量の場合はcountの利用を検討します。以下は、コストを抑えるためにprod環境のみNATゲートウェイをマルチAZ化する例です。

vpc.tf
resource "aws_eip" "nat_c" {
  count = var.env == "prod" ? 1 : 0

  domain = "vpc"

  tags = {
    Name = "${var.project_name}-${var.env}-nat-c"
  }
}

resource "aws_nat_gateway" "c" {
  count = var.env == "prod" ? 1 : 0

  allocation_id = aws_eip.nat_c[0].id
  subnet_id     = aws_subnet.public_c.id

  tags = {
    Name = "${var.project_name}-${var.env}-c"
  }
}

配下に別ディレクトリとして切り出し(コンポーネント化)

ただし、より多くのresourceを特定の環境だけに作りたい場合はcountを多用するとコードの可読性が落ちる懸念があります。

そうしたケースでは、それらresource群をコンポーネントと捉え、配下に別ディレクトリとして切り出します。そして、作成したい環境に関してのみ{env}.tfbackend{env}.tfvarsを配置するようにします。

以下はBIツールを稼働させるためのresourceを、dev環境では作成せず、stg環境とprod環境でのみ作成する場合の例です。dev環境は作成しないので、dev.tfbackenddev.tfvarsは配置していません。

-- <project-name>/
   -- (some files)
   -- bi/ # 新たにbiディレクトリを作成
     -- backend.tf     
     -- stg.tfbackend 
     -- prod.tfbackend 

     -- variables.tf
     -- stg.tfvars   
     -- prod.tfvars  

     -- terraform.sh -> ../../terraform.sh # Symbolick link

     -- providers.tf
     -- versions.tf 

     -- alb.tf      
     -- route53.tf  
     -- ecs.tf      
     -- (other tfs) 
   -- (other components/)

サブネットなどは上位のディレクトリでresource定義されているので、下位のbiディレクトリからはdata sourceとして参照するようにします。

<project-name>/bi/alb.tf
data "aws_subnet" "public_a" {
  filter {
    name   = "tag:Name"
    values = ["${var.project_name}-${var.env}-public-a"]
  }
}

data "aws_subnet" "public_c" {
  filter {
    name   = "tag:Name"
    values = ["${var.project_name}-${var.env}-public-c"]
  }
}

resource "aws_lb" "this" {
  # 略
  subnets = [
    data.aws_subnet.public_a.id,
    data.aws_subnet.public_c.id,
  ]
  # 略

なお、循環参照を避けるため、上位のディレクトリで下位のディレクトリのresourceを参照することはしないようにします。

それでもmoduleを使うケースは?

これまでmoduleを使わないアプローチを一通り説明してきましたが、例外的にmoduleを使うケースがあります。

Terraform Registoryの公開module

1つ目は、Terraform Registoryで公開されているmoduleを使うケースです。

例えばAWS Chatbotを作成する場合は、CloudFormationテンプレートをラップした以下のmoduleを使った方が楽なので利用させてもらっています。

https://github.com/waveaccounting/terraform-aws-chatbot-slack-configuration

その他、Atlantisというセルフホスト側の自動plan/applyサービスの構築にもmoduleを使っています。

https://github.com/terraform-aws-modules/terraform-aws-atlantis

全AWSアカウント共通のセキュリティ設定のmodule

2つ目は、全AWSアカウントで共通のセキュリティ設定などを入れたい場合です。

例えば「アカウントレベルのS3ブロックパブリックアクセス」や「EBSのデフォルト暗号化」等の設定はmodule化し、各AWSアカウントを管理するディレクトリからは共通でこの自作moduleを呼ぶようにしています。

modules/basic_security_global_services/main.tf
# 略
resource "aws_s3_account_public_access_block" "this" {
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
# 略
modules/basic_security_regional_services/main.tf
# 略
resource "aws_ebs_encryption_by_default" "this" {
  enabled = true
}
# 略
base.tf
module "basic_security_global_services" {
  source = "../modules/basic_security_global_services"
}

module "basic_security_regional_services" {
  providers = {
    aws = aws.ap-northeast-1
  }

  source = "../modules/basic_security_regional_services"
}

module "basic_security_regional_services_us_east_1" {
  providers = {
    aws = aws.us-east-1
  }

  source = "../modules/basic_security_regional_services"
}

自動plan/applyサービスの利用について

今回紹介したファイル構成において自動でplan/applyを実行してくれるサービスを利用する場合の設定例について解説します。

代表的なサービスとして、Terraform CloudとAtlantisを取り上げます。

各サービスの運用経験のある方向けの解説としており、個別サービスの概要や仕様については触れません。ご了承ください。

Terraform Cloud

Terraform Cloudでは対象のリポジトリ、ディレクトリを指定したWorkspaceを、環境ごとに作成してください。

例えば、同一のリポジトリ、ディレクトリを対象とするWorkspaceとして、以下を作成します。

  • example-dev
  • example-stg
  • example-prod

{env}.tfvarsに記述していた変数は各WorkspaceのVariablesにTerraform変数として設定し、各tfvarsのファイルは削除するようにしてください。

PRを作成すると、それぞれのWorkspaceで問題なくplanが自動実行されます。

コミットをプッシュする前にローカルのコードでplanを行いたい場合もあると思いますが、そうしたケースに備えてcloud blockを以下のように記述しておきます。

backend.tf
terraform {
  cloud {
    hostname     = "app.terraform.io"
    organization = "my-org"
    # nameは指定しない
  }
}

通常はnameに対象のWorkspace名を指定すると思いますが、今回は1ディレクトリで複数のWorkspaceを扱うため、nameは指定しません。

そして、plan実行時に変数TF_WORKSPACEで対象のWorkspace名を指定するようにします。これにより、任意のWorkspaceでplanを実行できます。

TF_WORKSPACE=example-dev terraform plan

Atlantis

Atlantisでは、Atlantisサーバー側に配置するrepos.yamlで、workflowを以下のようにカスタマイズします。

repos.yaml
repos:
- id: /.*/

  # 略

  allowed_workflows: [dev, stg, prod]

  allow_custom_workflows: false

  # 略

workflows:
  dev:
    plan:
      steps:
      - init:
          extra_args: ["-backend-config=dev.tfbackend", "-reconfigure"]
      - plan:
          extra_args: [-var-file=dev.tfvars]
  stg:
    plan:
      steps:
      - init:
          extra_args: ["-backend-config=stg.tfbackend", "-reconfigure"]
      - plan:
          extra_args: [-var-file=stg.tfvars]
  prod:
    plan:
      steps:
      - init:
          extra_args: ["-backend-config=prod.tfbackend", "-reconfigure"]
      - plan:
          extra_args: [-var-file=prod.tfvars]

そして、Terraformコードを管理するリポジトリ側に配置するatlantis.yamlでは、複数のprojectを定義し、それぞれ同一のdirを指定し、workflowは各環境に対応するものを指定してください。

atlantis.yaml
version: 3
# 略
projects:
  - name: example-dev
    dir: example
    workflow: dev
  - name: example-stg
    dir: example
    workflow: stg
  - name: example-prod
    dir: example
    workflow: prod

これにより、atlantis plan時に、環境に合わせた適切なbackendや変数の値が使用されます。

終わりに

以上、Terraformでmoduleを使わずに複数環境を構築する方法の解説でした。

今後新規にTerraformを導入するプロジェクトがあれば、今回のようなファイル構成についても検討の俎上に載せてもらえたら幸いです。

参考

スマートラウンド テックブログ

Discussion

Masatoshi MizumotoMasatoshi Mizumoto

本記事、参考にさせていただいております。
Terraform 1.5.7,MacbookPro(M1)環境においてラッパースクリプトを利用したところ、
引数が3未満(2つまで)にした際に

TF_ARGS[@]: unbound variable

の結果にて動作しなかったため、以下の変更を施したところ、正常動作を確認しました。

❯ diff ./terraform.sh ./terraform_mod.sh
30c30
< TF_ARGS=("${@:3}")
---
> TF_ARGS="${@:3}"
40c40
<       "${TF_ARGS[@]}" # When ./terraform.sh <env> init [args] is executed, [args] are interpreted as being specified for init, and expanded here
---
>       ${TF_ARGS[@]} # When ./terraform.sh <env> init [args] is executed, [args] are interpreted as being specified for init, and expanded here
52c52
<     terraform "${TF_COMMAND}" -var-file="${TF_ENV}.tfvars" "${TF_ARGS[@]}";;
---
>     terraform "${TF_COMMAND}" -var-file="${TF_ENV}.tfvars" ${TF_ARGS[@]};;
54c54
<     terraform "${TF_COMMAND}" "${TF_ARGS[@]}";;
---
>     terraform "${TF_COMMAND}" ${TF_ARGS[@]};;
Takashi YamaharaTakashi Yamahara

ご指摘ありがとうございます🙇
記事公開にあたり、スクリプトを普段運用しているものから一部変更したことでデグレが生じてしまったようです。

ご提示いただいた修正内容で正常動作することを確認しました。

なお、記事中のスクリプトに関しては私が普段運用しているものと同内容に戻しました。

  # 略

  TF_ENV=$1
  TF_COMMAND=$2
- TF_ARGS=("${@:3}")
+ TF_ARGS=${@:3}
  
  # set -uしているので、${TF_SKIP_INIT-false}とすることで、${TF_SKIP_INIT}が未定義の場合でもエラーとならずfalseとして扱われるようにしている
  if [ "${TF_SKIP_INIT-false}" = true ] ; then
    echo "[INFO] Skip init..."
  else
    if [ "${TF_COMMAND}" = 'init' ] ; then
+     # shellcheck disable=SC2086
      terraform init \
        -backend-config="${TF_ENV}.tfbackend" \
        -reconfigure \
-       "${TF_ARGS[@]}" # ./terraform.sh <env> init [args] が実行された時の [args] は、init用向けに指定されたものと解釈し、ここで展開する
+       ${TF_ARGS} # ./terraform.sh <env> init [args] が実行された時の [args] は、init用向けに指定されたものと解釈し、ここで展開する
      exit 0 # ./terraform.sh <env> init が実行された時はここで終了させる
    else
      terraform init \
        -backend-config="${TF_ENV}.tfbackend" \
        -reconfigure # Do you want to copy existing state to the new backend? を非表示にするため
    fi
  fi
  
  # -var-fileオプションの無いコマンドに-var-fileを指定するとエラーになる場合があるので処理を分岐させる
  case $TF_COMMAND in
    apply | console | destroy | import | plan | refresh)
+     # shellcheck disable=SC2086
+     # "${TF_ARGS}"が推奨だが複数引数を指定した時にエラーになるのでダブルクォートは外す
-     terraform "${TF_COMMAND}" -var-file="${TF_ENV}.tfvars" "${TF_ARGS}";;
+     terraform "${TF_COMMAND}" -var-file="${TF_ENV}.tfvars" ${TF_ARGS};;

    *)
+     # shellcheck disable=SC2086
-     terraform "${TF_COMMAND}" "${TF_ARGS}";;
+     terraform "${TF_COMMAND}" ${TF_ARGS};;
  esac
Masatoshi MizumotoMasatoshi Mizumoto

返信ありがとうございます。
差替いただいたスクリプトでも動作確認できました。