Terraform の apply が途中で失敗したので remote state から復旧した

Terraform の apply が途中で失敗したので remote state から復旧した

Terraform の apply が権限不足で途中停止しました。通常出力と remote state で作成済みリソースを確認し、不足権限だけを修正して再実行した際の作業ログを紹介します。
2026.07.20

はじめに

Terraform の plan で作成対象を確認していても、apply 中の API 呼び出しが権限不足で失敗することがあります。このとき、先に作成されたリソースは自動で削除されません。

今回、IAM ロールと Amazon S3 オブジェクトを作成する Terraform を、権限を絞った作業用ロールで実行しました。プランでは 5 リソースを追加する予定でしたが、2 個の S3 オブジェクトを作成する段階で s3:PutObjectTagging が拒否され、apply が途中で止まりました。

本記事では、Terraform の apply が途中で失敗したときに、作成済みと未作成のリソースを確認し、不足権限だけを修正して再実行した作業のログを紹介します。今回は作成済みのリソースを手動削除せず、Terraform の通常出力、remote state、AWS API の結果を照合しました。その後、不足していた権限だけを追加し、remote state からプランを作り直して apply しました。

対象読者

  • Terraform の apply が途中で失敗し、作成済みリソースの扱いに迷っている方
  • remote state と保存済みプランを使っている方
  • AWS の権限を広げすぎずに apply を再実行したい方
  • Terraform の生ログと公開用の記録を分けたい方

検証環境

項目
OS Windows
Terraform 1.15.8
AWS Provider 6.49.0
バックエンド Amazon S3、バージョニング有効、use_lockfile = true
AWS リージョン us-east-1
認証 作業専用の IAM ロール (assume role)

参考資料

構成

検証対象は、SageMaker Processing Job で使う IAM ロールと、S3 に置く小さな入力ファイルです。GPU や Processing Job は作成しません。

AWS Provider には共通のデフォルトタグを設定していました。

provider.tf
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = "poc"
      ManagedBy   = "terraform"
      Project     = "example"
    }
  }
}

default_tags はタグ付けをサポートするリソースへ自動的に適用されます。今回の S3 オブジェクトにもデフォルトタグが設定される計画でした。

診断方法

Terraform の通常出力を一次情報として残す

apply の失敗原因を調べるため、標準出力と標準エラーをひとつの Git 管理外ログへ保存しました。同時に、終了コード、ログの SHA-256、失敗した Terraform リソースのアドレス、AWS API、HTTP ステータス、AWS エラーコードを JSON へ記録しました。

