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
によって出力される定義の中には、id
やDeployedAt
など定義に書けないパラメータが一緒に出力されるため、手作業で消さなければなりません。
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の作業の手間を軽くする方法についてご紹介しました。
単純作業にかける時間を減らし、よりサービスの信頼性に貢献できる作業へ集中できる環境づくりの参考になれば幸いです。
-
https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/app_engine_firewall_rule↩
-
terraform importには
-allow-missing-config
オプションがありますが、今回は検証していません。↩