일반적인 개발 팁

이 가이드에서는 Cloud Run 서비스의 설계, 구현, 테스트, 배포를 위한 권장사항을 제공합니다. 자세한 내용은 기존 서비스 마이그레이션을 참조하세요.

효과적인 서비스 작성

이 섹션에서는 Cloud Run의 디자인과 구현을 위한 일반적인 권장사항을 설명합니다.

백그라운드 활동

백그라운드 활동이란 HTTP 응답이 전달된 뒤에 발생하는 모든 활동입니다. 서비스에서 쉽게 드러나지 않는 백그라운드 활동이 있는지 확인하려면 HTTP 요청에 대한 항목 이후에 기록된 내용이 있는지 로그를 확인하세요.

백그라운드 활동을 사용하기 위해 항상 할당되도록 CPU 구성

Cloud Run 서비스에서 백그라운드 활동을 지원하려면 요청 외부에서 백그라운드 활동을 실행하고 CPU 액세스 권한을 유지할 수 있도록 Cloud Run 서비스 CPU를 항상 할당되도록 설정합니다.

CPU가 요청 처리 중에만 할당되는 경우 백그라운드 활동 방지

요청 처리 중에만 CPU를 할당하도록 서비스를 설정해야 하는 경우 Cloud Run 서비스가 요청 처리를 완료하면 인스턴스의 CPU 액세스가 사용 중지되거나 크게 제한됩니다. 이러한 유형의 CPU 할당을 사용하는 경우 요청 핸들러의 범위를 벗어나 실행되는 백그라운드 스레드 또는 루틴을 시작해서는 안 됩니다.

응답을 전달하기 전에 모든 비동기식 작업이 완료되도록 코드를 검토합니다.

이러한 유형의 CPU 할당으로 백그라운드 스레드를 실행하면 이후에 동일한 컨테이너 인스턴스에 대한 요청으로 정지된 백그라운드 활동이 다시 시작되므로 예기치 않은 동작이 발생할 수 있습니다.

임시 파일 삭제

Cloud Run 환경에서 디스크 스토리지는 메모리 내의 파일 시스템입니다. 디스크에 작성된 파일은 서비스에 제공되는 메모리를 사용하며 호출 시 그대로 유지됩니다. 이 파일을 명시적으로 삭제하지 못하면 결국 메모리 부족 오류가 발생한 다음 콜드 스타트가 진행될 수 있습니다.

오류 보고

모든 예외를 처리하고 오류 시 서비스 장애가 발생하지 않도록 합니다. 트래픽이 대체 인스턴스의 큐에서 대기하는 동안 서비스 장애로 인해 콜드 스타트가 진행됩니다.

오류를 올바르게 보고하는 방법에 대해서는 Error reporting 가이드를 참조하세요.

성능 최적화

이 섹션에서는 성능 최적화를 위한 권장사항을 설명합니다.

컨테이너를 빠르게 시작

인스턴스는 필요에 따라 확장되므로 시작 시간이 서비스 지연 시간에 영향을 미칩니다. Cloud Run은 인스턴스 시작과 요청 처리를 분리하지만 요청이 새 인스턴스가 처리될 때까지 기다려야 할 수도 있습니다. 특히 0에서 확장할 때 이러한 상황이 발생합니다. 이를 '콜드 스타트'라고 합니다.

시작 루틴은 다음으로 구성됩니다.

  • 컨테이너 이미지 다운로드(Cloud Run의 컨테이너 이미지 스트리밍 기술 사용)
  • 진입점 명령어를 실행하여 컨테이너 시작
  • 컨테이너가 구성된 포트에서 리슨하기를 대기

컨테이너 시작 속도를 최적화하면 요청 처리 지연 시간이 최소화됩니다.

시작 CPU 부스트를 사용하여 시작 지연 시간 감소

시작 CPU 부스트를 사용 설정하면 시작 지연 시간을 줄이기 위해 인스턴스 시작 중에 CPU 할당을 일시적으로 늘릴 수 있습니다.

최소 인스턴스를 사용한 콜드 스타트 감소

콜드 스타트를 최소화하도록 최소 인스턴스동시 실행을 구성할 수 있습니다. 예를 들어 최소 인스턴스를 1로 설정하면 서비스가 새 인스턴스를 시작할 필요 없이 서비스에 구성된 동시 요청 수를 수신할 준비가 되는 것을 의미합니다.

