OpenTofu로 Terraform 옮기기: tfstate 마이그레이션, 모듈 구조, 원격 state, CI와 정책 검증

이 글의 핵심

OpenTofu는 Terraform 1.5를 기반으로 한 Linux Foundation 산하 오픈소스 포크라 기존 설정 대부분이 그대로 동작하지만, 전환할 때는 provider 버전을 고정해 plan이 No changes인지부터 확인해야 합니다. 내장 state 암호화와 S3 서버 측 암호화의 차이, 저장된 plan만 apply하는 CI, OPA 1.0 문법으로 바뀐 conftest 정책까지 함정 위주로 정리합니다.

이 글의 핵심

OpenTofu는 HashiCorp가 2023년 8월 Terraform 라이선스를 MPL(오픈소스)에서 BSL(Business Source License)로 변경하자, Linux Foundation 산하에서 포크된 오픈소스 IaC 도구입니다. 2024년 1월 1.6 GA 이후 꾸준히 릴리스를 내며, 포크 시점인 Terraform 1.5의 문법과 state 포맷을 기반으로 대부분 호환되면서 독자 기능(1.7의 내장 state 암호화, 1.9의 provider for_each와 -exclude 플래그 등)을 더해 왔습니다. 반대로 포크 이후 Terraform에 추가된 기능은 OpenTofu에 없거나 다르게 구현되어 있을 수 있어서, 두 도구는 시간이 갈수록 조금씩 갈라지고 있습니다.

이 글에서는 설치부터 tfstate 마이그레이션, 모듈 구조, 원격 백엔드, CI/CD, 시크릿 관리, 정책 검증까지 실무 운영에서 바로 쓸 수 있는 패턴을 정리합니다.

왜 OpenTofu인가

라이선스 리스크 회피

BSL은 “경쟁 제품에 Terraform을 포함할 수 없다”는 제약이 있습니다. 일반 엔터프라이즈 사용엔 문제가 없지만, 다음 경우에 이슈가 됩니다.

  • SaaS 제품에 Terraform을 번들로 제공
  • Terraform 래퍼를 상업적으로 재배포 (예: Spacelift, env0, Scalr)
  • 사내 플랫폼이지만 다른 부서에 “Terraform-as-a-Service”로 과금

OpenTofu는 MPL 2.0으로 이런 제약이 없습니다.

다만 이 부분은 과장하기 쉬운 지점입니다. 사내 인프라를 Terraform으로 관리하는 대부분의 회사는 BSL의 제약 대상이 아니므로, “라이선스 때문에 당장 옮겨야 한다”는 결론은 대개 성립하지 않습니다. 실제로 전환을 결정하는 이유는 장기적인 공급사 의존을 줄이고 싶거나, 아래의 OpenTofu 전용 기능이 필요하거나, Terraform Cloud 요금 체계를 벗어나고 싶은 경우가 더 많습니다. 법무 판단이 필요한 경우라면 BSL 전문(Additional Use Grant 조항)을 직접 확인하는 것이 정확합니다.

중립 거버넌스

Linux Foundation 산하 프로젝트로 운영되며 기술 운영 위원회(TSC)가 방향을 정하므로 한 회사의 단독 결정에 종속되지 않습니다. 포크를 주도한 곳은 Terraform 기반 서비스를 제공하던 여러 IaC 벤더들이고, RFC 저장소에서 기능 제안과 논의가 공개적으로 진행됩니다.

Terraform에 없는 고유 기능

# OpenTofu 1.9+
variable "regions" {
  type    = set(string)
  default = ["us-east-1", "eu-west-1", "ap-northeast-2"]
}

provider "aws" {
  alias    = "by_region"
  for_each = var.regions   # plan 시점에 값이 확정돼 있어야 함 (변수·로컬만 가능)
  region   = each.value
}

resource "aws_s3_bucket" "multi_region" {
  for_each = var.regions
  provider = aws.by_region[each.key]
  bucket   = "my-app-${each.key}"
}

