이벤트 기반 함수 재시도 사용 설정

이 문서에서는 이벤트 기반 함수에 대해 재시도를 사용 설정하는 방법을 설명합니다. HTTP 함수에서는 자동 재시도를 사용할 수 없습니다.

재시도 의미 체계

Cloud Run Functions는 이벤트 소스에서 전송한 각 이벤트에 대해 이벤트 기반 함수를 최소한 1회 실행합니다. 기본적으로 함수 호출이 오류 발생으로 종료되면 해당 함수는 다시 호출되지 않으며 이벤트는 삭제됩니다. 이벤트 기반 함수에 재시도를 사용 설정하면 Cloud Run Functions는 실패한 함수 호출을 성공할 때까지 또는 재시도 기간이 만료할 때까지 다시 시도합니다.

이 재시도 기간은 24시간 후에 만료됩니다. Cloud Run Functions는 지수 백오프 전략을 사용하여 새로 생성된 이벤트 기반 함수를 재시도하며, 백오프는 10~600초까지 증가합니다.

기본값인 함수에 대해 재시도가 사용 설정되지 않은 경우 함수가 항상 성공적으로 실행된 것으로 보고하고 200 OK 응답 코드가 로그에 나타날 수 있습니다. 이것은 함수에 오류가 발생한 경우에도 수행됩니다. 함수에 오류가 발생할 때 이를 명확하게 표시하려면 오류 보고를 적절히 수행해야 합니다.

이벤트 기반 함수를 완료하는 데 실패하는 이유

드문 경우지만 내부 오류로 인해 함수가 조기 종료될 수 있으며 기본값에 따라 함수가 자동으로 재시도되거나 재시도되지 않을 수 있습니다.

보통은 함수 코드 자체에 발생한 오류로 인해 이벤트 기반 함수를 완료하는 데 실패할 수 있습니다. 이러한 문제가 발생할 수 있는 이유는 다음과 같습니다.

  • 함수에 버그가 있으며 런타임에서 예외가 발생합니다.
  • 함수가 서비스 엔드포인트에 도달할 수 없거나 도달하려고 시도하는 중에 제한 시간이 초과되었습니다.
  • 예를 들어 매개변수가 유효성 검사에 실패하는 경우 등 함수에서 의도적인 예외가 발생합니다.
  • Node.js 함수가 거부된 프라미스를 반환하거나 null 외의 값을 콜백에 전달합니다.

이러한 경우 기본적으로 함수 실행이 중지되고 이벤트가 삭제됩니다. 오류 발생 시 함수를 재시도하려면 '실패 시 재시도' 속성을 설정하여 기본 재시도 정책을 변경하면 됩니다. 그러면 함수가 성공적으로 완료되거나 재시도 제한 시간이 만료될 때까지 이벤트가 반복적으로 재시도됩니다.

재시도 사용 설정 또는 중지

gcloud 명령줄 도구 또는 Google Cloud 콘솔을 사용하여 재시도를 사용 설정 또는 중지할 수 있습니다. 기본적으로 재시도는 중지되어 있습니다.

gcloud 명령줄 도구에서 재시도 구성

gcloud 명령줄 도구를 사용하여 재시도를 사용 설정하려면 다음과 같이 함수 배포 시 --retry 플래그를 포함합니다.

gcloud functions deploy FUNCTION_NAME --retry FLAGS...

재시도를 중지하려면 --retry 플래그 없이 함수를 다시 배포합니다.

gcloud functions deploy FUNCTION_NAME FLAGS...

콘솔에서 재시도 구성

새 함수를 만드는 경우 다음 안내를 따르세요.

  1. 함수 만들기 화면의 트리거 아래에서 함수의 트리거로 사용할 이벤트 유형을 선택합니다.
  2. 실패 시 재시도 체크박스를 선택하여 재시도를 사용 설정합니다.

기존 함수를 업데이트하는 경우 다음 안내를 따르세요.

  1. Cloud Run Functions 개요 페이지에서 업데이트 중인 함수의 이름을 클릭하여 함수 세부정보 화면을 연 다음 메뉴 바에서 수정을 선택하여 트리거 창을 표시합니다.
  2. 실패 시 재시도 체크박스를 선택하거나 선택 해제하여 재시도를 사용 설정 또는 중지합니다.