python scripts/capture_terraform.py `
  --log-file private/apply.log `
  --summary-file private/apply-summary.json `
  --run-id static-apply-001 `
  --plan-sha256 <approved-plan-sha256> `
  -- terraform apply processing-static.tfplan

生ログにはリクエスト ID、ARN、バケット名などが含まれる可能性があります。Git へ追加せず、公開用の記録には匿名化した項目だけを書きます。

JSON は次のような形になりました。

{
  "terraform_exit_code": 1,
  "resource_address": "aws_s3_object.driver",
  "aws_service": "S3",
  "api_operation": "PutObject",
  "http_status_code": 403,
  "aws_error_code": "AccessDenied",
  "log_sha256": "<sha256>"
}

Terraform の通常出力だけでリソースと API を特定できなかった場合に限り、CloudTrail などの補助情報を確認します。今回は通常出力に必要な情報があったため、CloudTrail は使いませんでした。

remote state と AWS API の両方を確認する

apply が失敗した時点で、S3 バックエンドの state serial は 0 から 1 へ進んでいました。state lock は解除済みです。

対象 remote state AWS API での確認
実行ロール あり あり
実行ロールのインラインポリシー あり あり
Terraform の検証記録 あり あり
ドライバーオブジェクト なし なし
フィクスチャオブジェクト なし なし

プランでは 5 リソースを追加する予定でしたが、実際には 3 リソースが作成され、2 リソースが未作成でした。Terraform のエラー表示だけでなく、remote state と AWS API の結果が一致することを確認しました。

行った対策

不足していた権限だけを追加する

エラーには、s3:PutObjectTagging が許可されていないことが記録されていました。オブジェクトのタグを設定するには s3:PutObjectTagging が必要です。

今回は、ドライバーとフィクスチャのオブジェクト ARN に権限を追加しました。

operator-policy.tf
statement {
  effect  = "Allow"
  actions = ["s3:PutObjectTagging"]
  resources = [
    local.driver_object_arn,
    local.fixture_object_arn,
  ]
}

既存の入力プレフィックス全体やバケット全体には広げません。許可した 2 オブジェクトは allowed、隣接する未承認キーは implicitDeny になることも確認しました。

ここで確認できたのは、不足していたアクションと対象オブジェクトです。AWS Provider が内部で S3 API を呼ぶ順番までは、保存したエラーから断定していません。

古い保存済みプランを捨てる

失敗時に使ったプランは、state serial 0 をもとに作られています。apply 後の remote state は serial 1 で、IAM リソースはすでに存在します。

そのため、古いプランは再利用しません。権限修正後、remote state serial 1 からプランを作り直しました。

terraform plan -out=processing-static-recovery.tfplan
terraform show processing-static-recovery.tfplan

terraform plan -out で保存したプランは、作成時の state と入力を使って後から apply するファイルです。プランファイルには機密値が平文で含まれる場合があります。Git へ追加せず、承認対象の SHA-256 とアクション数だけを共有しました。

作り直したプランの内容は次のとおりです。

アクション 内容
add 2 ドライバーとフィクスチャの S3 オブジェクト
change 2 ソースコミットタグと検証記録
destroy 0 なし

IAM ロールの信頼ポリシーと実行ポリシーには変更がありませんでした。失敗後の state がプランに反映されていることを確認できました。

作り直したプランを apply する

承認された新しいプランを 1 回だけ apply しました。

項目 結果
実行時間 約 9 秒
Terraform の終了コード 0
remote state serial 1 から 2
ドライバーオブジェクト 作成済み
フィクスチャオブジェクト 作成済み
Processing Job、GPU 未作成

2 オブジェクトはバージョニングと SSE-S3 が有効なバケットに保存されています。VersionId、サイズ、Content-Type、SHA-256 メタデータ、デフォルトタグを AWS API で取得し、ローカルファイルと一致することを確認しました。

まとめ

Terraform の apply が途中で失敗したあと、remote state と通常出力から現在地を確認し、不足権限だけを追加した新しいプランで再実行しました。apply の失敗をすぐ手動操作で直すのではなく、Terraform が記録した state を起点にすると、変更内容を確認しながら安全に再開できます。Terraform の apply が途中で失敗した際に、本記事での知見が参考になれば幸いです。

付録

Terraform の通常出力と終了コードは、Python スクリプトで Git 管理外へ保存しました。付録には、実際に使用したコードから記事に必要な処理を抜き出して掲載します。

Terraform 出力の取得スクリプト
scripts/capture_terraform.py
from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path

ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
RESOURCE_PATTERN = re.compile(r"(?m)^\s*with\s+([^,\r\n]+),\s*$")
OPERATION_PATTERN = re.compile(
    r"operation error\s+([^:\r\n]+):\s*([A-Za-z][A-Za-z0-9]+)"
)
STATUS_PATTERN = re.compile(r"StatusCode:\s*(\d{3})")
ERROR_CODE_PATTERN = re.compile(
    r"RequestID:\s*[^,\r\n]+,\s*(?:api error\s+)?([A-Za-z][A-Za-z0-9]+):"
)

def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()

def atomic_json(path: Path, value: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_suffix(path.suffix + ".tmp")
    temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
    temporary.replace(path)

def normalized_output(raw: bytes) -> str:
    text = raw.decode("utf-8", errors="replace")
    text = ANSI_ESCAPE.sub("", text)
    lines = []
    for line in text.splitlines():
        line = re.sub(r"^\s*[╷│╵]\s?", "", line)
        lines.append(line.rstrip())
    return "\n".join(lines)

def extract_diagnostic(raw: bytes) -> dict:
    text = normalized_output(raw)
    resource = RESOURCE_PATTERN.search(text)
    operation = OPERATION_PATTERN.search(text)
    status = STATUS_PATTERN.search(text)
    error_code = ERROR_CODE_PATTERN.search(text)
    first_error = re.search(r"(?m)^Error:\s*(.+)$", text)
    return {
        "terraform_error_summary": (
            first_error.group(1).strip() if first_error else None
        ),
        "resource_address": resource.group(1).strip() if resource else None,
        "aws_service": operation.group(1).strip() if operation else None,
        "api_operation": operation.group(2) if operation else None,
        "http_status_code": int(status.group(1)) if status else None,
        "aws_error_code": error_code.group(1) if error_code else None,
    }

def run_and_capture(
    command: list[str],
    log_path: Path,
    summary_path: Path,
    run_id: str,
    plan_sha256: str,
) -> int:
    started_at = utc_now()
    log_path.parent.mkdir(parents=True, exist_ok=True)
    digest = hashlib.sha256()

    process = subprocess.Popen(
        command,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
    )
    assert process.stdout is not None

    with log_path.open("wb") as handle:
        while chunk := process.stdout.read(65536):
            handle.write(chunk)
            handle.flush()
            digest.update(chunk)

    process.stdout.close()
    exit_code = process.wait()
    diagnostic = extract_diagnostic(log_path.read_bytes())
    summary = {
        "run_id": run_id,
        "plan_sha256": plan_sha256,
        "started_at": started_at,
        "completed_at": utc_now(),
        "terraform_exit_code": exit_code,
        "log_file": str(log_path),
        "log_sha256": digest.hexdigest(),
        **diagnostic,
    }
    summary["cloudtrail_fallback_required"] = exit_code != 0 and (
        not summary["resource_address"] or not summary["api_operation"]
    )
    atomic_json(summary_path, summary)
    return exit_code

def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--log-file", type=Path, required=True)
    parser.add_argument("--summary-file", type=Path, required=True)
    parser.add_argument("--run-id", required=True)
    parser.add_argument("--plan-sha256", required=True)
    parser.add_argument("command", nargs=argparse.REMAINDER)
    args = parser.parse_args()
    command = args.command[1:] if args.command[:1] == ["--"] else args.command
    if not command:
        parser.error("a Terraform command is required after --")
    return run_and_capture(
        command,
        args.log_file,
        args.summary_file,
        args.run_id,
        args.plan_sha256,
    )

if __name__ == "__main__":
    raise SystemExit(main())

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事