provider for_each는 Terraform엔 없는 기능으로, 다중 리전 리소스를 리전마다 provider 블록을 복사하지 않고 선언할 수 있습니다. 제약도 알아 둬야 합니다. provider의 for_each에는 다른 리소스의 출력처럼 apply 후에야 알 수 있는 값을 쓸 수 없고, 목록에서 리전을 하나 제거할 때는 그 리전의 리소스를 먼저 삭제한 뒤 provider 인스턴스를 빼야 합니다. provider를 먼저 지우면 해당 리소스를 삭제할 provider가 없어 plan이 Provider instance not present 계열 에러로 멈춥니다.

또한 tofu plan -exclude=module.legacy로 특정 리소스를 제외한 plan도 지원합니다(Terraform은 포함 대상을 고르는 -target만 지원). 문제가 있는 모듈 하나 때문에 나머지 변경을 배포하지 못할 때 유용하지만, -target과 마찬가지로 일상적인 배포 방식이 아니라 비상 수단으로 써야 합니다. 제외한 리소스와 나머지 리소스의 state가 어긋난 채로 남기 때문입니다.

설치

공식 바이너리 (권장)

# macOS (Homebrew)
brew install opentofu

# Linux (Snap / 공식 스크립트)
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh \
  -o install-opentofu.sh
chmod +x install-opentofu.sh
./install-opentofu.sh --install-method standalone

# Windows (Scoop)
scoop bucket add main
scoop install opentofu

Docker

docker run --rm -v "$(pwd):/work" -w /work ghcr.io/opentofu/opentofu:1.8 init

tfenv 스타일 버전 관리

# tenv (OpenTofu / Terraform / Terragrunt 통합 버전 매니저)
brew install tenv
tenv tofu install 1.8.0
tenv tofu use 1.8.0

버전을 .opentofu-version 파일로 고정해 팀 전체 일관성을 유지하세요. IaC에서 버전 고정이 특히 중요한 이유는 state에 마지막으로 쓴 도구 버전이 기록되기 때문입니다. 한 사람이 더 새 버전으로 apply하면 state 포맷이 올라갈 수 있고, 그 뒤로는 구버전을 쓰는 팀원과 CI가 state를 읽지 못합니다. 설정의 required_version과 버전 파일, CI의 tofu_version을 한 번에 같이 올리는 습관이 필요합니다.

첫 번째 설정: AWS S3 + DynamoDB state

# versions.tf
terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.70"
    }
  }

  backend "s3" {
    bucket         = "my-team-tfstate"
    key            = "prod/network/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "tf-state-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = "ap-northeast-2"
  default_tags {
    tags = {
      ManagedBy = "OpenTofu"
      Project   = "pkglog"
    }
  }
}
# main.tf
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.13"

  name = "prod-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false
}
tofu init        # 백엔드 초기화 + provider 다운로드
tofu fmt         # 코드 정리
tofu validate    # 문법 검증
tofu plan        # 변경 계획 검토
tofu apply       # 실제 적용

terraform-aws-modules 같은 public 모듈도 그대로 사용 가능합니다. 설정 블록 이름이 여전히 terraform { }인 것도 호환성을 위해서이고, OpenTofu는 source = "hashicorp/aws" 같은 짧은 주소를 기본적으로 registry.opentofu.org에서 찾습니다. single_nat_gateway = false는 AZ마다 NAT 게이트웨이를 하나씩 두는 설정이라 가용성은 높지만 NAT 게이트웨이 요금이 AZ 수만큼 나오므로, 개발 환경에서는 true로 두는 경우가 많습니다.

Terraform에서 OpenTofu로 마이그레이션

체크리스트

# 1) 현재 state 백업
terraform state pull > backup.tfstate
aws s3 cp backup.tfstate s3://my-backup-bucket/backup-$(date +%Y%m%d).tfstate

# 2) Terraform Cloud를 쓰고 있다면 `cloud {}` 블록 제거, 표준 백엔드로 이전
# 3) .terraform 디렉터리 삭제
rm -rf .terraform .terraform.lock.hcl

# 4) OpenTofu로 재초기화
tofu init

# 5) plan 결과가 "No changes" 인지 확인 (중요!)
tofu plan

tofu plan에서 drift(상태와 실제 리소스 불일치)가 나오면 실제 변경이 생기는지 꼼꼼히 확인합니다. 대부분은 provider 버전 차이로 인한 속성 정규화 차이이며 tofu apply -refresh-only로 해결됩니다.

마이그레이션에서 가장 흔한 실수는 “No changes”를 확인하지 않고 바로 apply하는 것입니다. .terraform.lock.hcl을 지우고 다시 init하면 제약 범위 안의 최신 provider가 설치되는데, 그 사이 provider의 기본값이 바뀌었다면 plan에 교체(-/+)가 섞여 나올 수 있습니다. 데이터베이스나 로드 밸런서의 교체는 곧 장애이므로, 전환 단계에서는 기존 lock 파일에 기록된 버전을 required_providers에 정확히 고정해 도구 교체와 provider 업그레이드를 분리하는 편이 안전합니다. 또 공식 마이그레이션 가이드는 출발하는 Terraform 버전(1.5 이하, 1.6, 1.7 이상 등)별로 나뉘어 있어서, 포크 이후 Terraform에만 생긴 기능(removed 블록의 일부 동작, 최신 버전의 ephemeral 리소스 등)을 쓰고 있다면 해당 코드를 먼저 정리해야 합니다.

호환되지 않는 부분

기능TerraformOpenTofu
cloud {} 블록 (Terraform Cloud 원격 실행)지원미지원 (표준 백엔드 사용)
terraform_remote_state with TFC지원미지원
Sentinel 정책지원미지원 (OPA·Conftest 대체)
terraform test 프레임워크v1.6+자체 구현, 세부 동작 차이

모듈 구조: 실무 표준

infrastructure/
├── modules/
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── rds/
│   └── eks/
├── envs/
│   ├── dev/
│   │   ├── main.tf       # modules/* 조합
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   └── prod/
└── .opentofu-version

각 환경 디렉터리는 독립된 state를 가지며 동일 모듈을 variables만 다르게 호출합니다. 이 구조는 blast radius를 제한하고 환경별 변경을 명확히 분리합니다.

같은 목적으로 tofu workspace를 쓰는 방법도 있지만, 워크스페이스는 같은 코드와 같은 백엔드 설정을 공유하고 state만 나눕니다. 현재 선택된 워크스페이스를 착각해 dev인 줄 알고 prod에 apply하는 사고가 대표적인 실패 사례이고, 환경마다 다른 계정·다른 권한을 쓰기도 어렵습니다. 그래서 환경 차이가 크거나 계정을 분리하는 조직은 디렉터리 분리를, 기능 브랜치별 임시 환경처럼 구조가 완전히 같은 복제본이 필요할 때는 워크스페이스를 쓰는 식으로 나누는 경우가 많습니다. 디렉터리 방식의 단점은 main.tf가 환경마다 복사된다는 점인데, 모듈 호출만 남기고 로직은 전부 modules/에 두면 중복이 크게 줄어듭니다.

모듈 사용 예제

# envs/prod/main.tf
module "vpc" {
  source = "../../modules/vpc"

  name = "prod"
  cidr = "10.0.0.0/16"
  az_count = 3
}

module "eks" {
  source = "../../modules/eks"

  cluster_name = "prod"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.private_subnets

  node_groups = {
    general = {
      instance_types = ["m6i.xlarge"]
      min_size       = 3
      max_size       = 10
      desired_size   = 5
    }
  }
}

원격 state 백엔드 전략

S3 + DynamoDB (AWS 표준)

