常规开发技巧

本指南提供了设计、实现、测试和部署 Cloud Run 服务的最佳做法。如需了解更多提示,请参阅迁移现有服务

编写高效的服务

本部分介绍设计和实现 Cloud Run 服务方面的一般最佳实践。

后台活动

后台活动是指在 HTTP 响应送达后发生的任何活动。如需确定服务中是否存在并不明显的后台活动,请检查日志以查找在 HTTP 请求条目后记录的任何内容。

将 CPU 配置成始终分配以使用后台活动

如果您希望在 Cloud Run 服务中支持后台活动,请将 Cloud Run 服务 CPU 设置为始终分配,以便您可以在请求之外运行后台活动,并且仍拥有 CPU 访问权限。

如果仅在处理请求期间分配 CPU,请避免进行后台活动

如果您需要将服务设置为仅在处理请求期间分配 CPU,则当 Cloud Run 服务处理完请求后,实例对 CPU 的访问将被停用或受到严重限制。如果使用此类型的 CPU 分配,您不应启动在请求处理程序范围之外运行的后台线程或例程。

建议检查您的代码,以确保所有异步操作都会在传送响应之前完成。

使用此类型的 CPU 分配运行后台线程可能会导致出现意外行为,因为对同一容器实例发出的任何后续请求都会恢复任何暂停的后台活动。

删除临时文件

在 Cloud Run 环境中,磁盘存储空间属于内存文件系统。写入磁盘的文件会占用供服务使用的内存,并且可在多次调用之间继续留存。 如果不删除这些文件,最终可能会导致内存不足错误,并且随后容器启动速度缓慢。

报告错误

您需要处理所有异常,防止服务因错误而崩溃。崩溃会导致容器启动缓慢,而流量则需要排队等候某个替换实例。

如需了解如何适当地报告错误,请参阅报告错误指南

优化性能

本部分介绍性能优化方面的最佳做法。

快速启动容器

因为实例根据需要进行扩缩,所以其启动时间会影响服务的延迟时间。虽然 Cloud Run 会将实例启动和请求处理分离,但请求可能必须等待新实例启动才能处理,尤其是在从零开始伸缩时。

启动例程包括以下步骤:

  • 下载容器映像(使用 Cloud Run 的容器映像流式传输技术)
  • 通过运行 entrypoint 命令启动容器。
  • 等待容器开始侦听已配置的端口

优化容器启动速度可以最大限度地缩短请求处理延迟时间。

使用启动 CPU 加速功能缩短启动延迟时间

您可以启用启动 CPU 加速,在实例启动期间临时增加 CPU 分配,以缩短启动延迟时间。

使用实例数下限缩短容器启动时间

您可以配置实例数下限并发数,以最大限度地缩短容器启动时间。例如,如果使用实例数下限 1,则表示您的服务已准备好接收为服务配置的并发请求数,而无需启动新的实例。

请注意,等待实例启动的请求将在队列中保持待处理状态,如下所示:

  • 如果新实例(例如在横向扩容期间)启动,则请求将至少留出此服务的容器实例的平均启动时间。这包括请求启动横向扩容的时间,例如从零开始横向扩容时。
  • 如果启动时间少于 10 秒,则请求将持续长达 10 秒。
  • 如果启动过程中没有实例,并且请求未启动横向扩容,则请求将持续长达 10 秒。

谨慎使用依赖项

如果您使用具有依赖项库的动态语言,例如导入 Node.js 模块,则这些模块的加载时间会增加启动延迟时间。

您可以通过以下方式缩短启动延迟时间:

  • 最大限度地减少依赖项的数量和大小,以构建精简服务。
  • 惰性加载不常用的代码(如果您所用的语言支持此方式)。
  • 使用代码加载优化技术,例如 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)
}

Java


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.
}

Java


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 functions 不同。

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 的容器映像流式传输技术,容器映像的大小不会影响容器启动时间或请求处理时间。容器映像大小也不会计入容器的可用内存

如需构建最小的容器,请考虑使用精简基础映像,例如:

Ubuntu 是较大的基础映像,但很常用,可提供更全面的开箱即用服务器环境。

如果您的服务构建流程中涉及很多工具,建议使用多阶段构建来确保容器轻装上阵。

如需进一步了解如何创建精简容器映像,请参阅以下资源: