관리 메뉴

근묵자흑

자동화된 테스트부터 정적 분석까지 본문

IaC/terraform

자동화된 테스트부터 정적 분석까지

Luuuuu 2025. 2. 9. 18:35

테라폼(Terraform)으로 인프라를 관리할 때 코드의 품질과 안정성을 보장하기 위해서는 체계적인 테스트 전략이 필요합니다. 이 글에서는 다양한 테스트 방법과 실제 구현 예제를 살펴보겠습니다.

1. 자동화된 테스트

테라폼 코드의 자동화된 테스트를 시작하기 위해, Ruby 웹 서버 예제를 통해 기본 개념을 이해해보겠습니다. 통합 테스트를 위해서는 다음과 같은 단계가 필요합니다:

  1. localhost에서 웹 서버를 실행하여 포트 리스닝
  2. 웹 서버에 HTTP 요청 전송
  3. 예상한 응답이 오는지 검증
def do_integration_test(path, check_response)
  port = 8000
  server = WEBrick::HTTPServer.new :Port => port
  server.mount '/', WebServer

  begin
    # 별도 스레드에서 웹 서버 시작
    thread = Thread.new do
      server.start
    end

    # 지정된 경로로 HTTP 요청
    uri = URI("http://localhost:#{port}#{path}")
    response = Net::HTTP.get_response(uri)

    # 응답 검증
    check_response.call(response)

  ensure
    # 테스트 종료 시 서버와 스레드 종료
    server.shutdown
    thread.join
  end
end

# 테스트 사용 예시
def test_integration_hello
  do_integration_test('/', lambda { |response|
    assert_equal(200, response.code.to_i)
    assert_equal('text/plain', response['Content-Type'])
    assert_equal('Hello, World', response.body)
  })
end

2. 통합 테스트

2.1 테스트 단계

기본 테스트 단계

통합 테스트는 일반적으로 다음 5개의 distinct 단계로 구성됩니다:

  1. MySQL 모듈에 대한 terraform apply 실행
  2. hello-world-app 모듈에 대한 terraform apply 실행
  3. 모든 것이 정상 작동하는지 검증 실행
  4. hello-world-app 모듈에 대한 terraform destroy 실행
  5. MySQL 모듈에 대한 terraform destroy 실행

CI 환경과 로컬 개발 환경의 차이

  • CI 환경: 모든 단계를 처음부터 끝까지 실행
  • 로컬 개발 환경: 모든 단계를 실행하는 것이 불필요할 수 있음

예를 들어, hello-world-app 모듈만 변경하는 경우, 매번 MySQL 모듈을 배포하고 제거하는 것은 불필요한 오버헤드(5-10분)가 됩니다.

이상적인 워크플로우

더 효율적인 개발을 위한 워크플로우는 다음과 같습니다:

  1. MySQL 모듈에 대한 terraform apply 실행
  2. hello-world-app 모듈에 대한 terraform apply 실행
  3. 반복적 개발 시작:
    a. hello-world-app 모듈 변경
    b. 변경된 hello-world-app 모듈에 대해 terraform apply 실행
    c. 검증 실행
    d. 성공하면 다음 단계로, 실패하면 3a로 돌아가기
  4. hello-world-app 모듈에 대한 terraform destroy 실행
  5. MySQL 모듈에 대한 terraform destroy 실행

2.1.1 테스트 단계 구현

Terratest는 test_structure 패키지를 통해 이러한 테스트 단계를 네이티브하게 지원합니다. 각 테스트 단계는 이름이 있는 함수로 래핑되며, 환경 변수를 통해 특정 단계를 건너뛸 수 있습니다.

기본 테스트 구조

func TestHelloWorldAppStageWithStages(t *testing.T) {
    t.Parallel()

    stage := test_structure.RunTestStage

    // MySQL DB 배포
    defer stage(t, "teardown_db", func() { teardownDb(t, dbDirStage) })
    stage(t, "deploy_db", func() { deployDb(t, dbDirStage) })

    // Hello-world-app 배포
    defer stage(t, "teardown_app", func() { teardownApp(t, appDirStage) })
    stage(t, "deploy_app", func() { deployApp(t, dbDirStage, appDirStage) })

    // 검증
    stage(t, "validate_app", func() { validateApp(t, appDirStage) })
}