권장사항

이 섹션에서는 재시도 사용의 권장사항을 설명합니다.

재시도를 사용하여 일시적인 오류 처리

함수는 성공적으로 실행될 때까지 지속적으로 재시도되므로 재시도를 사용 설정하기 전에 테스트를 통해 버그와 같은 영구적인 오류를 코드에서 제거해야 합니다. 재시도는 불안정한 서비스 엔드포인트 또는 시간 초과와 같이 재시도하면 해결될 가능성이 매우 높은 간헐적이거나 일시적인 실패를 처리하는 데 가장 적합합니다.

무한 재시도 루프를 방지하기 위해 종료 조건 설정

재시도를 사용할 때 함수가 지속적인 루프에 빠지지 않도록 조치를 취하는 것이 좋습니다. 함수 처리가 시작되기 전에 명확하게 정의된 종료 조건을 포함하면 됩니다. 함수가 성공적으로 시작되고 종료 조건을 평가할 수 있는 경우에만 이 방법을 사용할 수 있습니다.

간단하면서도 효과적인 방법은 특정 시간보다 오래된 타임스탬프가 있는 이벤트를 삭제하는 것입니다. 이렇게 하면 지속적으로 실패하거나 예상보다 오래 실패할 경우에 과도하게 많이 실행되는 것을 방지할 수 있습니다.

예를 들어 이 코드 스니펫은 10초보다 오래된 모든 이벤트를 삭제합니다.

Node.js

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

/**
 * Cloud Event Function that only executes within
 * a certain time period after the triggering event
 *
 * @param {object} event The Cloud Functions event.
 * @param {function} callback The callback function.
 */
functions.cloudEvent('avoidInfiniteRetries', (event, callback) => {
  const eventAge = Date.now() - Date.parse(event.time);
  const eventMaxAge = 10000;

  // Ignore events that are too old
  if (eventAge > eventMaxAge) {
    console.log(`Dropping event ${event} with age ${eventAge} ms.`);
    callback();
    return;
  }

  // Do what the function is supposed to do
  console.log(`Processing event ${event} with age ${eventAge} ms.`);

  // Retry failed function executions
  const failed = false;
  if (failed) {
    callback('some error');
  } else {
    callback();
  }
});

Python

from datetime import datetime, timezone

# The 'python-dateutil' package must be included in requirements.txt.
from dateutil import parser

import functions_framework


@functions_framework.cloud_event
def avoid_infinite_retries(cloud_event):
    """Cloud Event Function that only executes within a certain
    time period after the triggering event.

    Args:
        cloud_event: The cloud event associated with the current trigger
    Returns:
        None; output is written to Stackdriver Logging
    """
    timestamp = cloud_event["time"]

    event_time = parser.parse(timestamp)
    event_age = (datetime.now(timezone.utc) - event_time).total_seconds()
    event_age_ms = event_age * 1000

    # Ignore events that are too old
    max_age_ms = 10000
    if event_age_ms > max_age_ms:
        print("Dropped {} (age {}ms)".format(cloud_event["id"], event_age_ms))
        return "Timeout"

    # Do what the function is supposed to do
    print("Processed {} (age {}ms)".format(cloud_event["id"], event_age_ms))
    return  # To retry the execution, raise an exception here

Go


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

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
	"github.com/cloudevents/sdk-go/v2/event"
)

func init() {
	functions.CloudEvent("FiniteRetryPubSub", FiniteRetryPubSub)
}

// MessagePublishedData contains the full Pub/Sub message
// See the documentation for more details:
// https://cloud.google.com/eventarc/docs/cloudevents#pubsub
type MessagePublishedData struct {
	Message PubSubMessage
}

// PubSubMessage is the payload of a Pub/Sub event.
// See the documentation for more details:
// https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage
type PubSubMessage struct {
	Data []byte `json:"data"`
}