backend "s3" {
  bucket         = "my-team-tfstate"
  key            = "prod/app/terraform.tfstate"
  region         = "ap-northeast-2"
  dynamodb_table = "tf-state-locks"
  encrypt        = true

  # S3 서버 측 암호화(SSE-KMS)에 쓸 키 — 아래 OpenTofu 내장 암호화와는 별개
  kms_key_id = "alias/tfstate-encryption"
}

S3 버킷은 별도 “관리 계정”에 두고 각 워크로드 계정은 IAM Role assume으로 접근하도록 하세요. 잠금은 필수입니다 — 팀원 둘이 동시에 apply하는 사고를 막습니다. OpenTofu 1.10부터는 S3 백엔드에 use_lockfile = true를 지정하면 DynamoDB 없이 S3 조건부 쓰기로 잠금을 걸 수 있어서, DynamoDB 테이블 방식은 점차 대체되는 추세입니다. 버킷에는 버전 관리를 꼭 켜 두세요. 잘못된 apply나 수동 편집으로 state가 망가졌을 때 이전 버전으로 되돌릴 수 있는 사실상 유일한 수단입니다.

encrypt = true와 kms_key_id는 S3가 저장 시점에 객체를 암호화하는 서버 측 암호화입니다. 버킷 읽기 권한이 있는 사람은 복호화된 state를 그대로 내려받을 수 있으므로, state 안의 비밀번호를 버킷 권한자에게도 숨기고 싶다면 아래의 클라이언트 측 암호화가 필요합니다.

OpenTofu 1.7 내장 state 암호화

terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "alias/tfstate"
      region     = "ap-northeast-2"
      key_spec   = "AES_256"
    }

    method "aes_gcm" "main" {
      keys = key_provider.aws_kms.main
    }

    state {
      method = method.aes_gcm.main
    }

    plan {
      method = method.aes_gcm.main
    }
  }
}

tfstate에 포함된 비밀번호·키가 KMS로 추가 암호화됩니다. Terraform엔 없는 기능입니다. 이 방식은 OpenTofu가 state를 백엔드에 보내기 전에 암호화하므로, S3 버킷에 접근할 수 있어도 KMS 키 권한이 없으면 내용을 읽을 수 없습니다.

기존 state에 암호화를 처음 켤 때는 주의가 필요합니다. 위 설정을 그대로 넣으면 OpenTofu는 기존 평문 state를 암호화된 것으로 읽으려다 실패하므로, 전환 기간에는 method "unencrypted" "migrate" {}를 정의하고 state 블록 안에 fallback { method = method.unencrypted.migrate }를 추가해 평문 state도 읽을 수 있게 한 뒤 한 번 apply해서 암호화된 state로 다시 써야 합니다. 그리고 암호화를 켜는 순간 Terraform으로 되돌아갈 길은 사실상 닫히고, KMS 키를 잃으면 state도 잃습니다. 키 삭제 보호와 키 정책을 state 버킷만큼 엄격하게 관리해야 합니다.

CI/CD 파이프라인: GitHub Actions

name: OpenTofu CI

on:
  pull_request:
    paths: ['infrastructure/**']
  push:
    branches: [main]
    paths: ['infrastructure/**']

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      id-token: write  # OIDC for AWS

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GithubActionsRole
          aws-region: ap-northeast-2

      - uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: 1.8.0

      - name: Format check
        run: tofu fmt -check -recursive

      - name: Init
        working-directory: infrastructure/envs/prod
        run: tofu init -backend-config=backend.hcl

      - name: Validate
        working-directory: infrastructure/envs/prod
        run: tofu validate

      - name: Plan
        id: plan
        working-directory: infrastructure/envs/prod
        run: tofu plan -out=tfplan -no-color | tee plan.txt

      - name: Comment plan on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('infrastructure/envs/prod/plan.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `### OpenTofu Plan\n\`\`\`\n${plan.slice(0, 60000)}\n\`\`\``
            });