인스턴스가 시작되기를 기다리는 요청은 다음과 같이 큐에서 대기 상태로 유지됩니다.

  • 수평 확장 시와 같이 새 인스턴스가 시작되는 경우 요청은 최소한 이 서비스의 컨테이너 인스턴스 평균 시작 시간만큼 대기합니다. 여기에는 0에서 확장하는 경우와 같이 요청이 수평 확장을 시작하는 경우가 포함됩니다.
  • 시작 시간이 10초 미만이면 요청이 최대 10초 동안 대기합니다.
  • 시작 프로세스에 인스턴스가 없고 요청이 수평 확장을 시작하지 않은 경우 요청은 최대 10초 동안 대기합니다.

현명하게 종속 항목 사용

Node.js의 모듈 가져오기와 같은 종속 라이브러리에서 동적 언어를 사용하면 해당 모듈의 로드 시간이 시작 지연 시간에 추가됩니다.

다음과 같은 방법으로 시작 지연 시간을 줄일 수 있습니다.

  • 린(lean) 서비스를 빌드하는 데 필요한 종속 항목의 수와 크기를 최소화합니다.
  • 언어에서 지원하는 경우 자주 사용되지 않는 코드를 느리게 로드합니다.
  • PHP의 Composer 자동 로더 최적화와 같은 코드 로딩 최적화를 사용합니다.

전역 변수 사용

Cloud Run에서는 요청 간 서비스 상태가 보존된다고 가정할 수 없습니다. 하지만 Cloud Run은 진행 중인 트래픽을 제공하기 위해 개별 인스턴스를 재사용하므로 후속 호출에서 변수 값 재사용을 허용하도록 전역 범위의 변수를 선언할 수 있습니다. 이러한 재사용의 이점이 개별 요청에 적용되는지 여부는 사전에 알 수 없습니다.

또한 서비스 요청마다 객체를 다시 만드는 데 비용이 많이 드는 경우 메모리에 객체를 캐싱할 수 있습니다. 이러한 객체를 요청 로직에서 전역 범위로 이동하면 성능이 향상될 수 있습니다.

Node.js

const functions = require('@google-cloud/functions-framework');

// TODO(developer): Define your own computations
const {lightComputation, heavyComputation} = require('./computations');

// Global (instance-wide) scope
// This computation runs once (at instance cold-start)
const instanceVar = heavyComputation();

/**
 * HTTP function that declares a variable.
 *
 * @param {Object} req request context.
 * @param {Object} res response context.
 */
functions.http('scopeDemo', (req, res) => {
  // Per-function scope
  // This computation runs every time this function is called
  const functionVar = lightComputation();

  res.send(`Per instance: ${instanceVar}, per function: ${functionVar}`);
});

Python

import time

import functions_framework


# Placeholder
def heavy_computation():
    return time.time()


# Placeholder
def light_computation():
    return time.time()


# Global (instance-wide) scope
# This computation runs at instance cold-start
instance_var = heavy_computation()


@functions_framework.http
def scope_demo(request):
    """
    HTTP Cloud Function that declares a variable.
    Args:
        request (flask.Request): The request object.
        <http://flask.pocoo.org/docs/1.0/api/#flask.Request>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.make_response>.
    """

    # Per-function scope
    # This computation runs every time this function is called
    function_var = light_computation()
    return f"Instance: {instance_var}; function: {function_var}"

Go


// h is in the global (instance-wide) scope.
var h string

// init runs during package initialization. So, this will only run during an
// an instance's cold start.
func init() {
	h = heavyComputation()
	functions.HTTP("ScopeDemo", ScopeDemo)
}

// ScopeDemo is an example of using globally and locally
// scoped variables in a function.
func ScopeDemo(w http.ResponseWriter, r *http.Request) {
	l := lightComputation()
	fmt.Fprintf(w, "Global: %q, Local: %q", h, l)
}

자바


import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;

public class Scopes implements HttpFunction {
  // Global (instance-wide) scope
  // This computation runs at instance cold-start.
  // Warning: Class variables used in functions code must be thread-safe.
  private static final int INSTANCE_VAR = heavyComputation();

