인프라

Cloud Run 서비스 vs 잡: 하나의 Go 바이너리, 두 개의 진입점

Cloud Run 서비스와 작업은 하나의 Go 이미지와 하나의 빌드를 공유할 수 있습니다. 여기서는 단일 바이너리가 서브 모드와 배치 작업으로 분할되는 방법과 그렇게 하지 말아야 할 경우를 설명합니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

Cloud Run은 컨테이너를 실행하는 두 가지 방법을 제공합니다. Service는 계속 실행되며 HTTP 요청에 응답합니다. Job은 시작하여 작업을 수행하고 종료됩니다. 대부분의 가이드에서는 이들을 별도의 이미지를 사용하는 별개의 제품으로 취급합니다. 그럴 필요는 없습니다. 우리는 하나의 Go 바이너리, 하나의 이미지, 하나의 빌드 태그로 웹 서버와 배치 작업을 실행합니다. 바이너리는 첫 번째 인수를 보고 어떤 모드로 실행될지 결정합니다. 이 게시물에서는 디스패치 코드, 배포 형태, 그리고 대신 두 개의 이미지로 분리하는 것이 더 나았을 지점을 보여줍니다.

하나의 바이너리가 진입점을 선택하는 방법

핵심은 컨테이너 이미지에는 ENTRYPOINT가 하나만 있지만, Cloud Run에서는 각 Service 또는 Job이 해당 진입점에 자체 인수를 전달할 수 있다는 것입니다. 그래서 바이너리는 첫 번째 인수를 읽고 분기합니다.

func main() {
	if len(os.Args) < 2 {
		log.Fatal("usage: blog-engine <serve|ingest|index>")
	}
	switch os.Args[1] {
	case "serve":
		runServe(os.Args[2:]) // Cloud Run Service: long-lived HTTP
	case "ingest":
		runIngest(os.Args[2:]) // Cloud Run Job: parse, translate, load
	case "index":
		runIndex(os.Args[2:]) // Cloud Run Job: create DB indexes, then exit
	default:
		log.Fatalf("unknown command %q", os.Args[1])
	}
}

Dockerfile은 진입점을 바이너리로 설정하고 거기서 멈춥니다. 모드를 내장하지 않습니다.

ENTRYPOINT ["/blog-engine"]

Service는 serve를 인수로 사용하여 생성됩니다. 각 Job은 자체 명령어를 사용하여 생성됩니다. 이미지 다이제스트는 같지만 첫 번째 토큰은 다릅니다.

gcloud run deploy blog-engine \
  --image "$IMAGE" --args serve

gcloud run jobs create blog-engine-ingest \
  --image "$IMAGE" --args ingest

하나의 빌드는 하나의 아티팩트를 생성합니다. Service와 모든 Job은 정확히 동일한 바이트를 가져오므로, 페이지를 제공하는 코드와 해당 페이지를 데이터베이스에 작성한 코드 간에 버전 불일치가 없습니다.

serve 모드는 Cloud Run 서비스입니다

serve는 스스로 종료되지 않도록 설계된 요청-응답 프로세스입니다. Cloud Run은 이를 웜 상태로 유지하고, HTTP를 라우팅하며, 트래픽에 따라 인스턴스 수를 확장합니다. 블로그는 검색 크롤러를 위해 빠른 첫 바이트가 필요하기 때문에 서비스는 min-instances 1로 실행됩니다. 따라서 항상 하나의 인스턴스가 준비되어 있으며 조용한 아침의 첫 요청에도 콜드 스타트가 없습니다.

서비스가 올바르게 처리해야 하는 한 가지 플랫폼 세부 사항은 종료입니다. Cloud Run이 인스턴스를 축소하거나 새 리비전을 배포할 때, SIGTERM 신호를 보내고 SIGKILL 신호 전까지 프로세스에 짧은 유예 기간을 줍니다. SIGTERM을 무시하는 서버는 진행 중인 요청을 드롭하게 됩니다. 그래서 serve는 신호를 블로킹하고 드레이닝합니다.

func runServe(args []string) {
	srv := &http.Server{Addr: ":" + port(), Handler: router()}
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("serve: %v", err)
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()
	<-ctx.Done() // block here for the life of the instance

	shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = srv.Shutdown(shutCtx) // let open requests finish
}

구조는 간단합니다. 리스너를 시작하고, 종료 신호를 기다린 다음, 열려 있는 연결이 완료될 수 있도록 제한된 시간을 제공합니다. 서비스에 대한 모든 것은 프로세스가 단일 요청보다 오래 지속된다고 가정합니다.

ingest 및 index 모드는 Cloud Run Jobs입니다

Job은 반대 계약입니다. 포트에 연결되지 않으며 트래픽을 수신하지 않습니다. 완료될 때까지 실행되고 종료되며 종료 코드가 결과가 됩니다. 종료 코드 0은 작업이 성공했음을 의미합니다. 0이 아닌 종료 코드는 작업이 실패했음을 나타내며, 이는 재시도를 유발합니다.

