Terraform 병렬 실행 구조 알아보기

김유경·2025년 9월 13일

병렬 처리 기본 구조

parallelism 파라미터

Terraform CLI는 --parallelism=N 옵션을 통해 동시에 실행할 리소스의 최대 개수를 제한할 수 있습니다. 기본값은 10입니다.

🔗 테라폼 공식 문서 바로가기

internal/terraform/context.go 테라폼 코드 바로가기

par := opts.Parallelism

// 음수는 에러 반환
if par < 0 {
    return nil, diags
}

// 0이면 기본값 10 적용
if par == 0 {
    par = 10
}

parallelism 값이 0이면 기본값 10으로 설정되며, 음수 값이 입력되면 오류를 반환하며 실행을 중단합니다. 이 설정 값은 내부적으로 세마포어를 통해 병렬 실행 수를 제어하는 데 사용됩니다.


세마포어로 병렬 실행 제한

테라폼은 병렬 실행 수를 제어하기 위해 Go의 buffered channel을 기반으로 한 세마포어 구조를 사용합니다.

internal/terraform/semaphore.go

func NewSemaphore(n int) Semaphore {
    if n <= 0 {
        panic("semaphore with limit <=0")
    }
    ch := make(chan struct{}, n)
    return Semaphore(ch)
}

내부적으로 chan struct{} 타입의 버퍼 채널을 사용해 세마포어를 만듭니다. 버퍼 크기 n은 병렬로 동시에 실행 가능한 최대 리소스 수를 의미합니다.

internal/terraform/graph_walk_context.go

func (w *ContextGraphWalker) Execute(ctx EvalContext, n GraphNodeExecutable) tfdiags.Diagnostics {
    // Acquire a lock on the semaphore
    w.Context.parallelSem.Acquire() // 세마포어 락 획득
    defer w.Context.parallelSem.Release() // 종료 후 해제

    return n.Execute(ctx, w.Operation)
}

테라폼은 각 리소스를 실행할 때 Acquire()를 호출하여 세마포어 자원을 획득하고, 실행이 끝나면 Release()로 반환합니다. 이로 인해 설정된 개수를 초과하는 작업은 자동으로 대기 상태에 들어가며, 최대 병렬 실행 수가 제한됩니다.

🔍 버퍼 채널이란?

Go에서는 기본적으로 채널은 하나의 값을 주고받을 때마다 송신자와 수신자가 서로 기다려야 하지만, 버퍼 채널은 미리 정해진 크기만큼 값을 쌓아둘 수 있기 때문에 기다리지 않고 정해진 개수만큼 값을 미리 보내 둘 수 있습니다.

테라폼에서는 이 특성을 이용해 동시에 실행할 수 있는 작업의 수를 조절합니다. 버퍼가 가득 차면 새로운 값은 들어갈 수 없고 대기하게 되므로, 동시에 실행되는 작업 수가 n을 넘지 않도록 보장할 수 있습니다.


DAG 기반 실행 흐름 (internal/dag/walk.go)

테라폼은 리소스 간의 의존 관계를 DAG(방향성 비순환 그래프)로 표현하고, 이 그래프를 따라 리소스를 실행합니다. 쉽게 말해, 어떤 리소스가 다른 리소스를 참조하거나 의존할 경우, 해당 리소스가 먼저 실행된 후에 다음 리소스가 실행되도록 설계되어 있습니다.

Walker 구조체

type Walker struct {
    Callback   WalkFunc    // 실제 리소스를 실행할 함수
    vertices   Set         // 모든 리소스(vertex) 목록
    edges      Set         // 의존 관계(edge) 목록
    vertexMap  map[Vertex]*walkerVertex // 각 리소스의 실행 상태 저장
    wait       sync.WaitGroup  // 전체 작업 완료 대기
    ...
}

고루틴 생성 전략

테라폼은 각 리소스를 실행할 때 2개의 고루틴을 생성합니다.

// Walker will create V*2 goroutines (one for each vertex, and dependency
// waiter for each vertex). In general this should be of no concern unless
// there are a huge number of vertices.
  • waitDeps(): 이 리소스가 언제 실행할 수 있는지 판단
  • walkVertex(): 실제 리소스를 실행하는 작업 수행

이렇게 분리하면 작업 실행과 의존성 대기가 서로 간섭 없이 병렬로 진행되기 때문에 리소스 수가 많아도 각 작업이 언제 시작해야 할지를 정확하고 빠르게 판단할 수 있습니다.

실행 대기: waitDeps

func (w *Walker) waitDeps(...) {
    for dep, depCh := range deps {
        <-depCh  // 각 의존성 리소스가 끝날 때까지 대기
    }

    // 모든 의존성이 성공했는지 확인
    for dep := range deps {
        if w.diagsMap[dep].HasErrors() {
            doneCh <- false // 실패한 의존성이 있음
            return
        }
    }

    doneCh <- true // 모두 성공 → 실행 시작 가능
}

의존성이 없는 리소스는 바로 실행되고, 의존성이 있는 리소스는 상위 작업이 끝날 때까지 기다립니다. 만약 상위 작업 중 하나라도 실패하면, 해당 리소스는 실행되지 않고 건너뛰게 됩니다.

실행: walkVertex

func (w *Walker) walkVertex(v Vertex, info *walkerVertex) {
    // 의존성 대기
    ...
    
    // 모든 의존성이 성공했을 때만 실행
    if depsSuccess {
        diags = w.Callback(v)  // 실제 작업 실행
    } else {
        diags = diags.Append(errors.New("upstream dependencies failed"))
    }

    // 실행 결과 저장
    w.diagsMap[v] = diags
}

모든 의존 리소스가 정상적으로 실행되었을 때만 작업을 수행합니다. 실행 후에는 성공/실패 결과를 기록하고, 다음 리소스들이 이 결과를 참고할 수 있도록 합니다.


전체 실행 흐름 정리

  1. 리소스 간 관계를 분석해 DAG 생성
  2. 각 리소스마다 의존성 감시용과 실행용 고루틴 생성
  3. 상위 리소스가 모두 끝날 때까지 대기
  4. 조건이 충족된 리소스를 최대 N개까지 동시 실행
  5. 모든 실행이 끝나면 성공/실패 결과 수집

0개의 댓글