  @Override
  public void service(HttpRequest request, HttpResponse response)
      throws IOException {
    // Per-function scope
    // This computation runs every time this function is called
    int functionVar = lightComputation();

    var writer = new PrintWriter(response.getWriter());
    writer.printf("Instance: %s; function: %s", INSTANCE_VAR, functionVar);
  }

  private static int lightComputation() {
    int[] numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    return Arrays.stream(numbers).sum();
  }

  private static int heavyComputation() {
    int[] numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    return Arrays.stream(numbers).reduce((t, x) -> t * x).getAsInt();
  }
}

전역 변수의 지연 초기화 수행

전역 변수 초기화는 항상 시작 중에 수행되므로 콜드 스타트 시간이 늘어납니다. 자주 사용하지 않는 객체에 지연 초기화를 사용하면 시간 비용을 줄이고 콜드 스타트 시간을 줄일 수 있습니다.

Node.js

const functions = require('@google-cloud/functions-framework');

// Always initialized (at cold-start)
const nonLazyGlobal = fileWideComputation();

// Declared at cold-start, but only initialized if/when the function executes
let lazyGlobal;

/**
 * HTTP function that uses lazy-initialized globals
 *
 * @param {Object} req request context.
 * @param {Object} res response context.
 */
functions.http('lazyGlobals', (req, res) => {
  // This value is initialized only if (and when) the function is called
  lazyGlobal = lazyGlobal || functionSpecificComputation();

  res.send(`Lazy global: ${lazyGlobal}, non-lazy global: ${nonLazyGlobal}`);
});

Python

import functions_framework

# Always initialized (at cold-start)
non_lazy_global = file_wide_computation()

# Declared at cold-start, but only initialized if/when the function executes
lazy_global = None


@functions_framework.http
def lazy_globals(request):
    """
    HTTP Cloud Function that uses lazily-initialized globals.
    Args:
        request (flask.Request): The request object.
        <http://flask.pocoo.org/docs/1.0/api/#flask.Request>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.make_response>.
    """
    global lazy_global, non_lazy_global

    # This value is initialized only if (and when) the function is called
    if not lazy_global:
        lazy_global = function_specific_computation()

    return f"Lazy: {lazy_global}, non-lazy: {non_lazy_global}."

Go


// Package tips contains tips for writing Cloud Functions in Go.
package tips

import (
	"context"
	"log"
	"net/http"
	"sync"

	"cloud.google.com/go/storage"
	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

// client is lazily initialized by LazyGlobal.
var client *storage.Client
var clientOnce sync.Once

func init() {
	functions.HTTP("LazyGlobal", LazyGlobal)
}

// LazyGlobal is an example of lazily initializing a Google Cloud Storage client.
func LazyGlobal(w http.ResponseWriter, r *http.Request) {
	// You may wish to add different checks to see if the client is needed for
	// this request.
	clientOnce.Do(func() {
		// Pre-declare an err variable to avoid shadowing client.
		var err error
		client, err = storage.NewClient(context.Background())
		if err != nil {
			http.Error(w, "Internal error", http.StatusInternalServerError)
			log.Printf("storage.NewClient: %v", err)
			return
		}
	})
	// Use client.
}

자바


import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;

public class LazyFields implements HttpFunction {
  // Always initialized (at cold-start)
  // Warning: Class variables used in Servlet classes must be thread-safe,
  // or else might introduce race conditions in your code.
  private static final int NON_LAZY_GLOBAL = fileWideComputation();

  // Declared at cold-start, but only initialized if/when the function executes
  // Uses the "initialization-on-demand holder" idiom
  // More information: https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
  private static class LazyGlobalHolder {
    // Making the default constructor private prohibits instantiation of this class
    private LazyGlobalHolder() {}

    // This value is initialized only if (and when) the getLazyGlobal() function below is called
    private static final Integer INSTANCE = functionSpecificComputation();

    private static Integer getInstance() {
      return LazyGlobalHolder.INSTANCE;
    }
  }

  @Override
  public void service(HttpRequest request, HttpResponse response)
      throws IOException {
    Integer lazyGlobal = LazyGlobalHolder.getInstance();

    var writer = new PrintWriter(response.getWriter());
    writer.printf("Lazy global: %s; non-lazy global: %s%n", lazyGlobal, NON_LAZY_GLOBAL);
  }

  private static int functionSpecificComputation() {
    int[] numbers = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9};
    return Arrays.stream(numbers).sum();
  }

  private static int fileWideComputation() {
    int[] numbers = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9};
    return Arrays.stream(numbers).reduce((t, x) -> t * x).getAsInt();
  }
}

