既存リソースをTerraformでimportする作業を楽にする

SREチームの今です。

カヤックでは、クラウドリソースの管理にはTerraformを利用することが多いです。
クラウドリソースの構成や設定をコードで管理することで、リソースの変更内容の差分をレビューできる、意図しない設定変更を発見できるなどの利点があり、SREの目的であるサービスを安定して提供する上で重要な要素の一つです。

実際の作業として、既に運用中のサービスを新たにTerraform管理下に置く場合や、多くのリソースが既にweb consoleから作成されているものをTerraform管理下に追加する場合も多いと思います。
その際にはTerraform importをする必要があります。しかし、Terraform importは単純作業とはいえ時間と手間がかかり、優先順位を下げてついつい後回しにしてしまうことも多いのではないでしょうか。

今回は、手作業でTerraform importする作業を軽くするために利用したツール等をご紹介します。
本記事はTerraformを既に扱っている方向けのため、Terraformについての説明は割愛します。

アプローチ: 単純作業の自動化

クラウドリソースがTerraformの管理外で作成、変更された場合には、その理由を明らかにし、管理下に置くかどうか等を判断する必要があります。 そのため、全自動化ではなく単純作業の部分をスクリプトで自動化し、状況に応じて判断が必要なフローに集中できる環境を作ります。

手作業でimportする際の作業の流れは、次の手順になります。

  • importするリソースを列挙する
  • Terraform import
    • cliあるいはweb consoleから対象リソースのIDを調べる
    • providerのドキュメントをを見てimportに必要なIDの形を調べる
  • Terraform state showの結果をtfファイルへコピー
  • Terraform validateのエラーを解決
  • resource間の参照を書く

この一連の作業の部分部分をスクリプト化します。

今回はgcloudコマンドの結果を手軽に利用するために、連想配列を扱うことのできるPythonを選択しました。

検証環境

  • OS: MacOS Monterey 12.1
  • Terraform: 1.1.6
  • Terraform Google Provider: 4.11.0
  • Python: 3.9.10

新規の既存リソースの発見、列挙

Terraform importに必要な情報は、利用する各providerのドキュメントに記載されています。
コマンド引数として渡すものとして、以下の3つが必要です。

  • resource type
  • 定義ファイル上のresource name
  • 既存リソースのID (resource typeによって変わる)

特に既存リソースのIDはresource typeによって変わる上に、トリッキーな表記をするものもあります。体感としてはVPC設定類の関連付けに多くあります。
毎度Terraform providerのドキュメントを読みに行くのも手間なので、aws cliやgcloud cliの出力を利用して自動で生成することを考えました。

例として、GCPのAppEngine firewall ruleとBigQuery datasetをそれぞれgcloudコマンド、bqコマンドから必要となるresource type、resource name、IDの3つの組を生成してみましょう。

例1: AppEngine firewall rule

GAEのfirewallはapps/{{project}}/firewall/ingressRules/{{priority}}をIDとしてimportします。1

firewall rule一覧はgcloud beta app firewall-rules listから得られます。

 % gcloud beta app firewall-rules list --format json
[
  {
    "action": "ALLOW",
    "description": "The default action.",
    "priority": 2147483647,
    "sourceRange": "*"
  }
]

これを利用して、importに利用するIDを作成するスクリプトがこのように書けます。

def get_gae(project_id):
    # firewall rule
    #   resource: google_app_engine_firewall_rule
    #   ID: apps/{{project}}/firewall/ingressRules/{{priority}}
    result = json.loads(exec_command(f"gcloud beta app firewall-rules list --project {project_id} --format json"))
    resources = []
    for rsc in result:
        resources.append({
            "resource": "google_app_engine_firewall_rule",
            "name": sanitize_resource_name(f"priority_{rsc['priority']}"),
            "id": f"apps/{project_id}/firewall/ingressRules/{rsc['priority']}",
        })
    return resources

スクリプト内で利用している関数(exec_command, sanitize_resource_name)

# exec_command cmdを実行した結果を返す
def exec_command(cmd, shell=False):
    if not shell:
        cmd = cmd.split(' ')
    run_result = subprocess.run(cmd, capture_output=True, shell=shell)
    return run_result.stdout.decode("utf-8")

# sanitize_resource_name Terraformのresource nameに使えない文字を_に置換する
def sanitize_resource_name(name):
    return re.sub('[^a-zA-Z0-9_-]', '_', name)

get_gaeの結果が以下のようになります。

[
  {
    'resource': 'google_app_engine_firewall_rule',
    'name': 'priority_2147483647',
    'id': 'apps/xxxxxx/firewall/ingressRules/2147483647'
  }
]

これでimportに必要な情報が揃います。

例2: BigQuery dataset

BigQueryをimportするための引数は、bqコマンドの出力から生成できます。