PR에 자동으로 tofu plan 결과를 코멘트로 남깁니다. 리뷰어는 실제 변경 내역을 코드 diff와 함께 확인합니다. AWS 자격 증명은 장기 액세스 키 대신 OIDC(id-token: write)로 받으므로 저장소 시크릿에 키를 둘 필요가 없습니다. 이때 IAM 역할의 신뢰 정책에서 token.actions.githubusercontent.com:sub 조건을 저장소와 브랜치로 좁혀 두지 않으면, 같은 조직의 다른 저장소 워크플로도 이 역할을 가져갈 수 있습니다. PR의 plan 잡에는 읽기 위주의 역할을, main의 apply 잡에는 쓰기 권한 역할을 따로 주는 것이 좋습니다.

plan 출력을 PR 코멘트로 올릴 때는 민감한 값이 노출되지 않는지도 확인해야 합니다. sensitive로 표시된 변수와 출력은 (sensitive value)로 가려지지만, 모듈이 비밀 값을 일반 속성으로 다루면 plan에 평문으로 찍힐 수 있고, 공개 저장소라면 누구나 그 코멘트를 볼 수 있습니다.

Apply는 별도 잡으로

  apply:
    needs: plan
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production  # GitHub Environment 승인 게이트
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    defaults:
      run:
        working-directory: infrastructure/envs/prod
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GithubActionsApplyRole
          aws-region: ap-northeast-2
      - uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: 1.8.0
      - run: tofu init -backend-config=backend.hcl
      - run: tofu apply -auto-approve

environment: production은 GitHub Environment의 필수 리뷰어 승인을 강제하는 트릭입니다. 수동 승인 없이는 apply가 진행되지 않습니다.

이 구성에는 알고 써야 할 약점이 하나 있습니다. apply 잡은 PR에서 리뷰한 plan 파일이 아니라 그 시점에 새로 계산한 plan을 적용합니다. PR 리뷰와 머지 사이에 다른 PR이 먼저 머지되었거나 누군가 콘솔에서 리소스를 바꿨다면, 승인한 내용과 실제 적용되는 내용이 달라질 수 있습니다. 이를 막으려면 plan 잡에서 tofu plan -out=tfplan으로 만든 파일을 아티팩트로 올리고 apply 잡에서 tofu apply tfplan으로 그 파일만 적용하게 하는데, 저장된 plan은 state가 바뀌면 Saved plan is stale 에러로 거부되므로 “승인한 것만 적용된다”는 보장이 생깁니다. 대신 plan 파일에는 민감한 값이 들어 있으므로 아티팩트 보존 기간을 짧게 두고, OpenTofu의 plan 암호화를 함께 쓰는 편이 좋습니다.

정책 검증: OPA + Conftest

Sentinel(유료) 대신 OPA(Open Policy Agent)의 conftest를 사용해 OpenTofu plan을 검증할 수 있습니다.

tofu plan -out=tfplan
tofu show -json tfplan > plan.json
conftest test plan.json --policy policy/
# policy/s3.rego (OPA 1.0 / Rego v1 문법)
package main

# AWS provider v4+에서는 ACL이 별도 리소스(aws_s3_bucket_acl)로 분리됨
deny contains msg if {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket_acl"
  resource.change.after.acl == "public-read"
  msg := sprintf("S3 bucket ACL %s must not be public", [resource.address])
}

deny contains msg if {
  resource := input.resource_changes[_]
  resource.type == "aws_instance"
  not resource.change.after.tags_all.Owner  # default_tags까지 포함하려면 tags_all
  msg := sprintf("EC2 instance %s must have Owner tag", [resource.address])
}

공공 S3·보안그룹 0.0.0.0/0 개방·태그 누락 등을 CI에서 차단합니다. 인터넷에 있는 많은 예제가 deny[msg] { ... } 형태의 예전 문법을 쓰는데, OPA 1.0 기반의 최신 conftest에서는 이 문법이 rego_parse_error: 'if' keyword is required before rule body 에러로 거부됩니다. 예전 정책을 그대로 써야 한다면 conftest test --rego-version v0으로 임시로 돌릴 수 있지만, contains/if를 붙이는 쪽으로 옮기는 편이 낫습니다.