각 단계별 구현

DB 배포 함수

func deployDb(t *testing.T, dbDir string) {
    dbOpts := createDbOpts(t, dbDir)
    // 다른 테스트 단계에서 사용할 수 있도록 디스크에 데이터 저장
    test_structure.SaveTerraformOptions(t, dbDir, dbOpts)
    terraform.InitAndApply(t, dbOpts)
}

DB 제거 함수

func teardownDb(t *testing.T, dbDir string) {
    dbOpts := test_structure.LoadTerraformOptions(t, dbDir)
    defer terraform.Destroy(t, dbOpts)
}

앱 배포 함수

func deployApp(t *testing.T, dbDir string, helloAppDir string) {
    dbOpts := test_structure.LoadTerraformOptions(t, dbDir)
    helloOpts := createHelloOpts(dbOpts, helloAppDir)

    test_structure.SaveTerraformOptions(t, helloAppDir, helloOpts)
    terraform.InitAndApply(t, helloOpts)
}

앱 제거 함수

func teardownApp(t *testing.T, helloAppDir string) {
    helloOpts := test_structure.LoadTerraformOptions(t, helloAppDir)
    defer terraform.Destroy(t, helloOpts)
}

앱 검증 함수

func validateApp(t *testing.T, helloAppDir string) {
    helloOpts := test_structure.LoadTerraformOptions(t, helloAppDir)
    validateHelloApp(t, helloOpts)
}

2.1.3 테스트 워크플로우

데이터 저장과 로드

  • 각 테스트 단계는 디스크에 데이터를 저장
  • 다른 테스트 단계에서 이 데이터를 로드하여 사용
  • 이를 통해 서로 다른 프로세스에서도 동일한 데이터 유지 가능

단계 건너뛰기

환경 변수 SKIP_foo=true를 설정하여 특정 단계를 건너뛸 수 있습니다.

2.1.4 실행 예시와 시간 절약

초기 실행 (teardown 단계 제외)

$ SKIP_teardown_db=true \
  SKIP_teardown_app=true \
  go test -timeout 30m -run 'TestHelloWorldAppStageWithStages'

실행 결과:

The 'SKIP_deploy_db' environment variable is not set,
so executing stage 'deploy_db'.
...
ok terraform-up-and-running 423.650s

반복 개발 시 실행 (MySQL 배포 제외)

$ SKIP_teardown_db=true \
  SKIP_teardown_app=true \
  SKIP_deploy_db=true \
  go test -timeout 30m -run 'TestHelloWorldAppStageWithStages'

실행 결과:

The 'SKIP_deploy_db' environment variable is set,
so skipping stage 'deploy_db'.
...
ok terraform-up-and-running 13.824s

최종 정리 (배포와 검증 단계 제외)

$ SKIP_deploy_db=true \
  SKIP_deploy_app=true \
  SKIP_validate_app=true \
  go test -timeout 30m -run 'TestHelloWorldAppStageWithStages'

테스트 단계를 사용하면 다음과 같은 이점이 있습니다:

  1. 빠른 피드백: 10-15분에서 10-60초로 테스트 시간 단축
  2. 반복적 개발 효율성 향상
  3. 필요한 단계만 선택적으로 실행 가능
  4. CI 환경에서는 전체 테스트 실행
  5. 로컬 개발 환경에서는 필요한 단계만 실행

2.2 재시도

인프라 환경에서는 일시적인 오류가 발생할 수 있으므로, 재시도 로직이 필요합니다:

func createHelloOpts(
    dbOpts *terraform.Options,
    terraformDir string) *terraform.Options {
    return &terraform.Options{
        TerraformDir: terraformDir,
        Vars: map[string]interface{}{
            "db_remote_state_bucket": dbOpts.BackendConfig["bucket"],
            "db_remote_state_key": dbOpts.BackendConfig["key"],
            "environment": dbOpts.Vars["db_name"],
        },
        // 재시도 설정
        MaxRetries: 3,
        TimeBetweenRetries: 5 * time.Second,
        RetryableTerraformErrors: map[string]string{
            "RequestError: send request failed": "Throttling issue?",
        },
    }
}