def get_bq(project_id, location):
    # dataset
    #   resource: google_bigquery_dataset
    #   ID: projects/{{project}}/datasets/{{dataset_id}}
    result = json.loads(exec_command(f"bq ls --project_id {project_id} --format json"))
    resources = []
    for rsc in result:
        resources.append({
            "resource": "google_bigquery_dataset",
            "name": sanitize_resource_name(rsc['datasetReference']['datasetId']),
            "id": f"projects/{rsc['datasetReference']['projectId']}/datasets/{rsc['datasetReference']['datasetId']}",
        })
    return resources

実行して生成される、BigQuery dataset sample_datasetのimportに必要な情報が以下になります。

[
  {
    'resource': 'google_bigquery_dataset',
    'name': 'sample_dataset',
    'id': 'projects/xxxxxx/datasets/sample_dataset'
  },
  ...
]

既に管理下にあるリソースを除外する

生成した既存リソース一覧には、既にTerraformの管理下にあるリソースが含まれます。 再びimportするのを避けるために、既に管理下にあるリソースを除外する必要があります。

terraform showの結果から管理下リソースのIDの一覧が取得できます。例えば、以下のコマンドで一覧を取得することができます。

terraform show -json | jq '.values.root_module.resources[].values.id'

管理下リソースと同じIDを持つを既存リソースを一覧から除外し、管理下リソースを再びimportするのを防ぎます。

Terraform importの自動化

今回の例では、前節の結果を以下の構造のunmanaged_resources.csvという名前でcsvとして出力することにしましょう。このcsvファイルには、新たにimportするリソースのみが列挙されています。

resource_type, resource_name, id

import対象のリソースが定義ファイルに記述されていない場合はimportに失敗するため2、必要に応じて空の定義を追加してimportします。
既に定義ファイルに書かれているかの判定には、hcleditを利用します。

 % cat *.tf | hcledit block list
terraform
provider.google
resource.google_app_engine_firewall_rule.priority_2147483647
resource.google_bigquery_dataset.sample_dataset

以下のスクリプトで、csvの内容に対する空のリソース定義を作成し、Terraform importを実行します。

# importするリソースを列挙したcsv(unmanaged_resources.csv)を読み込む
blank_resources = list() # temporary.tfに書き込まれる空定義のリスト
import_commands = list() # 実行するterraform importコマンドのリスト
already_defined_resources = exec_command(f'cat "*.tf" | hcledit block list', shell=True).split('\n')
already_managed_resources = exec_command(f'terraform state list').split('\n')

with open("unmanaged_resources.csv", 'r') as csvfile:
    for row in csv.reader(csvfile):
        # 定義が存在しない -> 空のリソース定義を追加する
        if not f"resource.{row[0]}.{row[1]}" in already_defined_resources:
            blank_resources.append(f'resource "{row[0]}" "{row[1]}"' + ' {}' + ' # auto generated')
        # importされていない -> importコマンドのリストに追加する
        if not f"{row[0]}.{row[1]}" in already_managed_resources:
            import_commands.append(f'terraform import {row[0]}.{row[1]} {row[2]}')

#  空のリソース定義をファイルに書き出し
tmp_tf = "temporary_tf"
with open(tmp_tf, 'a') as tffile:
    tffile.write('\n'.join(blank_resources))

# importを実行する
for cmd in import_commands:
    res = exec_command(cmd)

tfファイルに追加した定義の内容を拡充

importした後、管理下に置いたリソースの定義をtfファイルへ記述します。

import時に作成した空の定義を、terraform state showの結果で上書きします。
出力を利用する場合、shellによりますがカラーコードが入る可能性があるので-no-colorオプションを付けるのがおすすめです。

terraform state show xxx.xxx -no-color

以下のスクリプトでは、import時に作成した空の定義を削除し、terraform state showの結果をtfファイルへ書き込みます。
空の定義は、argumentが足りていないリソースかどうか(エラーメッセージがMissing required argument)で判断しています。

# エラーメッセージが'Missing required argument'のblockを削除し、再定義する
res = terraform_validate_list(workdir)
if 'Missing required argument' in res:
    redef_rscs = list() # 一度消して再度定義するリソース一覧
    for missing in res['Missing required argument']:
        remove_rsc = find_resource_by_tffile_and_name(missing['filename'], missing['line'])
        redef_rscs.append(remove_rsc)

    for rsc in redef_rscs:
        exec_command(f"hcledit block rm -u -f {rsc['filename']} resource.{rsc['name']}")
        state = exec_command(f"terraform state show -no-color {rsc['name']}")
        with open(tmp_tf, "a") as tffile:
            tffile.write(state + "\n")

スクリプト内で利用している関数(terraform_validate_list, find_resource_by_tffile_and_name)

terraform_validate_list

terraform validateの結果を辞書型にして返します