정책 검사가 보는 것은 plan JSON이라는 점도 기억해 둘 만합니다. known after apply인 값은 plan 시점에 after에 없고 after_unknown에 표시되므로, 이런 속성을 기준으로 한 정책은 조용히 통과할 수 있습니다. 태그처럼 plan에서 확정되는 값을 검사하는 데 가장 잘 맞고, 실제 리소스 상태 점검은 AWS Config 같은 런타임 도구가 보완해야 합니다.

시크릿 관리

tfvars에 비밀을 커밋하지 마세요. 다음 중 하나를 택합니다.

AWS SSM Parameter Store / Secrets Manager

data "aws_ssm_parameter" "db_password" {
  name = "/prod/db/password"
  with_decryption = true
}

resource "aws_db_instance" "main" {
  password = data.aws_ssm_parameter.db_password.value
}

이 방법은 비밀이 코드 저장소에는 남지 않지만 state에는 평문으로 저장됩니다. data source로 읽은 값과 password 속성이 모두 state에 기록되기 때문입니다. 그래서 SSM을 쓰더라도 state 버킷 접근 제한과 앞의 state 암호화가 함께 필요합니다. RDS라면 manage_master_user_password = true로 비밀번호 생성과 보관을 Secrets Manager에 맡기면 state에 비밀번호가 아예 들어가지 않습니다.

환경변수 + TF_VAR_*

export TF_VAR_db_password=$(aws secretsmanager get-secret-value \
  --secret-id prod/db --query SecretString --output text)
tofu apply

SOPS + age

# secrets.enc.env (암호화 상태로 커밋 가능, 값만 암호화됨)
# TF_VAR_db_password=ENC[AES256_GCM,data:...]
# 복호화한 값을 환경변수로만 주입하고, 평문 파일은 디스크에 남기지 않음
sops exec-env secrets.enc.env 'tofu apply'

sops -d로 평문 .tfvars 파일을 만들어 쓰는 방법도 있지만, 그 파일이 실수로 커밋되거나 CI 로그·아티팩트에 남기 쉽습니다. exec-env는 복호화한 값을 자식 프로세스의 환경변수로만 넘기므로 이런 위험이 줄어듭니다. 설정 안에서 직접 복호화하고 싶다면 커뮤니티 SOPS provider의 sops_file data source를 쓰는 방법도 있는데, 이 경우에도 값은 state에 남습니다.

실전: drift 감지 + 자동 경보

매일 새벽 tofu plan을 돌려 drift(수동 콘솔 변경)를 감지하고 Slack으로 알립니다.

on:
  schedule:
    - cron: '0 18 * * *'  # 매일 새벽 3시 KST

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
      - run: tofu init
      - id: plan
        run: |
          if tofu plan -detailed-exitcode -no-color > plan.txt; then
            echo "drift=false" >> $GITHUB_OUTPUT
          elif [ $? -eq 2 ]; then
            echo "drift=true" >> $GITHUB_OUTPUT
          fi

      - if: steps.plan.outputs.drift == 'true'
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text":"⚠️ Infra drift detected in prod","blocks":[...]}

-detailed-exitcode는 변경 없음이면 0, 에러면 1, 변경이 있으면 2를 반환합니다. 위 스크립트는 1(에러)일 때 아무 출력도 남기지 않으므로 자격 증명 만료나 provider 다운로드 실패가 “drift 없음”처럼 조용히 지나갈 수 있어, 실제로는 else 분기에서 exit 1로 잡을 실패로 처리하는 편이 좋습니다. 이 잡도 앞의 plan 잡처럼 configure-aws-credentials와 working-directory 설정이 있어야 동작합니다. drift 감지를 처음 켜면 “매일 알림이 오는데 아무도 보지 않는” 상태가 되기 쉬운데, 대부분은 오토스케일링 그룹의 desired_capacity처럼 코드 밖에서 바뀌는 게 정상인 속성 때문입니다. 이런 속성은 ignore_changes로 명시해 두어야 알림이 의미를 갖습니다.

