근묵자흑
자동화된 테스트부터 정적 분석까지 본문
테라폼(Terraform)으로 인프라를 관리할 때 코드의 품질과 안정성을 보장하기 위해서는 체계적인 테스트 전략이 필요합니다. 이 글에서는 다양한 테스트 방법과 실제 구현 예제를 살펴보겠습니다.
1. 자동화된 테스트
테라폼 코드의 자동화된 테스트를 시작하기 위해, Ruby 웹 서버 예제를 통해 기본 개념을 이해해보겠습니다. 통합 테스트를 위해서는 다음과 같은 단계가 필요합니다:
- localhost에서 웹 서버를 실행하여 포트 리스닝
- 웹 서버에 HTTP 요청 전송
- 예상한 응답이 오는지 검증
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 단계로 구성됩니다:
- MySQL 모듈에 대한
terraform apply
실행 - hello-world-app 모듈에 대한
terraform apply
실행 - 모든 것이 정상 작동하는지 검증 실행
- hello-world-app 모듈에 대한
terraform destroy
실행 - MySQL 모듈에 대한
terraform destroy
실행
CI 환경과 로컬 개발 환경의 차이
- CI 환경: 모든 단계를 처음부터 끝까지 실행
- 로컬 개발 환경: 모든 단계를 실행하는 것이 불필요할 수 있음
예를 들어, hello-world-app 모듈만 변경하는 경우, 매번 MySQL 모듈을 배포하고 제거하는 것은 불필요한 오버헤드(5-10분)가 됩니다.
이상적인 워크플로우
더 효율적인 개발을 위한 워크플로우는 다음과 같습니다:
- MySQL 모듈에 대한
terraform apply
실행 - hello-world-app 모듈에 대한
terraform apply
실행 - 반복적 개발 시작:
a. hello-world-app 모듈 변경
b. 변경된 hello-world-app 모듈에 대해terraform apply
실행
c. 검증 실행
d. 성공하면 다음 단계로, 실패하면 3a로 돌아가기 - hello-world-app 모듈에 대한
terraform destroy
실행 - 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'
테스트 단계를 사용하면 다음과 같은 이점이 있습니다:
- 빠른 피드백: 10-15분에서 10-60초로 테스트 시간 단축
- 반복적 개발 효율성 향상
- 필요한 단계만 선택적으로 실행 가능
- CI 환경에서는 전체 테스트 실행
- 로컬 개발 환경에서는 필요한 단계만 실행
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. 종단 간 테스트
종단 간 테스트는 전체 시스템을 실제 환경과 유사하게 테스트합니다. 하지만 다음과 같은 도전 과제가 있습니다:
- 너무 느림
- 전체 아키텍처 배포/제거에 수 시간 소요
- 피드백 루프가 너무 길어 하루에 한 번 정도만 실행 가능
- 너무 불안정
- 리소스가 많을수록 실패 확률 증가
- 예: 단일 리소스 실패 확률 0.1%일 때
- 20개 리소스: 98.0% 성공률
- 60개 리소스: 94.1% 성공률
- 600개 리소스: 54.9% 성공률
권장되는 접근 방식:
- 영구적인 테스트 환경 구축
- 변경사항만 점진적으로 적용하고 검증
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)
}
결론
테라폼 코드의 품질을 보장하기 위해서는 다양한 테스트 방법을 조합하여 사용하는 것이 좋습니다:
- 단위 테스트를 기본으로
- 중요한 통합 포인트에 대한 통합 테스트 구현
- 핵심 시나리오에 대한 종단 간 테스트 구성
- 정적 분석 도구를 통한 기본적인 오류 방지
이러한 다층적 접근을 통해 안정적이고 신뢰할 수 있는 인프라 코드를 유지할 수 있습니다.
'IaC > terraform' 카테고리의 다른 글
GitOps를 통한 Terraform 협업 환경 구축하기 (with Atlantis) (0) | 2025.03.02 |
---|---|
Terraform을 팀에서 사용하는 방법 (0) | 2025.02.16 |
Terraform 코드 테스트 (8) | 2025.02.02 |
Terraform Mocks (4) | 2025.02.02 |
프로덕션 수준의 테라폼 코드 (3) | 2025.01.19 |