func runIngest(args []string) {
	ctx := context.Background()
	if err := ingest.Run(ctx); err != nil {
		log.Fatalf("ingest: %v", err) // non-zero exit, the Job task fails
	}
	// clean return, exit 0, the Job task succeeds
}

블로그의 경우 ingest는 Markdown 게시물을 파싱하고, 변경된 섹션을 번역 모델로 보내고, 결과를 Firestore에 씁니다. index는 데이터베이스 색인을 한 번 생성하고 종료됩니다. 둘 다 웹 요청에 속하지 않습니다. 몇 분 동안 실행될 수 있으며, 일정에 따라 또는 수동으로 트리거되고, 성공 여부는 HTTP 본문이 아닌 예 또는 아니요로 결정됩니다.

Job에는 Service에는 없는 자체 실행 설정도 있습니다. 이것이 배치 단계에 중요한 설정입니다.

설정 제어하는 항목 Ingest 값
task-timeout 작업이 종료되기 전 최대 실제 시간 3600s
max-retries 실패한 작업당 재시도 횟수 0
parallelism 한 번에 실행되는 작업 수 1

블로그는 max-retries 0으로 설정하는데, 이는 절반만 완료된 번역 실행이 자동으로 반복되어서는 안 되기 때문입니다. 실패는 반복되는 것이 아니라 중지하고 살펴봐야 합니다. 다른 워크로드, 예를 들어 독립적인 파일을 처리하는 팬아웃(fan-out)은 재시도와 높은 병렬 처리를 원할 수 있습니다. 요점은 이러한 조정 기능이 Job에 있으며, Service로 실행되는 동일한 바이너리는 이를 전혀 볼 수 없다는 것입니다.

두 개의 리포지토리 대신 하나의 바이너리를 사용하는 이유

이를 하나로 합치는 이유는 웹 서버와 배치 작업이 서로 무관하지 않기 때문입니다. 진입점(entry point) 아래의 거의 모든 것을 공유합니다. 서빙 코드는 수집(ingest) 코드가 글을 쓰는 데 사용하는 것과 동일한 store 패키지에서 글을 읽습니다. 둘 다 동일한 데이터 모델, 동일한 택소노미 로더, 동일한 Firestore 연결 코드, 동일한 설정 파싱을 사용합니다.

이것을 두 개의 리포지토리나 두 개의 이미지로 분리하면 이제 스토어 계층의 복사본 두 개를 유지하거나 자체 버전 관리가 있는 공유 라이브러리를 유지해야 합니다. 양쪽이 서로 달라지게 됩니다. 서버는 수집 측이 아직 쓰지 않은 필드를 예상하기 시작하거나, 수집 측이 서버가 읽기를 중단하도록 업데이트된 형태를 쓰게 됩니다. 이러한 종류의 버그는 데이터가 서로 다른 버전의 두 부분을 모두 통과할 때까지 보이지 않습니다.

하나의 바이너리를 사용하면 Post 구조체가 정의되는 곳이 한 군데, 쿼리 형태가 있는 곳이 한 군데이며, 컴파일은 두 모드 모두에 대해 성공하거나 두 모드 모두에 대해 실패합니다. 수집 측이 새 필드를 쓰기 시작하면, 서빙 코드는 동일한 빌드에서 동일한 정의를 기반으로 컴파일됩니다. 작성자와 독자 간의 버전 불일치는 불가능해집니다. 왜냐하면 둘은 동일한 프로그램이기 때문입니다.

이에 대한 비용은 정직하고 작습니다. 이미지는 현재 실행 중이지 않은 모드의 코드를 가지고 있습니다. 서브 인스턴스는 바이너리 안에 사용되지 않는 수집 코드를 가지고 있고, 수집 작업(Job)은 HTTP 서버가 컴파일되어 있지만 결코 수신 대기하지 않습니다. Go 바이너리의 경우 몇 메가바이트가 추가되는 정도이므로 실제 제약은 아닙니다. 또한 main에 디스패치 로직이 필요한데, 이것이 위의 switch 문입니다. 이것이 전체적인 비용입니다.

시크릿을 삭제하지 않고 공유 이미지 배포하기

하나의 이미지를 공유하면 재배포의 모습이 달라집니다. 빌드는 한 번만 이루어집니다. 빌드가 완료되면 서비스와 모든 작업(Job)이 새 이미지를 가리키게 되며, 그 외에는 아무것도 변경되지 않습니다.

gcloud run services update blog-engine --image "$IMAGE"
gcloud run jobs update blog-engine-ingest --image "$IMAGE"

뼈아픈 경험으로 배운 규칙은 재배포 시 --image만 건드리고 다른 것은 건드리지 않는 것입니다. 환경 변수와 시크릿은 생성 시에 한 번 설정되었습니다. 만약 재배포 시 --set-secrets--set-env-vars도 전달하면, 이 플래그들은 기존 세트 전체를 병합하는 대신 대체합니다. 재배포 명령어에서 시크릿 하나를 빼먹으면 실행 중인 서비스에서 해당 시크릿이 사라집니다. 프로덕션에서 이런 일이 처음 발생하면, 서버는 빈 데이터베이스 URL로 부팅되고 모든 페이지에서 500 오류가 발생합니다. 따라서 생성 명령어에 환경 변수와 시크릿을 넣고, 업데이트 명령어에는 이미지만 포함해야 합니다.