문제 해결

Error: Failed to query available provider packages

  • 버전 제약(version = "~> 5.70")을 만족하는 버전이 OpenTofu 레지스트리에 아직 없는지 확인합니다. 새로 나온 버전은 레지스트리 반영이 조금 늦을 수 있습니다.
  • lock 파일이 다른 버전을 고정하고 있다면 tofu init -upgrade로 갱신합니다.
  • 사내망이라면 registry.opentofu.org와 GitHub 릴리스 다운로드 주소가 방화벽에서 열려 있는지 확인하고, 필요하면 CLI 설정 파일의 provider_installation 블록으로 사내 미러를 지정합니다.

state 잠금 안 풀림

tofu force-unlock <LOCK_ID>

DynamoDB 콘솔에서 tf-state-locks 테이블의 해당 LockID 항목을 삭제해도 됩니다.

provider 버전 충돌

모듈마다 다른 버전을 요구할 때 required_providers의 version 제약을 가장 넓은 교집합으로 조정하거나, 모듈을 쪼개 독립 workspace로 분리하세요.

plan에 매번 같은 변경이 나옴

lifecycle { ignore_changes = [tags["CreatedAt"]] } 같은 자동 갱신 속성은 명시적으로 무시해 noise를 줄입니다. timestamp() 함수를 태그 값에 쓰면 plan마다 값이 바뀌므로 항상 변경으로 잡힙니다. 반대로 ignore_changes를 너무 넓게(all) 걸면 코드로 바꾼 값도 반영되지 않으므로 속성 단위로 좁혀 쓰는 것이 원칙입니다.

체크리스트: 프로덕션 도입 전

  • tfstate 백엔드 S3 버전 관리·암호화·접근 제한 설정
  • DynamoDB 잠금 테이블 생성 (LockID 파티션 키)
  • 환경별 디렉터리 분리 (envs/{dev,staging,prod})
  • opentofu/setup-opentofu@v1 + OIDC 기반 CI
  • PR plan 자동 코멘트, apply는 승인 게이트
  • OPA/Conftest 보안 정책
  • drift 감지 스케줄 잡
  • 시크릿은 SSM/Secrets Manager/SOPS
  • tofu fmt -check -recursive pre-commit hook
  • runbook: state 손상·잠금 해제·롤백 절차 문서화

마무리

OpenTofu는 이미 Terraform의 단순 포크가 아닌 독립된 프로젝트입니다. provider iteration, 내장 state 암호화, -exclude 같은 기능은 실무에 직접적 가치를 줍니다. 반대로 Terraform Cloud·Sentinel에 깊이 묶여 있거나, 포크 이후 Terraform에 추가된 기능을 이미 쓰고 있다면 전환 비용이 기대보다 큽니다. 기존 Terraform 설정을 그대로 두고 tofu init 한 줄로 실험할 수 있으니, 스테이징 환경의 state 사본으로 plan이 “No changes”인지부터 확인해 보는 것이 가장 싼 출발점입니다.


자주 묻는 질문 (FAQ)

Q. apply가 중단된 뒤 state 잠금이 풀리지 않으면 어떻게 하나요?

A. tofu force-unlock <LOCK_ID>로 잠금을 해제하거나, S3+DynamoDB 백엔드라면 잠금 테이블에서 해당 LockID 항목을 삭제할 수 있습니다. 다만 다른 사람이나 CI 잡이 실제로 apply 중일 때 잠금을 풀면 state가 꼬일 수 있으므로, 실행 중인 작업이 없는지 먼저 확인해야 합니다. CI에서 apply를 별도 잡으로 분리해 두면 누가 잠금을 잡고 있는지 추적하기도 쉬워집니다.


같이 보면 좋은 글