다른 실행 환경 사용

다른 실행 환경을 사용하면 시작 시간이 더욱 단축될 수 있습니다.

동시 실행 최적화

Cloud Run 인스턴스는 구성 가능한 최대 동시 실행 값까지 여러 요청을 동시에 처리할 수 있습니다. 이는 concurrency = 1을 사용하는 Cloud Run 함수와 다릅니다.

Cloud Run은 동시 실행을 구성된 최대 한도까지 자동으로 조정합니다.

기본 최대 동시성 80은 많은 컨테이너 이미지에 적합합니다. 하지만 다음을 수행해야 합니다.

  • 컨테이너가 많은 동시 요청을 처리할 수 없는 경우 값을 낮춥니다.
  • 컨테이너가 많은 요청 볼륨을 처리할 수 있으면 값을 늘립니다.

서비스의 동시 실행 조정

각 인스턴스가 처리할 수 있는 동시 요청 수는 기술 스택과 변수 및 데이터베이스 연결과 같은 공유 리소스 사용에 의해 제한될 수 있습니다.

안정적인 최대 동시 실행을 위해 서비스를 최적화하려면 다음 안내를 따르세요.

  1. 서비스 성능을 최적화합니다.
  2. 모든 코드 수준 동시 실행 구성에서 예상되는 동시 실행 지원 수준을 설정합니다. 일부 기술 스택에는 이러한 설정이 필요하지 않습니다.
  3. 서비스를 배포합니다.
  4. 서비스의 Cloud Run 동시 실행을 코드 수준 구성과 같거나 낮게 설정합니다. 코드 수준 구성이 없으면 예상 동시 실행을 사용하세요.
  5. 구성 가능한 동시 실행을 지원하는 부하 테스트 도구를 사용합니다. 예상되는 부하 및 동시 실행 조건에서 서비스가 안정적으로 유지되는지 확인해야 합니다.
  6. 서비스가 제대로 작동하지 않으면 1단계로 이동하여 서비스를 개선하거나 2단계로 이동하여 동시 실행을 줄입니다. 서비스가 제대로 작동하면 2단계로 돌아가서 동시 실행을 늘립니다.

안정적인 최대 동시 실행 횟수를 찾을 때까지 위 단계를 계속 반복합니다.

동시 실행에 메모리 일치

서비스에서 요청을 처리할 때마다 일정량의 추가 메모리가 필요합니다. 따라서 동시 실행을 조정하는 경우 메모리 한도도 조정해야 합니다.

변경 가능한 전역 상태 방지

동시 실행 컨텍스트에서 변경 가능한 전역 상태를 활용하려면 코드에서 추가 단계를 수행하여 안전하게 수행하세요. 전역 변수를 일회성 초기화로 제한하여 경합을 최소화하고 성능에 설명된 대로 재사용합니다.

여러 요청을 동시에 처리하는 서비스에서 변경 가능한 전역 변수를 사용하는 경우 잠금 또는 뮤텍스를 사용하여 경합 상태를 방지해야 합니다.

처리량, 지연 시간, 비용 절충

최대 동시 요청 설정을 조정하면 서비스의 처리량, 지연 시간, 비용 간의 균형을 유지하는 데 도움이 됩니다.

일반적으로 최대 동시 요청 설정이 낮을수록 지연 시간이 짧아지고 인스턴스당 처리량이 줄어듭니다. 최대 동시 요청이 낮을수록 각 인스턴스 내에서 리소스를 놓고 경쟁하는 요청이 줄어들고 각 요청의 성능이 향상됩니다. 그러나 각 인스턴스가 한 번에 처리할 수 있는 요청 숫자가 적어지므로 인스턴스당 처리량이 줄어들고 서비스에 동일한 트래픽을 처리하려면 더 많은 인스턴스가 필요합니다.

반대로 최대 동시 요청 설정이 높을수록 일반적으로 지연 시간이 길어지고 인스턴스당 처리량이 증가합니다. 요청이 인스턴스 내부의 CPU, GPU, 메모리 대역폭과 같은 리소스에 액세스할 때까지 기다려야 하므로 지연 시간이 늘어납니다. 하지만 각 인스턴스가 한 번에 더 많은 요청을 처리할 수 있으므로 서비스에서 동일한 트래픽을 처리하는 데 필요한 전체 인스턴스가 줄어듭니다.