배치 워크로드는 오랫동안 단일 작업에 머무르는 경우가 거의 없기 때문에, 작업(Job)들은 하드코딩된 단일 이름보다는 목록으로 관리하는 것이 좋습니다.

JOBS=("blog-engine-ingest")

for job in "${JOBS[@]}"; do
  gcloud run jobs update "$job" --image "$IMAGE"
done

두 번째 작업, 예를 들어 야간 사이트맵 재구축 작업이 나타나면, 배열에 추가되고 루프가 동일한 이미지로 업데이트합니다. 이것을 추가하는 것을 잊으면 해당 작업은 아무런 경고 없이 계속 이전 이미지를 실행합니다. 왜냐하면 오래된 작업은 실패하지 않고, 단지 조용히 잘못된 작업을 수행하기 때문입니다. 배열을 사용하면 전체 작업 세트를 한눈에 보고 확장할 수 있는 하나로 만들 수 있습니다.

백그라운드 goroutine 대신 Job을 사용해야 하는 경우

모든 것을 Service에 유지하는 것은 매력적인 지름길입니다. 이미 오래 실행되는 프로세스가 있으므로 요청에 응답한 후 goroutine에서 무거운 작업을 시작하는 것은 어떨까요? 실제 CPU나 수 분의 벽시계 시간이 필요한 모든 작업에 대해 Cloud Run에서는 이것이 잘못된 본능입니다.

Service 인스턴스는 요청을 중심으로 요금이 청구되고 확장됩니다. goroutine의 백그라운드 작업은 해당 작업을 시작한 요청이 반환된 후 인스턴스가 축소될 때 중단될 수 있으며, 인스턴스 크기가 조정된 CPU를 두고 요청 처리와 경쟁합니다. 해당 작업에는 작업 시간 초과, 재시도, 명확한 성공 또는 실패 신호가 없습니다. 플랫폼에는 보이지 않습니다.

Job은 요청 외부에서 실행되는 모든 작업에 적합한 공간입니다. 결정은 거의 기계적입니다.

  • HTTP 요청에 응답, 세션 유지, 페이지 제공: Service.
  • 예약된 작업, 데이터 마이그레이션, 일괄 가져오기, 야간 재빌드: Job.
  • 요청 외부에서 실제 CPU 또는 긴 벽시계 시간이 필요한 경우: Service에 매달린 goroutine가 아닌 Job.

블로그의 수집은 교과서적인 사례입니다. 일정에 따라 실행되고, 몇 분이 걸릴 수 있으며, 확고한 시간 초과와 명확한 실패 여부 결과가 필요하며, 페이지 렌더링에서 사이클을 훔쳐서는 안 됩니다. Service가 이미 동일한 바이너리를 실행하고 있고 기술적으로 작업을 수행할 수 있음에도 불구하고 이는 Job입니다.

트레이드오프, 간단히 말해서

두 개의 진입점을 가진 하나의 바이너리는 단일 빌드, 단일 버전, 그리고 데이터를 쓰는 코드와 읽는 코드 간의 불일치 없음을 보장합니다. 서버와 배치 작업이 데이터 모델을 공유하는 시스템에서 이는 큰 이점입니다. 그 대가로 약간 더 큰 이미지와 main에 있는 작은 디스패치 스위치를 감수해야 합니다. 온라인 경로와 오프라인 경로 간에 코드를 공유하는 대부분의 서비스에게 이는 좋은 트레이드오프입니다.

양쪽이 공유를 멈추면 이는 더 이상 좋은 트레이드오프가 아닙니다. 예를 들어, Job이 서버에는 필요 없는 머신러닝 런타임이나 대규모 데이터 툴킷과 같은 무거운 의존성 트리를 가져오는 경우, 이를 서버 이미지에 포함시키면 실행되지도 않는 코드로 인해 모든 서빙 인스턴스가 비대해지고 콜드 스타트가 느려집니다. 두 경로가 서로 다른 팀에 의해 소유되고 다른 주기로 배포되는 경우, 이를 하나의 빌드 태그로 강제하면 독립적으로 진행되어야 할 릴리스가 서로 종속됩니다. 그리고 모델, 스토어, 설정 등 진정으로 공유하는 것이 아무것도 없다면, 단일 바이너리는 그저 하나의 진입점을 사용하는 관련 없는 두 프로그램일 뿐이며, 두 개의 이미지를 사용하는 것이 더 명확할 것입니다.

판단 기준은 진입점 아래의 공유 코드입니다. Service와 Job이 동일한 패키지를 통해 동일한 타입을 읽고 쓰는 경우에는 하나의 바이너리로 유지하고 첫 번째 인자로 모드를 선택하게 하십시오. 의존성, 소유권 또는 데이터에서 차이가 발생하면 이미지를 분리하고 각자가 자신의 조건에 맞게 빌드하도록 하십시오.

관련 글