// FiniteRetryPubSub demonstrates how to avoid inifinite retries.
func FiniteRetryPubSub(ctx context.Context, e event.Event) error {
	var msg MessagePublishedData
	if err := e.DataAs(&msg); err != nil {
		return fmt.Errorf("event.DataAs: %w", err)
	}

	// Ignore events that are too old.
	expiration := e.Time().Add(10 * time.Second)
	if time.Now().After(expiration) {
		log.Printf("event timeout: halting retries for expired event '%q'", e.ID())
		return nil
	}

	// Add your message processing logic.
	return processTheMessage(msg)
}

자바


import com.google.cloud.functions.CloudEventsFunction;
import io.cloudevents.CloudEvent;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.logging.Logger;

public class RetryTimeout implements CloudEventsFunction {
  private static final Logger logger = Logger.getLogger(RetryTimeout.class.getName());
  private static final long MAX_EVENT_AGE = 10_000;

  /**
   * Cloud Event Function that only executes within
   * a certain time period after the triggering event
   */
  @Override
  public void accept(CloudEvent event) throws Exception {
    ZonedDateTime utcNow = ZonedDateTime.now(ZoneOffset.UTC);
    ZonedDateTime timestamp = event.getTime().atZoneSameInstant(ZoneOffset.UTC);

    long eventAge = Duration.between(timestamp, utcNow).toMillis();

    // Ignore events that are too old
    if (eventAge > MAX_EVENT_AGE) {
      logger.info(String.format("Dropping event with timestamp %s.", timestamp));
      return;
    }

    // Process events that are recent enough
    // To retry this invocation, throw an exception here
    logger.info(String.format("Processing event with timestamp %s.", timestamp));
  }
}

C#

using CloudNative.CloudEvents;
using Google.Cloud.Functions.Framework;
using Google.Events.Protobuf.Cloud.PubSub.V1;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace TimeBoundedRetries;

public class Function : ICloudEventFunction<MessagePublishedData>
{
    private static readonly TimeSpan MaxEventAge = TimeSpan.FromSeconds(10);
    private readonly ILogger _logger;

    // Note: for additional testability, use an injectable clock abstraction.
    public Function(ILogger<Function> logger) =>
        _logger = logger;

    public Task HandleAsync(CloudEvent cloudEvent, MessagePublishedData data, CancellationToken cancellationToken)
    {
        string textData = data.Message.TextData;

        DateTimeOffset utcNow = DateTimeOffset.UtcNow;

        // Every PubSub CloudEvent will contain a timestamp.
        DateTimeOffset timestamp = cloudEvent.Time.Value;
        DateTimeOffset expiry = timestamp + MaxEventAge;

        // Ignore events that are too old.
        if (utcNow > expiry)
        {
            _logger.LogInformation("Dropping PubSub message '{text}'", textData);
            return Task.CompletedTask;
        }

        // Process events that are recent enough.
        // If this processing throws an exception, the message will be retried until either
        // processing succeeds or the event becomes too old and is dropped by the code above.
        _logger.LogInformation("Processing PubSub message '{text}'", textData);
        return Task.CompletedTask;
    }
}

Ruby

require "functions_framework"

FunctionsFramework.cloud_event "avoid_infinite_retries" do |event|
  # Use the event timestamp to determine the event age.
  event_age_secs = Time.now - event.time.to_time
  event_age_ms = (event_age_secs * 1000).to_i

  max_age_ms = 10_000
  if event_age_ms > max_age_ms
    # Ignore events that are too old.
    logger.info "Dropped #{event.id} (age #{event_age_ms}ms)"

  else
    # Do what the function is supposed to do.
    logger.info "Handling #{event.id} (age #{event_age_ms}ms)..."
    failed = true

    # Raise an exception to signal failure and trigger a retry.
    raise "I failed!" if failed
  end
end

PHP


/**
 * This function shows an example method for avoiding infinite retries in
 * Google Cloud Functions. By default, functions configured to automatically
 * retry execution on failure will be retried indefinitely - causing an
 * infinite loop. To avoid this, we stop retrying executions (by not throwing
 * exceptions) for any events that are older than a predefined threshold.
 */

use Google\CloudFunctions\CloudEvent;