# terraform_validate_list terraform validateの結果を種類毎にまとめて返します
# 返り値
# {
#   '(error_message)': [
#       {
#           'filename': '(name)',
#           'line': '(line_num)',
#       },
#       ...
#   ],
#   '(error_message)': [...],
# }
def terraform_validate_list(workdir):
    res = json.loads(exec_command(f"terraform -chdir={workdir} validate -json", allow_error=True))
    messages = dict()
    for error in res['diagnostics']:
        if not error['summary'] in messages:
            messages[error['summary']] = []

        filename = os.path.join(workdir, error['range']['filename'])
        messages[error['summary']].append({
                'filename': filename,
                'line': error['range']['start']['line'],
            })
    return messages

find_resource_by_tffile_and_name

tfファイルのファイル名と行数を引数として受け取り、その行が含まれるブロック名を返します。
正規表現を決め打ちしているため、表記ゆれ等によって動作しない場合があります。

# find_resource_by_tffile_and_name ファイルの指定した行がどのresourceのblock内かを返す
# 返り値
# {
#   'name': '(tf_resource_type).(resource_name)',
#   'line': '(line_num)'
#   'filename': '(filename)'
# }
def find_resource_by_tffile_and_name(filename, find_line):
    resources = []
    with open(filename, 'r') as fd:
        line_count = 1
        for line in fd.readlines():
            match = re.search('^resource "(.*)" "(.*)".*$', line)
            if match:
                resources.append({
                    "name": f"{match.group(1)}.{match.group(2)}",
                    "line": line_count,
                    'filename': filename
                })

            line_count += 1

    rsc = {
        'name': "resource not found",
        'line': 0,
        'filename': filename
    }
    for resource in resources:
        if rsc['line'] < resource['line'] and resource['line'] <= find_line:
            rsc = resource
    return rsc

頻出エラーの自動解決

terraform state showによって出力される定義の中には、idDeployedAtなど定義に書けないパラメータが一緒に出力されるため、手作業で消さなければなりません。
idや、AWSのetag, arn等は一見して省略可能だと推測できますが、それ以外のパラメータはresource typeによって設定できるもの、出来ないものの違いが多く、パラメータ名で判断して自動で消すのは良い手ではありません。
そこでterraform validate -jsonの出力を利用することで、頻出のエラーメッセージの行は自動で取り除くことができます。

以下のエラーメッセージの行はargumentとして設定できないものなので、自動で削除します。

Invalid or unknown key' in res
Computed attributes cannot be set' in res
Value for unconfigurable attribute' in res

以下のスクリプトは、terraform validateの結果からargumentとして設定できない行を削除します。

# 削除対象のエラー行を列挙する
res = terraform_validate_list(workdir)
remove_rscs = dict() # 削除するファイル名と行(配列)のセット
if 'Invalid or unknown key' in res:
    for error in res['Invalid or unknown key']:
        if not error['filename'] in remove_rscs:
            remove_rscs[error['filename']] = []
        remove_rscs[error['filename']].append(error['line'])

if 'Computed attributes cannot be set' in res:
    for error in res['Computed attributes cannot be set']:
        if not error['filename'] in remove_rscs:
            remove_rscs[error['filename']] = []
        remove_rscs[error['filename']].append(error['line'])

if 'Value for unconfigurable attribute' in res:
    for error in res['Value for unconfigurable attribute']:
        if not error['filename'] in remove_rscs:
            remove_rscs[error['filename']] = []
        remove_rscs[error['filename']].append(error['line'])

# ファイル毎に対象行を削除
for filename, lines in remove_rscs.items():
    # 上から消すと行数がズレるので下の行から消す
    lines.sort(reverse=True)
    for line in lines:
        exec_command(f"sed -i -e {line}d {filename}")

残ったエラーは、それぞれproviderのドキュメントを参照して状況に応じて解決します。

resourceを参照する

関連するリソースのID等が生の値で書かれている定義が生成されるので、手で参照に切り替えます。ここは状況に応じて対応が変わるので自動化はしていません。
IDのような文字列を見つけたらterraform state list -id xxxで参照先が管理下にあるか確認して、エディタ上で全置換することが多いです。

社内での利用例

Terraform planに加えて、既存リソースを列挙するスクリプトを定期的に回して新規リソースの作成を検知しています。

新たにリソースが発見された場合

  • 管理下に置くか検討、置かない場合は無視するリストに追加
  • importスクリプトを回す
  • 自動で取り切れなかったvalidate errorを手動で解決する
  • ID類をresource参照へ置き換える
  • terraform planで差分が無いことを確認、レビューを通してmerge

というアクションをとります。
まだまだ手のかかる場所はありますが、状況に応じて対応する場面が多いため仕方のない部分かな、とも思っています。

まとめ

本記事では、いくつかのCLIツールを用いてTerraform importの作業の手間を軽くする方法についてご紹介しました。
単純作業にかける時間を減らし、よりサービスの信頼性に貢献できる作業へ集中できる環境づくりの参考になれば幸いです。

カヤックでは一緒に働くエンジニアを募集しています。

hubspot.kayac.com


  1. https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/app_engine_firewall_rule

  2. terraform importには-allow-missing-configオプションがありますが、今回は検証していません。