3. 종단 간 테스트

종단 간 테스트는 전체 시스템을 실제 환경과 유사하게 테스트합니다. 하지만 다음과 같은 도전 과제가 있습니다:

  1. 너무 느림
    • 전체 아키텍처 배포/제거에 수 시간 소요
    • 피드백 루프가 너무 길어 하루에 한 번 정도만 실행 가능
  2. 너무 불안정
    • 리소스가 많을수록 실패 확률 증가
    • 예: 단일 리소스 실패 확률 0.1%일 때
      • 20개 리소스: 98.0% 성공률
      • 60개 리소스: 94.1% 성공률
      • 600개 리소스: 54.9% 성공률

권장되는 접근 방식:

  1. 영구적인 테스트 환경 구축
  2. 변경사항만 점진적으로 적용하고 검증

4. 다른 테스트 접근 방식

4.1 정적 분석

정적 분석 도구를 사용한 테스트 예제:

# tfsec 검사를 위한 예제 코드
resource "aws_security_group" "example" {
  name        = "example"
  description = "Example security group"
  vpc_id      = aws_vpc.main.id

  # tfsec이 경고할 수 있는 취약한 규칙
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # tfsec 경고: 너무 개방적인 SSH 접근
  }
}

# tflint 검사를 위한 예제 코드
resource "aws_instance" "example" {
  ami           = "ami-12345678"  # tflint 경고: 하드코딩된 AMI ID
  instance_type = "t2.micro"

  # tflint 경고: 태그 누락
}

OPA(Open Policy Agent) 정책 예제:

package terraform

# ManagedBy 태그 확인 정책
allow {
    resource_change := input.resource_changes[_]
    resource_change.change.after.tags["ManagedBy"]
}

# 인스턴스 크기 제한 정책
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_instance"
    not allowed_instance_types[resource.change.after.instance_type]

    msg := sprintf(
        "Instance type '%v' is not allowed. Allowed types are: %v",
        [resource.change.after.instance_type, allowed_instance_types]
    )
}

allowed_instance_types = {
    "t2.micro",
    "t2.small",
    "t2.medium"
}

4.2 Plan 테스트

terraform plan 출력을 분석하는 테스트 예제:

func TestAlbExamplePlan(t *testing.T) {
    t.Parallel()

    albName := fmt.Sprintf("test-%s", random.UniqueId())
    opts := &terraform.Options{
        TerraformDir: "../examples/alb",
        Vars: map[string]interface{}{
            "alb_name": albName,
        },
    }

    planString := terraform.InitAndPlan(t, opts)

    // 리소스 수 검증
    resourceCounts := terraform.GetResourceCount(t, planString)
    require.Equal(t, 5, resourceCounts.Add)
    require.Equal(t, 0, resourceCounts.Change)
    require.Equal(t, 0, resourceCounts.Destroy)

    // Plan 상세 검증
    planStruct := terraform.InitAndPlanAndShowWithStructNoLogTempPlanFile(t, opts)
    alb, exists := planStruct.ResourcePlannedValuesMap["module.alb.aws_lb.example"]
    require.True(t, exists, "aws_lb resource must exist")

    name, exists := alb.AttributeValues["name"]
    require.True(t, exists, "missing name parameter")
    require.Equal(t, albName, name)
}

결론

테라폼 코드의 품질을 보장하기 위해서는 다양한 테스트 방법을 조합하여 사용하는 것이 좋습니다:

  1. 단위 테스트를 기본으로
  2. 중요한 통합 포인트에 대한 통합 테스트 구현
  3. 핵심 시나리오에 대한 종단 간 테스트 구성
  4. 정적 분석 도구를 통한 기본적인 오류 방지

이러한 다층적 접근을 통해 안정적이고 신뢰할 수 있는 인프라 코드를 유지할 수 있습니다.