function avoidInfiniteRetries(CloudEvent $event): void
{
    $log = fopen(getenv('LOGGER_OUTPUT') ?: 'php://stderr', 'wb');

    $eventId = $event->getId();

    // The maximum age of events to process.
    $maxAge = 10; // 10 seconds

    // The age of the event being processed.
    $eventAge = time() - strtotime($event->getTime());

    // Ignore events that are too old
    if ($eventAge > $maxAge) {
        fwrite($log, 'Dropping event ' . $eventId . ' with age ' . $eventAge . ' seconds' . PHP_EOL);
        return;
    }

    // Do what the function is supposed to do
    fwrite($log, 'Processing event: ' . $eventId . ' with age ' . $eventAge . ' seconds' . PHP_EOL);

    // infinite_retries failed function executions
    $failed = true;
    if ($failed) {
        throw new Exception('Event ' . $eventId . ' failed; retrying...');
    }
}

재시도할 수 있는 함수와 심각한 오류 구분

함수 재시도가 사용 설정되면 처리되지 않은 오류가 재시도를 트리거합니다. 코드에서 재시도를 초래해서는 안 되는 오류가 포착되는지 확인하세요.

Node.js

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

/**
 * Register a Cloud Event Function that demonstrates
 * how to toggle retries using a promise
 *
 * @param {object} event The Cloud Event for the function trigger.
 */
functions.cloudEvent('retryPromise', cloudEvent => {
  // The Pub/Sub event payload is passed as the CloudEvent's data payload.
  // See the documentation for more details:
  // https://cloud.google.com/eventarc/docs/cloudevents#pubsub
  const base64PubsubMessage = cloudEvent.data.message.data;
  const jsonString = Buffer.from(base64PubsubMessage, 'base64').toString();

  const tryAgain = JSON.parse(jsonString).retry;

  if (tryAgain) {
    throw new Error('Retrying...');
  } else {
    console.error('Not retrying...');
    return Promise.resolve();
  }
});

/**
 * Cloud Event Function that demonstrates
 * how to toggle retries using a callback
 *
 * @param {object} event The Cloud Event for the function trigger.
 * @param {function} callback The callback function.
 */
functions.cloudEvent('retryCallback', (cloudEvent, callback) => {
  // The Pub/Sub event payload is passed as the CloudEvent's data payload.
  // See the documentation for more details:
  // https://cloud.google.com/eventarc/docs/cloudevents#pubsub
  const base64PubsubMessage = cloudEvent.data.message.data;
  const jsonString = Buffer.from(base64PubsubMessage, 'base64').toString();

  const tryAgain = JSON.parse(jsonString).retry;
  const err = new Error('Error!');

  if (tryAgain) {
    console.error('Retrying:', err);
    callback(err);
  } else {
    console.error('Not retrying:', err);
    callback();
  }
});

Python

import base64
import json

import functions_framework
from google.cloud import error_reporting


error_client = error_reporting.Client()


@functions_framework.cloud_event
def retry_or_not(cloud_event):
    """Cloud Event Function that demonstrates how to toggle retries.

    Args:
        cloud_event: The cloud event with a Pub/Sub data payload
    Returns:
        None; output is written to Stackdriver Logging
    """

    # The Pub/Sub event payload is passed as the CloudEvent's data payload.
    # See the documentation for more details:
    # https://cloud.google.com/eventarc/docs/cloudevents#pubsub
    encoded_pubsub_message = cloud_event.data["message"]["data"]

    # Retry based on a user-defined parameter
    try_again = json.loads(base64.b64decode(encoded_pubsub_message).decode())["retry"]

    try:
        raise RuntimeError("I failed you")
    except RuntimeError:
        error_client.report_exception()
        if try_again:
            raise  # Raise the exception and try again
        else:
            pass  # Swallow the exception and don't retry

Go

package tips

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
	"github.com/cloudevents/sdk-go/v2/event"
)

func init() {
	functions.CloudEvent("RetryPubSub", RetryPubSub)
}

// MessagePublishedData contains the full Pub/Sub message
// See the documentation for more details:
// https://cloud.google.com/eventarc/docs/cloudevents#pubsub
type MessagePublishedData struct {
	Message PubSubMessage
}

// PubSubMessage is the payload of a Pub/Sub event.
// See the documentation for more details:
// https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage
type PubSubMessage struct {
	Data []byte `json:"data"`
}

// RetryPubSub demonstrates how to toggle using retries.
func RetryPubSub(ctx context.Context, e event.Event) error {
	var msg MessagePublishedData
	if err := e.DataAs(&msg); err != nil {
		return fmt.Errorf("event.DataAs: %w", err)
	}

	name := string(msg.Message.Data)
	if name == "" {
		name = "World"
	}

	// A misconfigured client will stay broken until the function is redeployed.
	client, err := MisconfiguredDataClient()
	if err != nil {
		log.Printf("MisconfiguredDataClient (retry denied):  %v", err)
		// A nil return indicates that the function does not need a retry.
		return nil
	}

	// Runtime error might be resolved with a new attempt.
	if err = FailedWriteOperation(client, name); err != nil {
		log.Printf("FailedWriteOperation (retry expected): %v", err)
		// A non-nil return indicates that a retry is needed.
		return err
	}

	return nil
}

자바


import com.google.cloud.functions.CloudEventsFunction;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import functions.eventpojos.PubSubBody;
import io.cloudevents.CloudEvent;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.logging.Logger;

public class RetryPubSub implements CloudEventsFunction {
  private static final Logger logger = Logger.getLogger(RetryPubSub.class.getName());

  // Use Gson (https://github.com/google/gson) to parse JSON content.
  private static final Gson gson = new Gson();

  @Override
  public void accept(CloudEvent event) throws Exception {
    if (event.getData() == null) {
      logger.warning("No data found in event!");
      return;
    }

    // Extract Cloud Event data and convert to PubSubBody
    String cloudEventData = new String(event.getData().toBytes(), StandardCharsets.UTF_8);
    PubSubBody body = gson.fromJson(cloudEventData, PubSubBody.class);

    String encodedData = body.getMessage().getData();
    String decodedData =
        new String(Base64.getDecoder().decode(encodedData), StandardCharsets.UTF_8);

    // Retrieve and decode PubSubMessage data into a JsonElement.
    // Function is expecting a user-supplied JSON message which determines whether
    // to retry or not.
    JsonElement jsonPubSubMessageElement = gson.fromJson(decodedData, JsonElement.class);

    boolean retry = false;
    // Get the value of the "retry" JSON parameter, if one exists
    if (jsonPubSubMessageElement != null && jsonPubSubMessageElement.isJsonObject()) {
      JsonObject jsonPubSubMessageObject = jsonPubSubMessageElement.getAsJsonObject();

      if (jsonPubSubMessageObject.has("retry")
          && jsonPubSubMessageObject.get("retry").getAsBoolean()) {
        retry = true;
      }
    }

    // Retry if appropriate
    if (retry) {
      // Throwing an exception causes the execution to be retried
      throw new RuntimeException("Retrying...");
    } else {
      logger.info("Not retrying...");
    }
  }
}

C#

using CloudNative.CloudEvents;
using Google.Cloud.Functions.Framework;
using Google.Events.Protobuf.Cloud.PubSub.V1;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Retry;

public class Function : ICloudEventFunction<MessagePublishedData>
{
    private readonly ILogger _logger;

    public Function(ILogger<Function> logger) =>
        _logger = logger;

    public Task HandleAsync(CloudEvent cloudEvent, MessagePublishedData data, CancellationToken cancellationToken)
    {
        bool retry = false;
        string text = data.Message?.TextData;

        // Get the value of the "retry" JSON parameter, if one exists.
        if (!string.IsNullOrEmpty(text))
        {
            JsonElement element = JsonSerializer.Deserialize<JsonElement>(data.Message.TextData);

            retry = element.TryGetProperty("retry", out var property) &&
                property.ValueKind == JsonValueKind.True;
        }

        // Throwing an exception causes the execution to be retried.
        if (retry)
        {
            throw new InvalidOperationException("Retrying...");
        }
        else
        {
            _logger.LogInformation("Not retrying...");
        }
        return Task.CompletedTask;
    }
}