비용 고려사항

Cloud Run 청구는 인스턴스 시간을 기준으로 합니다. CPU가 항상 할당됨인 경우 인스턴스 시간은 각 인스턴스의 총 수명입니다. CPU가 항상 할당되지 않는 경우, 인스턴스 시간은 각 인스턴스에서 하나 이상의 요청을 처리하는 데 사용된 시간입니다.

최대 동시 요청이 결제에 미치는 영향은 트래픽 패턴에 따라 다릅니다. 최대 동시 요청을 낮추면 낮아진 설정이 다음과 같은 영향을 주는 경우 청구액이 줄어들 있습니다.

  • 지연 시간 감소
  • 인스턴스 작업 완료 속도 상승
  • 총 인스턴스 수가 더 필요한 경우에도 인스턴스가 더 빨리 종료됨

하지만 그 반대의 경우도 가능합니다. 지연 시간이 개선되어 각 인스턴스의 실행 시간이 줄어들어도 늘어난 인스턴스 숫자를 상쇄하지 못한다면 최대 동시 요청 수를 줄여도 청구가 증가할 수 있습니다.

청구를 최적화하는 가장 좋은 방법은 다양한 최대 동시 요청 설정을 사용하여 부하 테스트를 실행하고 container/billable_instance_time 모니터링 측정항목에 표시된 대로 청구 가능 인스턴스 시간이 가장 짧은 설정을 식별하는 것입니다.

컨테이너 보안

컨테이너식 애플리케이션에는 많은 범용 소프트웨어 보안 권장사항이 적용됩니다. 일부 권장사항은 컨테이너에만 적용되거나 컨테이너의 원칙과 아키텍처에 부합합니다.

컨테이너 보안을 개선하려면 다음 안내를 따르세요.

  • Google 기본 이미지와 같이 능동적으로 유지되고 안전한 기본 이미지를 사용하거나 Docker Hub의 공식 이미지를 사용합니다.

  • 컨테이너 이미지를 정기적으로 재빌드하고 서비스를 다시 배포하여 서비스에 보안 업데이트를 적용합니다.

  • 서비스를 실행하는 데 필요한 항목만 컨테이너에 포함합니다. 추가 코드, 패키지, 도구는 잠재적인 보안 취약점입니다. 관련 있는 성능 영향은 위의 내용을 참조하세요.

  • 특정 소프트웨어 및 라이브러리 버전을 포함하는 확정적 빌드 프로세스를 구현합니다. 이렇게 하면 확인되지 않은 코드가 컨테이너에 포함되지 않습니다.

  • Dockerfile USER을 사용하여 컨테이너가 root 이외의 사용자로 실행되도록 설정합니다. 일부 컨테이너 이미지에는 이미 특정 사용자가 구성되어 있을 수 있습니다.

보안 스캔 자동화

Artifact Registry에 저장된 컨테이너 이미지의 보안 스캔을 위해 취약점 스캔을 사용 설정합니다.

최소 컨테이너 이미지 빌드

대용량 컨테이너 이미지는 코드에 필요한 것보다 더 많이 포함되어 있기 때문에 보안 취약점을 높일 수 있습니다.

Cloud Run의 컨테이너 이미지 스트리밍 기술로 인해 컨테이너 이미지의 크기는 콜드 스타트 또는 요청 처리 시간에 영향을 미치지 않습니다. 또한 컨테이너 이미지 크기는 컨테이너의 사용 가능한 메모리 계산에 포함되지 않습니다.

최소 컨테이너를 빌드하려면 다음과 같은 린(lean) 기본 이미지를 만드는 것이 좋습니다.

Ubuntu는 크기가 더 크지만, 일반적으로 사용되는 기본 이미지로 보다 완벽하며 즉시 사용 가능한 서버 환경을 제공합니다.

서비스에 도구 사용량이 많은 빌드 프로세스가 있으면 다단계 빌드를 사용하여 런타임 시 컨테이너를 가볍게 유지하는 것이 좋습니다.

린(lean) 컨테이너 이미지 만들기에 대한 자세한 내용은 다음 리소스를 참조하세요.