이벤트 기반 함수 재시도

이 문서에서는 이벤트 기반 함수(백그라운드 함수CloudEvent 함수)를 재시도하는 방법을 설명합니다. HTTP 함수에서는 자동 재시도를 사용할 수 없습니다.

재시도의 시맨틱스

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

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

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

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

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

위와 같은 경우 기본적으로 함수 실행이 중지되고 이벤트가 삭제됩니다. 오류 발생 시 함수를 재시도하려면 '실패 시 재시도' 속성을 설정하여 기본 재시도 정책을 변경하면 됩니다. 그러면 함수가 완료될 때까지 최대 며칠 동안 이벤트가 반복적으로 재시도됩니다.

재시도 사용 설정 및 중지

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

gcloud 명령줄 도구 사용

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

gcloud functions deploy FUNCTION_NAME --retry FLAGS...

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

gcloud functions deploy FUNCTION_NAME FLAGS...

Cloud Console 사용

다음과 같이 Cloud Console에서 재시도를 사용 설정하거나 중지할 수 있습니다.

  1. Cloud Platform Console의 Cloud Functions 개요 페이지로 이동합니다.

  2. 함수 만들기를 클릭합니다. 또는 기존 함수를 클릭하여 세부정보 페이지로 이동하고 수정을 클릭합니다.

  3. 함수의 필수 필드를 작성합니다.

  4. 트리거 필드가 Cloud Pub/Sub 또는 Cloud Storage와 같은 이벤트 기반 함수 트리거 유형으로 설정되어 있는지 확인합니다.

  5. 더보기를 클릭하여 고급 설정을 펼칩니다.

  6. 실패 시 재시도 라벨이 지정된 체크박스를 선택하거나 선택 해제합니다.

권장사항

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

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

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

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

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

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

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

Node.js

/**
 * Background Cloud 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.
 */
exports.avoidInfiniteRetries = (event, callback) => {
  const eventAge = Date.now() - Date.parse(event.timestamp);
  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

def avoid_infinite_retries(data, context):
    """Background Cloud Function that only executes within a certain
    time period after the triggering event.

    Args:
        data (dict): The event payload.
        context (google.cloud.functions.Context): The event metadata.
    Returns:
        None; output is written to Stackdriver Logging
    """

    timestamp = context.timestamp

    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(context.event_id, event_age_ms))
        return 'Timeout'

    # Do what the function is supposed to do
    print('Processed {} (age {}ms)'.format(context.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"

	"cloud.google.com/go/functions/metadata"
)

// 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, m PubSubMessage) error {
	meta, err := metadata.FromContext(ctx)
	if err != nil {
		// Assume an error on the function invoker and try again.
		return fmt.Errorf("metadata.FromContext: %v", err)
	}

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

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

자바


import com.google.cloud.functions.BackgroundFunction;
import com.google.cloud.functions.Context;
import com.google.events.cloud.pubsub.v1.Message;
import com.google.gson.Gson;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.logging.Logger;

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

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

  /**
   * Background Cloud Function that only executes within
   * a certain time period after the triggering event
   */
  @Override
  public void accept(Message message, Context context) {
    ZonedDateTime utcNow = ZonedDateTime.now(ZoneOffset.UTC);
    ZonedDateTime timestamp = ZonedDateTime.parse(context.timestamp());

    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

/**
 * Background Cloud Function that demonstrates
 * how to toggle retries using a promise
 *
 * @param {object} event The Cloud Functions event.
 * @param {object} event.data Data included with the event.
 * @param {object} event.data.retry User-supplied parameter that tells the function whether to retry.
 */
exports.retryPromise = event => {
  const tryAgain = !!event.data.retry;

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

/**
 * Background Cloud Function that demonstrates
 * how to toggle retries using a callback
 *
 * @param {object} event The Cloud Functions event.
 * @param {object} event.data Data included with the event.
 * @param {object} event.data.retry User-supplied parameter that tells the function whether to retry.
 * @param {function} callback The callback function.
 */
exports.retryCallback = (event, callback) => {
  const tryAgain = !!event.data.retry;
  const err = new Error('Error!');

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

Python

from google.cloud import error_reporting
error_client = error_reporting.Client()

def retry_or_not(data, context):
    """Background Cloud Function that demonstrates how to toggle retries.

    Args:
        data (dict): The event payload.
        context (google.cloud.functions.Context): The event metadata.
    Returns:
        None; output is written to Stackdriver Logging
    """

    # Retry based on a user-defined parameter
    try_again = data.data.get('retry') is not None

    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 contains tips for writing Cloud Functions in Go.
package tips

import (
	"context"
	"errors"
	"log"
)

// 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, m PubSubMessage) error {
	name := string(m.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.BackgroundFunction;
import com.google.cloud.functions.Context;
import com.google.events.cloud.pubsub.v1.Message;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.logging.Logger;

public class RetryPubSub implements BackgroundFunction<Message> {
  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(Message message, Context context) {
    String bodyJson = new String(
        Base64.getDecoder().decode(message.getData()), StandardCharsets.UTF_8);
    JsonElement bodyElement = gson.fromJson(bodyJson, JsonElement.class);

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

      if (body.has("retry") && body.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가 이미 처리되었음을 어딘가에 기록하는 상태를 유지합니다.
  • 중복 함수 호출을 대역 외로 처리합니다. 예를 들어 중복 함수가 호출된 후 삭제하는 별도의 삭제 프로세스를 둡니다.

다음 단계