Ruby

require "functions_framework"

FunctionsFramework.cloud_event "retry_or_not" do |event|
  try_again = event.data["retry"]

  begin
    # Simulate a failure
    raise "I failed!"
  rescue RuntimeError => e
    logger.warn "Caught an error: #{e}"
    if try_again
      # Raise an exception to return a 500 and trigger a retry.
      logger.info "Trying again..."
      raise ex
    else
      # Return normally to end processing of this event.
      logger.info "Giving up."
    end
  end
end

PHP


use Google\CloudFunctions\CloudEvent;

function tipsRetry(CloudEvent $event): void
{
    $cloudEventData = $event->getData();
    $pubSubData = $cloudEventData['message']['data'];

    $json = json_decode(base64_decode($pubSubData), true);

    // Determine whether to retry the invocation based on a parameter
    $tryAgain = $json['some_parameter'];

    if ($tryAgain) {
        /**
         * Functions with automatic retries enabled should throw exceptions to
         * indicate intermittent failures that a retry might fix. In this
         * case, a thrown exception will cause the original function
         * invocation to be re-sent.
         */
        throw new Exception('Intermittent failure occurred; retrying...');
    }

    /**
     * If a function with retries enabled encounters a non-retriable
     * failure, it should return *without* throwing an exception.
     */
    $log = fopen(getenv('LOGGER_OUTPUT') ?: 'php://stderr', 'wb');
    fwrite($log, 'Not retrying' . PHP_EOL);
}

재시도 가능한 이벤트 기반 함수 멱등성 만들기

재시도할 수 있는 이벤트 기반 함수는 멱등성이 있어야 합니다. 이러한 함수 멱등성을 만들기 위한 일반적인 가이드는 다음과 같습니다.

  • Stripe와 같은 다양한 외부 API를 사용하면 매개변수로 멱등 키를 제공할 수 있습니다. 이러한 API를 사용한다면 이벤트 ID를 멱등 키로 사용해야 합니다.
  • 멱등성이 있으면 재시도해도 안전하므로 최소 1회 전송 시 잘 작동합니다. 따라서 안정적인 코드를 작성하기 위한 일반적인 권장사항은 재시도와 멱등성을 결합하는 것입니다.
  • 코드에 내부적으로 멱등성이 있어야 합니다. 예를 들면 다음과 같습니다.
    • 결과에 변화 없이 변형이 2번 이상 발생할 수 있는지 확인합니다.
    • 상태가 변형되기 전에 트랜잭션에서 데이터베이스 상태를 쿼리합니다.
    • 모든 부가적인 결과에 자체적으로 멱등성이 있는지 확인합니다.
  • 코드에 관계없이 함수 외부에서 트랜잭션 검사를 시행합니다. 예를 들어 특정 이벤트 ID가 이미 처리되었음을 어딘가에 기록하는 상태를 유지합니다.
  • 중복 함수 호출을 대역 외로 처리합니다. 예를 들어 중복 함수가 호출된 후 삭제하는 별도의 삭제 프로세스를 둡니다.

재시도 정책 구성

Cloud Run 함수의 요구사항에 따라 재시도 정책을 직접 구성할 수 있습니다. 이렇게 하면 다음을 원하는 대로 조합하여 설정할 수 있습니다.

  • 재시도 기간을 7일에서 10분으로 단축합니다.
  • 지수 백오프 재시도 전략의 최소 및 최대 백오프 시간을 변경합니다.
  • 즉시 재시도하도록 재시도 전략을 변경합니다.
  • 데드 레터 주제를 구성합니다.
  • 전송 시도의 최대 및 최소 횟수를 설정합니다.

재시도 정책을 구성하려면 다음 안내를 따르세요.

  1. HTTP 함수를 작성합니다.
  2. Pub/Sub API를 사용하여 Pub/Sub 구독을 만들고 함수의 URL을 대상으로 지정합니다.

Pub/Sub 직접 구성에 대한 자세한 내용은 오류 처리에 관한 Pub/Sub 문서를 참조하세요.

다음 단계