AWS Lambda 编程指南(二)

原文:zh.annas-archive.org/md5/a00e6d2e46d6e58fa60dc99d69f92ec1

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:测试

一个良好的测试套件,就像房子的坚实基础一样,为我们提供了一个已知的系统行为基准,我们可以在其上放心地构建。这个基准给了我们信心,可以添加功能,修复错误,并进行重构,而不用担心会破坏系统的其他部分。当集成到开发工作流程中时,同样的测试套件还通过更容易维护现有测试和添加新测试来鼓励良好的实践。

当然,基础并非免费。维护测试的努力必须与测试提供的价值相平衡。如果我们把所有精力都花在测试上,就没有剩余精力来处理系统的其他部分了。

对于无服务器应用程序来说,划分有价值测试和脆弱技术债务之间的界限比以往任何时候都更加困难。幸运的是,我们可以使用一个熟悉的模型来帮助考虑这些权衡。

测试金字塔

经典的“测试金字塔”(来自 2009 年迈克·科恩的书《成功的敏捷》中,图 6-1 显示的图 6-1)对我们帮助决定写哪种测试是一个有用的指南。金字塔的比喻说明了在给定切片中测试的数量、这些测试的价值以及编写、运行和维护它们的成本之间的权衡。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0601.png

图 6-1。测试金字塔

在无服务器世界中进行测试与传统应用程序并没有实质性的区别,特别是在金字塔的基础部分。然而,与由不同组件和服务组成的任何分布式系统一样,更高级别的“端到端”测试更具挑战性。在本章中,我们将从金字塔底部到顶部讨论测试,并且会沿途提供大量示例。

单元测试

金字塔的基础是单元测试 —— 这些测试应该针对我们应用程序的特定组件进行测试,而不依赖于任何外部依赖项(如数据库)。单元测试应该快速执行,并且在开发过程中我们应该能够定期(甚至自动化地)运行它们,配置最小化且无需网络访问。我们应该有足够多的单元测试来确保我们的代码正常工作。单元测试不仅覆盖“正常路径”,还要彻底处理边缘情况和错误处理。即使是一个小应用程序也可能有数十甚至数百个单元测试。

功能测试

金字塔的中间是功能测试。像单元测试一样,这些测试应该快速执行,并且不应依赖外部依赖项。与单元测试不同的是,我们可能需要模拟或存根这些外部依赖项,以满足测试组件的运行时要求。

而不是试图详尽地执行我们代码的每个逻辑分支,我们的功能测试解决组件的主要代码路径,特别关注失败模式。

端到端测试

位于金字塔顶端的是端到端测试。端到端测试向应用程序提交输入(通常通过正常的用户界面或 API),然后对输出或副作用进行断言。与功能测试不同,端到端测试针对完整的应用程序及其所有外部依赖项在类似生产的环境中运行(尽管通常与生产隔离)。

因为端到端测试比功能和单元测试更昂贵(就运行时间和基础设施成本而言),通常您只应测试一些重要的情况。一个很好的经验法则是至少有一个端到端测试覆盖应用程序中最重要的路径(例如,在在线购物应用程序中的购买路径)。

重构以进行测试

我们将使用我们在第五章中构建的无服务器数据流水线作为基础,构建一套单元测试、功能测试和端到端测试。在我们开始之前,让我们做一点重构,使我们的数据流水线 Lambdas 更容易测试。

从前一节中回顾,单元测试会测试我们应用程序的特定组件的具体部分。在我们的情况下,我们指的是构成我们 Lambda 函数的 Java 类中的方法。我们希望编写测试,为某些方法提供输入,并断言这些方法的输出(或副作用)是否符合我们的预期。

首先,让我们回顾一下BulkEventsLambda,牢记测试金字塔的单元和功能切片。这个相对简单的 Lambda 函数与两个外部 AWS 服务(S3 和 SNS)以及序列化和反序列化 JSON 数据进行交互。

重访 BulkEventsLambda

每当文件上传到特定的 S3 存储桶时,就会触发BulkEventsLambda。处理程序方法会使用一个S3Event对象调用。对于该事件中的每个S3EventNotificationRecord,Lambda 会从 S3 存储桶中检索一个 JSON 文件。该 JSON 文件包含零个或多个 JSON 对象。Lambda 将 JSON 文件反序列化为一组WeatherEvent Java 对象。然后,每个 Java 对象都序列化为一个String并发布到一个 SNS 主题。最后,Lambda 函数会向 STDOUT(因此也会向 CloudWatch Logs)写入一个日志条目,指出发送到 SNS 的天气事件的数量。

您在第五章看到的代码是为了清晰而编写和组织的,但不一定是为了便于测试。让我们来看一下BulkEvents Lambda类中的四个方法。

首先,handler方法,接收一个S3Event对象:

public void handler(S3Event event) {
  event.getRecords().forEach(this::processS3EventRecord);
}

这是类外唯一可访问的方法——没有重构的话,这意味着对这个类的任何测试必须使用一个S3Event对象调用此方法。此外,该方法具有void返回类型,因此很难断言成功或失败。

接下来,我们看到这个方法为每个传入的事件记录调用了processS3EventRecord

private void processS3EventRecord(
    S3EventNotification.S3EventNotificationRecord record) {

  final List<WeatherEvent> weatherEvents = readWeatherEventsFromS3(
    record.getS3().getBucket().getName(),
    record.getS3().getObject().getKey());

  weatherEvents.stream()
    .map(this::weatherEventToSnsMessage)
    .forEach(message -> sns.publish(snsTopic, message));

  System.out.println("Published " + weatherEvents.size()
    + " weather events to SNS");
}

此方法是私有的,因此无法在不将可见性更改为“包私有”(通过删除private关键字)的情况下进行测试。与handler函数一样,它具有 void 返回类型,因此我们进行的任何断言都将是关于方法的副作用而不是方法的返回值。此方法有两个明确的副作用:

  • System.out.println调用。

  • 调用sns.publish方法,向由snsTopic字段命名的主题发送 SNS 消息。由于这是 AWS SDK 调用,必须考虑许多其他环境和系统属性:

    • 必须设置和正确配置适当的 AWS 配置。

    • 配置的 AWS API 端点必须通过网络访问。

    • 必须存在命名的 SNS 主题。

    • 我们正在使用的 AWS 凭据必须具有写入该 SNS 主题的访问权限。

要按照编写的方式调用processS3EventRecord,我们必须提前处理所有这些项目。对于单元测试来说,这是不可接受的开销。

此外,如果我们还想断言processS3EventRecord是否已正确运行,则需要一种方法来确保 SNS 消息已发送到正确的主题。做法之一是在我们的测试过程中订阅 SNS 主题,并等待预期的消息出现。与以前一样,这对于单元测试来说是不可接受的开销。

在 Java 中测试这些副作用的常见方法是使用诸如Mockito之类的工具来模拟或存根负责这些副作用的类。这使我们能够测试我们自己的应用程序类,这些类产生副作用,通过替换诸如 AWS SDK 之类的模拟对象,看起来和行为类似,但允许我们避免实际设置真实的 SNS 主题。使用诸如参数捕获之类的技术,模拟对象还可以保存用于调用它们的参数,这使我们能够断言它们的调用方式——在本例中,我们可以断言sns.publish方法是否使用正确的主题名称和消息进行了调用。

要使用这样的模拟 AWS SDK 对象,我们需要一种将其注入到受测试类中的方式——通常是通过接受适当参数的构造函数完成的。BulkEventsLambda没有这样的构造函数,因此我们需要添加一个构造函数以便能够使用模拟对象。

readWeatherEventsFromS3方法是另一个具有副作用的方法的示例,本例中是远程 API 调用。在这种情况下,它使用 AWS S3 SDK 客户端的getObject调用从 S3 下载数据。

然后将数据反序列化为WeatherEvent对象集合并返回给调用方:

private List<WeatherEvent> readWeatherEventsFromS3(String bucket, String key) {
  try {
    final S3ObjectInputStream s3is =
      s3.getObject(bucket, key).getObjectContent();
    final WeatherEvent[] weatherEvents =
      objectMapper.readValue(s3is, WeatherEvent[].class);
    s3is.close();
    return Arrays.asList(weatherEvents);
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

这个方法做了两件完全不同的事情——它从 S3 下载数据,并对该数据进行反序列化。这种行为组合使我们难以测试每个功能片段是否独立。如果我们想测试 JSON 反序列化过程中如何处理错误,我们仍然需要确保方法的输入具有正确的 S3 存储桶和密钥,尽管这些信息与 JSON 处理无关。

最后,weatherEventToSnsMessage是一个应该很容易测试的方法示例(如果在BulkEventsLambda类外部可见的话)。它接受一个Weather Event对象并返回一个String,并且不会造成任何副作用。

重构 BulkEventsLambda

在审查了BulkEventsLambda中的四种方法之后,以下是一些可以更好地实现单元测试和功能测试的方法:

  • 通过构造函数参数启用模拟 AWS SDK 类的注入。

  • 隔离副作用,因此大多数方法可以在不使用模拟的情况下进行测试。

  • 将方法拆分开来,使大多数方法只做一件事情。

添加构造函数

在牢记这些事情的情况下,让我们从添加一些构造函数开始:

public BulkEventsLambda() {
  this(AmazonSNSClientBuilder.defaultClient(),
    AmazonS3ClientBuilder.defaultClient());
}

public BulkEventsLambda(AmazonSNS sns, AmazonS3 s3) {
  this.sns = sns;
  this.s3 = s3;
  this.snsTopic = System.getenv(FAN_OUT_TOPIC_ENV);

  if (this.snsTopic == null) {
    throw new RuntimeException(
      String.format("%s must be set", FAN_OUT_TOPIC_ENV));
  }
}

现在我们有了两个构造函数。正如我们在第三章中学到的那样,默认的无参数构造函数将在第一次运行我们的函数时由 Lambda 运行时调用。该默认构造函数创建了一个 AWS SDK SNS 客户端和一个 S3 客户端,并将这两个对象传递给第二个构造函数(这种技术称为构造函数链)。

第二个构造函数以这些客户端对象为参数。在测试中,我们可以使用这个构造函数来实例化具有模拟 AWS SDK 客户端的BulkEventsLambda类。该第二个构造函数还读取FAN_OUT_TOPIC环境变量,如果没有设置,则抛出异常。

隔离副作用

我们从BulkEventsLambda审查中注意到了三个副作用:

  • 从 S3 下载 JSON 文件。

  • 向 SNS 主题发布消息。

  • 写入一条日志到 STDOUT。

前两个对测试环境有一些先决条件,减慢了测试执行速度,并使编写测试变得更加复杂。虽然我们肯定要测试这些副作用(同时使用模拟和实际的 AWS 服务),但将它们隔离到尽可能少的方法中将有助于使我们的单元测试简单而快速。

在那个基础上,让我们看一下两种新方法,以隔离 AWS 的副作用:

private void publishToSns(String message) {
  sns.publish(snsTopic, message);
}

private InputStream getObjectFromS3(
      S3EventNotification.S3EventNotificationRecord record) {

  String bucket = record.getS3().getBucket().getName();
  String key = record.getS3().getObject().getKey();
  return s3.getObject(bucket, key).getObjectContent();
}

第一个方法publishToSns接受一个String参数并向 SNS 主题发布消息。第二个getObjectFromS3接受一个S3EventNotification​Record并从 S3 下载相应的文件。

现在从重构的handler方法中调用这两种方法,这是实现副作用隔离的实际位置:

public void handler(S3Event event) {

  List<WeatherEvent> events = event.getRecords().stream()
    .map(this::getObjectFromS3)
    .map(this::readWeatherEvents)
    .flatMap(List::stream)
    .collect(Collectors.toList());

  // Serialize and publish WeatherEvent messages to SNS
  events.stream()
    .map(this::weatherEventToSnsMessage)
    .forEach(this::publishToSns);

  System.out.println("Published " + events.size()
    + " weather events to SNS");
}

在这个新的handler方法中还有更多的工作,但现在只需注意get​ObjectFromS3publishToSns是从这里调用的(其他地方没有)。

分割方法

除了隔离我们的副作用外,新的handler方法现在也包含了我们大部分的处理逻辑。这看起来可能与我们的目标相反,但这种“粘合”逻辑协调了许多更简单、单一用途的方法,这些方法更容易进行单元测试。在这种情况下,readWeatherEvents方法不再需要访问 S3(或模拟的 S3 客户端)。它的唯一目的是将InputStream反序列化为一组WeatherEvent对象,并处理错误(通过重新抛出RuntimeException来停止 Lambda 函数)。

List<WeatherEvent> readWeatherEvents(InputStream inputStream) {
  try (InputStream is = inputStream) {
    return Arrays.asList(
      objectMapper.readValue(is, WeatherEvent[].class));
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

注意,我们现在使用了 Java 的try-with-resources特性来自动关闭输入流。我们还从weatherEventToSnsMessage方法中移除了private关键字,这样我们的测试类可以根据需要访问它们两者。

测试 BulkEventsLambda

现在我们已经重构了代码,让我们为BulkEventsLambda添加一些单元测试。

单元测试

这些测试完全隔离了副作用 —— 我们无需配置或连接到任何 AWS 服务或其他外部依赖项。这种隔离也意味着这些测试执行速度快,仅需几毫秒。尽管BulkEventsLambda相当简单,我们只有几个测试,但是即使以这种风格编写数百个单元测试,也可以在几秒钟内运行。

这是BulkEventsLambdareadWeatherEvents方法的一个单元测试:

public class BulkEventsLambdaUnitTest {

  @Test
  public void testReadWeatherEvents() {

    // Fixture data
    InputStream inputStream =
      getClass().getResourceAsStream("/bulk_data.json");

    // Construct Lambda function class, and invoke
    BulkEventsLambda lambda =
      new BulkEventsLambda(null, null);
    List<WeatherEvent> weatherEvents =
      lambda.readWeatherEvents(inputStream);

    // Assert
    Assert.assertEquals(3, weatherEvents.size());

    Assert.assertEquals("Brooklyn, NY",
      weatherEvents.get(0).locationName);
    Assert.assertEquals(91.0,
      weatherEvents.get(0).temperature, 0.0);
    Assert.assertEquals(1564428897L,
      weatherEvents.get(0).timestamp, 0);
    Assert.assertEquals(40.7,
      weatherEvents.get(0).latitude, 0.0);
    Assert.assertEquals(-73.99,
      weatherEvents.get(0).longitude, 0.0);

    Assert.assertEquals("Oxford, UK",
      weatherEvents.get(1).locationName);
    Assert.assertEquals(64.0,
      weatherEvents.get(1).temperature, 0.0);
    Assert.assertEquals(1564428897L,
      weatherEvents.get(1).timestamp, 0);
    Assert.assertEquals(51.75,
      weatherEvents.get(1).latitude, 0.0);
    Assert.assertEquals(-1.25,
      weatherEvents.get(1).longitude, 0.0);

    Assert.assertEquals("Charlottesville, VA",
      weatherEvents.get(2).locationName);
    Assert.assertEquals(87.0,
      weatherEvents.get(2).temperature, 0.0);
    Assert.assertEquals(1564428897L,
      weatherEvents.get(2).timestamp, 0);
    Assert.assertEquals(38.02,
      weatherEvents.get(2).latitude, 0.0);
    Assert.assertEquals(-78.47,
      weatherEvents.get(2).longitude, 0.0);
  }

}

为了方便起见,我们从磁盘上的 JSON 文件中读取输入数据。然后我们创建了一个BulkEventsLambda的实例 —— 请注意,我们只是简单地传入null作为 SNS 和 S3 客户端,因为在这个测试中它们根本不需要。调用了readWeatherEvents方法,并断言它产生了正确的对象。

我们甚至可以用更少的代码来测试失败的情况:

public class BulkEventsLambdaUnitTest {

  @Rule
  public ExpectedException thrown = ExpectedException.none();

  @Rule
  public EnvironmentVariables environment = new EnvironmentVariables();

  @Test
  public void testReadWeatherEventsBadData() {

    // Fixture data
    InputStream inputStream =
      getClass().getResourceAsStream("/bad_data.json");

    // Expect exception
    thrown.expect(RuntimeException.class);
    thrown.expectCause(
      CoreMatchers.instanceOf(InvalidFormatException.class));
    thrown.expectMessage(
      "Can not deserialize value of type java.lang.Long from String");

    // Invoke
    BulkEventsLambda lambda = new BulkEventsLambda(null, null);
    lambda.readWeatherEvents(inputStream);
  }

}

这里我们使用了一个Junit 规则,来断言我们的方法是否抛出了预期类型的异常。

就单元测试而言,这些都是简单有效的。对于更复杂的 Lambda 函数,我们可能会有数十个这样的测试,以测试尽可能多的逻辑路径和边缘情况。

功能测试

与单元测试类似,我们希望我们的功能测试在不连接到 AWS 的情况下运行。然而,与单元测试不同的是,我们希望将 Lambda 函数作为单个组件进行测试,这意味着我们必须让我们的代码认为它正在与云端通信!为了完成这种欺骗和欺骗的壮举,我们将使用 Mockito 来构建 AWS SDK 客户端的“模拟”实例,配置为返回预先安排的响应以响应方法调用。例如,如果我们的代码调用 S3 客户端的getObject方法,我们的模拟将返回一个包含固定测试数据的S3Object

这是一个“正常路径”的功能测试:

public class BulkEventsLambdaFunctionalTest {

  @Test
  public void testHandler() throws IOException {

    // Set up mock AWS SDK clients
    AmazonSNS mockSNS = Mockito.mock(AmazonSNS.class);
    AmazonS3 mockS3 = Mockito.mock(AmazonS3.class);

    // Fixture S3 event
    S3Event s3Event = objectMapper
      .readValue(getClass()
      .getResourceAsStream("/s3_event.json"), S3Event.class);
    String bucket =
      s3Event.getRecords().get(0).getS3().getBucket().getName();
    String key =
      s3Event.getRecords().get(0).getS3().getObject().getKey();

    // Fixture S3 return value
    S3Object s3Object = new S3Object();
    s3Object.setObjectContent(
      getClass().getResourceAsStream(String.format("/%s", key)));
    Mockito.when(mockS3.getObject(bucket, key)).thenReturn(s3Object);

    // Fixture environment
    String topic = "test-topic";
    environment.set(BulkEventsLambda.FAN_OUT_TOPIC_ENV, topic);

    // Construct Lambda function class, and invoke handler
    BulkEventsLambda lambda = new BulkEventsLambda(mockSNS, mockS3);
    lambda.handler(s3Event);

    // Capture outbound SNS messages
    ArgumentCaptor<String> topics =
      ArgumentCaptor.forClass(String.class);
    ArgumentCaptor<String> messages =
      ArgumentCaptor.forClass(String.class);
    Mockito.verify(mockSNS,
      Mockito.times(3)).publish(topics.capture(),
        messages.capture());

    // Assert
    Assert.assertArrayEquals(
      new String[]{topic, topic, topic},
      topics.getAllValues().toArray());
    Assert.assertArrayEquals(new String[]{
      "{\"locationName\":\"Brooklyn, NY\",\"temperature\":91.0,"
        + "\"timestamp\":1564428897,\"longitude\":-73.99,"
        + "\"latitude\":40.7}",
      "{\"locationName\":\"Oxford, UK\",\"temperature\":64.0,"
        + "\"timestamp\":1564428898,\"longitude\":-1.25,"
        + "\"latitude\":51.75}",
      "{\"locationName\":\"Charlottesville, VA\",\"temperature\":87.0,"
        + "\"timestamp\":1564428899,\"longitude\":-78.47,"
        + "\"latitude\":38.02}"
    }, messages.getAllValues().toArray());
  }
}

首先要注意的是,这个测试比我们的单元测试长得多。大部分额外的代码用于设置模拟对象并配置环境,使得我们的 Lambda 函数的handler方法认为自己在云中运行。

第二点需要注意的是,我们正在从磁盘上的文件中读取输入数据。s3_event.json是使用此sam命令生成的文件:

$ sam local generate-event s3 put > src/test/resources/s3_event.json

然后,我们将key字段更改为引用另一个本地文件,bulk_data.json,它表示将存储在 S3 上的天气数据:

{
  "Records": [
    {
      ...
      "s3": {
        "bucket": {
          "name": "example-bucket",
        ...
        },
        "object": {
          "key": "bulk_data.json",
        }
      }
    }
  ]
}

当调用s3.getObject方法时,我们的模拟 S3 客户端返回bulk_data.json文件的内容,而我们的 Lambda 函数对此一无所知。

最后,我们想要断言BulkEventsLambda向 SNS 发布消息,但实际上并不向 AWS 发送消息。在这里,我们使用我们的模拟 SNS 客户端,并捕获传递给sns.publish方法的参数。如果该方法以正确的参数调用了预期次数,我们的测试就会通过。

另一个功能测试断言,如果 Lambda 函数接收到不良输入数据,则会引发异常。最后一个测试断言,如果未设置FAN_OUT_TOPIC环境变量,则会引发异常。

这些功能测试编写起来更复杂,运行时间稍长,但它们确保了BulkEventsLambda在 Lambda 运行时调用handler函数并传递S3Event对象时的行为符合我们的预期。

端到端测试

通过我们的一系列单元测试和功能测试所获得的信心,我们可以将最复杂和成本最高的测试方法集中在应用程序的关键路径上。我们还可以利用基础设施即代码的方法部署我们的无服务器应用程序及其基础设施的完整版本到 AWS,专门用于运行端到端测试。当测试成功完成时,我们将进行清理和拆除。

要运行端到端测试,我们只需执行mvn verify命令。这使用了 Maven Failsafe 插件,它会查找以**IT*结尾的测试类,并使用 JUnit 运行它们。在这种情况下,IT 代表集成测试,但这只是 Maven 的命名惯例,我们可以配置 Failsafe 插件使用不同的后缀。

对于我们的端到端测试,我们确切地按照其在生产中的使用方式来运行我们的应用程序。我们将一个 JSON 文件上传到 S3 存储桶,然后断言Single EventLambda生成了正确的 CloudWatch Logs 输出。从测试的角度来看,我们的无服务器应用程序就是一个黑盒子。

这是测试方法的主体:

@Test
public void endToEndTest() throws InterruptedException {
  String bucketName = resolvePhysicalId("PipelineStartBucket");
  String key = UUID.randomUUID().toString();
  File file = new File(getClass().getResource("/bulk_data.json").getFile());

  // 1\. Upload bulk_data file to S3
  s3.putObject(bucketName, key, file);

  // 2\. Check for executions of SingleEventLambda
  Thread.sleep(30000);
  String singleEventLambda = resolvePhysicalId("SingleEventLambda");
  Set<String> logMessages = getLogMessages(singleEventLambda);
  Assert.assertThat(logMessages, CoreMatchers.hasItems(
    "WeatherEvent{locationName='Brooklyn, NY', temperature=91.0, "
      + "timestamp=1564428897, longitude=-73.99, latitude=40.7}",
    "WeatherEvent{locationName='Oxford, UK', temperature=64.0, "
      + "timestamp=1564428898, longitude=-1.25, latitude=51.75}",
    "WeatherEvent{locationName='Charlottesville, VA', temperature=87.0, "
      + "timestamp=1564428899, longitude=-78.47, latitude=38.02}"
  ));

  // 3\. Delete object from S3 bucket (to allow a clean CloudFormation teardown)
  s3.deleteObject(bucketName, key);

  // 4\. Delete Lambda log groups
  logs.deleteLogGroup(
    new DeleteLogGroupRequest(getLogGroup(singleEventLambda)));
  String bulkEventsLambda = resolvePhysicalId("BulkEventsLambda");
  logs.deleteLogGroup(
    new DeleteLogGroupRequest(getLogGroup(bulkEventsLambda)));
}

从这个例子中有几个值得注意的点:

  • 测试解析了 S3 存储桶的实际名称(在 AWS 术语中称为“物理 ID”),该技术用于资源发现非常有用,因为它允许我们部署命名堆栈而不明确指定资源的名称(或者将堆栈名称用作资源名称的一部分)。这意味着我们可以在同一账户和区域中多次部署同一应用,并使用不同的堆栈名称进行 CloudFormation 堆栈部署。

  • 为了简单起见,我们的测试在检查SingleEventLambda是否执行之前简单地休眠 30 秒。另一种方法是主动轮询 CloudWatch 日志,这种方法更可靠,但显然更复杂。

  • 我们在测试方法结束时清理一些资源。我们这样做是为了在测试失败时,这些资源仍然可用于我们对测试失败的调查。如果我们使用了 JUnit 的@After功能,即使测试失败,也会进行清理,从而阻碍调查工作。

现在你已经看到了测试方法本身,让我们看看如何设置和拆除测试基础设施。我们需要确保 S3 存储桶、SNS 主题和 Lambda 函数已经准备好以便我们的测试运行,但我们不希望单独创建这些资源。相反,我们想使用与生产环境相同的 SAM template.yaml文件。

对于这个例子,我们使用 Maven 的“exec”插件来连接到构建生命周期的“pre-integration”阶段,这将在端到端测试之前执行。在这里使用 Maven 并不可怕。你可以很容易地使用简单的 Shell 脚本或 Makefile 来做同样的事情。重要的是我们使用与生产环境相同的template.yaml文件,如果可能的话,使用相同的 AWS CLI 命令来部署我们的应用。

<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>exec-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>001-sam-deploy</id>
      <phase>pre-integration-test</phase>
      <goals>
        <goal>exec</goal>
      </goals>
      <configuration>
        <basedir>${project.parent.basedir}</basedir>
        <executable>sam</executable>
        <arguments>
          <argument>deploy</argument>
          <argument>--s3-bucket</argument>
          <argument>${integration.test.code.bucket}</argument>
          <argument>--stack-name</argument>
          <argument>${integration.test.stack.name}</argument>
          <argument>--capabilities</argument>
          <argument>CAPABILITY_IAM</argument>
        </arguments>
      </configuration>
    </execution>
  </executions>
</plugin>

需要多行 XML 来描述,但在此示例中,我们使用与我们在第五章中使用的相同参数调用 SAM CLI 二进制文件。

${integration.test.code.bucket}${integration.test.stack.name}属性来自顶层pom.xml文件,并且定义如下:

<properties>
  <maven.build.timestamp.format>
    yyyyMMddHHmmss
  </maven.build.timestamp.format>
  <integration.test.code.bucket>
    ${env.CF_BUCKET}
  </integration.test.code.bucket>
  <integration.test.stack.name>
    chapter6-it-${maven.build.timestamp}
  </integration.test.stack.name>
</properties>

我们的 Maven 过程使用${integration.test.code.bucket}的值来填充$CF_BUCKET环境变量的值,我们在前几章中已经使用过它。${maven.build.timestamp.format} pom.xml 文档告诉 Maven 构建一个人类可读的数值时间戳,然后我们将其用作${integration.test.stack.name}的一部分。这为我们提供了一个(几乎)唯一的 CloudFormation 堆栈名称,因此可以在同一 AWS 账户和区域中同时运行多个端到端测试(只要它们不是在同一秒钟开始!)。

在这个 Maven 配置中看不到任何 AWS 凭据。由 Maven 的“exec”插件启动的进程将自动获取环境变量,因此这将使用我们在过去几章中一直在使用的 AWS 环境变量,而无需我们进一步配置。

在大多数情况下,您应该为您的测试环境使用单独的 AWS 帐户,以隔离测试基础设施和数据。要在这里实现这一点,只需通过环境变量提供不同的 AWS 凭据集。

在我们的端到端测试运行后,CloudFormation 栈的拆除工作以相同的方式进行,作为 Maven 的“post-integration-test”生命周期阶段的一部分:

<execution>
  <id>001-cfn-delete</id>
  <phase>post-integration-test</phase>
  <goals>
    <goal>exec</goal>
  </goals>
  <configuration>
    <basedir>${project.parent.basedir}</basedir>
    <executable>aws</executable>
    <arguments>
      <argument>cloudformation</argument>
      <argument>delete-stack</argument>
      <argument>--stack-name</argument>
      <argument>${integration.test.stack.name}</argument>
    </arguments>
  </configuration>
</execution>

现在我们已经达到了测试金字塔的顶端。端到端测试带来了很多价值:它部署并运行整个应用程序。它测试了关键路径,就像在生产环境中执行的那样。然而,随着这个价值而来的是相当高的成本——我们需要大量额外的配置和设置以及拆除代码,以确保测试可以重复运行,并且不倾向于特定的 AWS 帐户或区域。尽管有这些努力,这个测试仍然容易受到供应商故障、环境变化以及在全球网络上操作固有的不确定行为的影响。

换句话说,与单元测试和功能测试相比,我们的端到端测试更加脆弱且维护成本高昂。因此,您应尽量少写端到端测试,并且更多地依赖成本较低的测试来全面测试您的应用程序。

本地云测试

多年来,一个良好的开发工作流的固有和不可动摇的特性是能够在不触及任何外部资源的情况下在本地运行整个应用程序或系统。对于传统的桌面或服务器应用程序,这可能意味着只运行应用程序本身,或者可能包括应用程序和数据库。对于 web 应用程序,需求列表可能包括反向代理、Web 服务器和作业队列。

但是,当我们开始使用供应商管理的云服务时会发生什么呢?我们最初的反应可能是尝试实现与以前相同的完全本地开发工作流程,使用像 localstacksam local(“sam local invoke”)这样的工具。这种方法起初可能看似可行,但很快就会与云优先架构相冲突,在这种架构中,我们希望充分利用由云供应商提供的可扩展、可靠、完全托管的服务。最重要的是,我们不希望将服务选择限制为仅允许我们的开发工作流程。这是本末倒置的问题!

在一个由供应商管理的云服务的世界里,完全本地开发存在哪些困难?根本问题是保真度:一个服务的本地版本(比如 S3 或 DynamoDB 或 Lambda)要具有与云版本相同的属性是完全不可能的。即使供应商(在这种情况下是 AWS)提供了本地模拟,它仍然会存在以下至少一些问题:

  • 缺少功能

  • 不同的(或者不存在的)控制平面行为(例如,创建 DynamoDB 表)

  • 不同的扩展行为

  • 不同的延迟(例如,与云服务相比,本地模拟的延迟非常低)

  • 不同的故障模式

  • 不同的(或者没有)安全控制

一次又一次地遇到这些问题后,我们倡导本章中采用的务实测试方法。我们广泛依赖单元测试来验证特定功能片段的行为,并且在开发各个 Lambda 函数时使用这些测试来快速迭代。功能测试使用模拟或存根来代替 AWS SDK 客户端和其他外部依赖项来执行 Lambda 函数的功能。最后,几个完整的端到端测试让我们在云中执行整个应用程序,使用相同的 SAM 基础设施模板和 CLI 命令,就像我们在生产环境中使用的那样。

云测试环境

对于我们在本章中描述的单元测试和功能测试,具有 Java、Maven 和您喜欢的 IDE 的本地环境将非常满足要求。对于端到端测试,您需要访问一个 AWS 账户。这对于单个开发人员在隔离环境中工作是非常简单的,但是当作为较大团队的一部分工作时,情况可能会变得更加复杂。

当您作为较大团队的一部分工作时,最佳的云资源工作方式是什么?我们发现一个好的起点是让每个开发人员拥有一个隔离的开发账户,并且整个团队为每个共享集成环境(例如,开发,测试,暂存)拥有一个账户。当依赖于真正共享的资源(如数据库或 S3 存储桶)时,情况可能会变得棘手,但总的来说,在快速开发过程中保持隔离可以防止从意外删除到资源争用等一系列问题。

严格的基础设施即代码方法使得在多个账户中管理资源变得更加容易。更进一步,基础设施即代码方法设置构建流水线意味着在新账户中部署无服务器应用可能就像部署一个代表构建流水线的单个 CloudFormation 栈那样简单,然后该栈将获取最新的源代码并部署应用程序。

概要

测试无服务器应用与测试传统应用没有本质区别——关键是找到覆盖范围、复杂性、成本和价值的平衡,并将我们的测试方法扩展到团队中使用。

在本章中,您学习到了测试金字塔如何指导您在无服务器应用程序中的测试策略。我们重构了我们的 Lambda 代码,以便轻松进行单元测试,并且能够在没有网络连接的情况下进行功能测试。端到端测试展示了基础设施即代码方法的有效性,以及测试分布式应用程序固有的高复杂度。

您看到了试图在本地运行云服务面临各种问题,特别是在本地无法达到的准确性。如果您想测试基于云的应用程序,最终您必须在云中实际运行它!最后,为了团队有效地工作,开发人员应该拥有隔离的云账户,团队应该有共享的集成环境。

通过测试,我们现在有信心我们的应用程序会按预期行事。在下一章中,我们将探讨如何通过日志记录、度量和跟踪来了解我们部署的应用程序的行为。

练习

本章的代码和测试涉及 S3 和 SNS。为第五章的应用编写一个集成测试,该测试使用 Java 从部署的 API Gateway 发出 HTTP 调用,然后断言其响应(及副作用)。如果有余力,可以使用Java 11 的新原生 HTTP 客户端

第七章:日志记录、指标和跟踪

在本章中,我们将探讨如何通过日志记录、指标和跟踪来增强 Lambda 函数的可观察性。通过日志记录,您将学习如何从 Lambda 函数执行期间发生的特定事件中获取信息。平台和业务指标将揭示我们无服务器应用程序的运行健康状态。最后,分布式跟踪将让您看到请求如何流向组成我们架构的不同托管服务和组件。

我们将使用第五章的天气 API 来探索 AWS 无服务器应用程序中可用的广泛的日志记录、指标和跟踪选项。类似于我们在第六章中对数据管道所做的更改,您将注意到天气 API 的 Lambda 函数已经重构为使用aws-lambda-java-events库。

日志记录

根据以下日志消息,我们能推断出生成它的应用程序的状态是什么?

Recorded a temperature of 78 F from Brooklyn, NY

我们知道一些数据的值(温度测量和位置),但不知道其他太多。这些数据是何时接收或处理的?在我们应用程序的更大上下文中,哪个请求生成了这些数据?哪个 Java 类和方法产生了这条日志消息?我们如何将其与其他可能相关的日志消息进行关联?

从本质上讲,这是一条没有帮助的日志消息。它缺乏上下文和具体性。如果像这样的消息被重复数百或数千次(可能使用不同的温度或位置值),它将失去意义。当我们的日志消息是散文(例如句子或短语)时,如果不使用正则表达式或模式匹配,解析它们会更加困难。

在探索 Lambda 函数中的日志记录时,请记住高价值日志消息的几个属性:

数据丰富

我们希望捕获尽可能多的数据,既可行又具有成本效益。我们拥有的数据越多,就越不需要在事后返回并添加更多日志记录。

高基数

使特定日志消息唯一的数据值尤为重要。例如,像请求 ID 这样的字段将具有大量唯一值,而像线程优先级这样的字段可能不会(尤其是在单线程 Lambda 函数中)。

可机读

使用 JSON 或其他易于机器读取的标准化格式(无需自定义解析逻辑)将通过下游工具简化分析。

CloudWatch Logs

CloudWatch Logs 正如其名称所示,是 AWS 的日志收集、聚合和处理服务。通过各种机制,它接收来自应用程序和其他 AWS 服务的日志数据,并通过 Web 控制台以及 API 使这些数据可访问。

CloudWatch Logs 的两个主要组织组件是日志组和日志流。日志组是一组相关日志流的顶层分组。日志流是一系列日志消息的列表,通常来自单个应用程序或函数实例。

Lambda 和 CloudWatch Logs

在无服务器应用程序中,默认情况下每个 Lambda 函数有一个日志组,其中包含许多日志流。每个日志流包含特定函数实例的所有函数调用的日志消息。回顾第三章,Lambda 运行时会捕获写入标准输出(Java 中的System.out)或标准错误(System.err)的任何内容,并将该信息转发给 CloudWatch Logs。

Lambda 函数的日志输出如下所示:

START RequestId: 6127fe67-a406-11e8-9030-69649c02a345
  Version: $LATEST
Recorded a temperature of 78 F from Brooklyn, NY
END RequestId: 6127fe67-a406-11e8-9030-69649c02a345
REPORT RequestId: 6127fe67-a406-11e8-9030-69649c02a345
  Duration: 2001.52 ms
  Billed Duration: 2000 ms
  Memory Size: 512 MB
  Max Memory Used: 51 MB

STARTENDREPORT行是 Lambda 平台自动添加的。特别感兴趣的是带有 UUID 值标记为RequestId的值。这是每次请求的Lambda 函数调用都唯一的标识符。日志中重复的RequestId值最常见的来源是当我们的函数出现错误并且平台重试执行时(参见“错误处理”)。除此之外,由于 Lambda 平台(像大多数分布式系统一样)具有“至少一次”语义,即使没有错误,平台偶尔也可能多次使用相同的RequestId值调用函数(我们在“至少一次传递”中研究了这种行为)。

LambdaLogger

上面STARTEND行之间的日志行是使用System.out.println生成的。这是从简单的 Lambda 函数开始记录的一个完全合理的方法,但还有几种其他选项可以提供合理的行为和定制的组合。其中的第一种选择是 AWS 提供的LambdaLogger类。

此记录器通过 Lambda Context对象访问,因此我们需要修改我们的WeatherEvent Lambda 处理函数以包括该参数,如下所示:

public class WeatherEventLambda {public APIGatewayProxyResponseEvent handler(
      APIGatewayProxyRequestEvent request,
      Context context
       ) throws IOException {

    context.getLogger().log("Request received");}
}

此日志语句的输出看起来就像是使用System.out.println生成的一样:

START RequestId: 4f40a12b-1112-4b3a-94a9-89031d57defa Version: $LATEST
Request received
END RequestId: 4f40a12b-1112-4b3a-94a9-89031d57defa

当输出包含换行符(例如堆栈跟踪)时,您可以看到LambdaLoggerSystem println方法之间的区别:

public class WeatherEventLambda {public APIGatewayProxyResponseEvent handler(
      APIGatewayProxyRequestEvent request,
      Context context
       ) throws IOException {

    StringWriter stringWriter = new StringWriter();
    Exception e = new Exception();
    e.printStackTrace(new PrintWriter(stringWriter));

    context.getLogger().log(stringWriter);}
}

使用System.err.println打印的堆栈跟踪会生成多行 CloudWatch Logs 条目(图 7-1)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0701.png

图 7-1. 使用 System.err.println 在 CloudWatch Logs 中输出的堆栈跟踪

使用 LambdaLogger,该堆栈跟踪是单个条目(可以在 Web 控制台中展开,如图 7-2 所示)。

仅这一特性就足以使用LambdaLogger而不是System.out.printlnSystem.err.println,特别是在打印异常堆栈跟踪时。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0702.png

图 7-2. 使用 LambdaLogger 在 CloudWatch Logs 中输出堆栈跟踪

Java 日志框架

LambdaLogger 对于简单的 Lambda 函数通常已经足够了。然而,正如本章后面将要介绍的,定制日志输出以满足特定需求,比如捕获业务指标或生成应用程序警报,通常更为有用。虽然可以使用 Java 的标准库(比如 String.format)生成这种类型的输出,但使用像 Log4J 或 Java Commons Logging 这样的现有日志框架会更容易。这些框架提供了诸如日志级别、基于属性或文件的配置以及各种输出格式等便利功能。它们还可以轻松地在每条日志消息中包含相关的系统和应用程序上下文(如 AWS 请求 ID)。

当 Lambda 首次推出时,AWS 提供了一个非常旧且不支持的 Log4J 版本的自定义 appender。在基于 Lambda 的无服务器应用程序中使用这个旧版本的流行日志框架使得集成新的日志功能变得困难。因此,我们花费了相当多的时间和精力为 Lambda 函数构建了一个更现代化的日志解决方案,称为 lambda-monitoring,它使用了 SLF4J 和 Logback。

然而,AWS 现在提供了一个,其中包含一个自定义的日志 appender,它在底层使用 LambdaLogger,适用于最新版本的 Log4J2。我们现在建议按照 AWS 在 Lambda 文档的 Java logging section 中概述的方式进行设置。设置这种日志记录方法只需添加几个额外的依赖项、添加一个 log4j2.xml 配置文件,然后在我们的代码中使用 org.apache.logging.log4j.Logger

这里是我们的 Weather API 项目的 pom.xml 添加部分:

<dependencies>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-lambda-java-log4j2</artifactId>
    <version>1.1.0</version>
  </dependency>
  <dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.12.1</version>
    </dependency>
  <dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.12.1</version>
  </dependency>
</dependencies>

log4j2.xml 配置文件对于使用过 Log4J 的人来说应该很熟悉。它使用 AWS 提供的 Lambda appender,并允许自定义日志模式:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.amazonaws.services.lambda.runtime.log4j2">
  <Appenders>
    <Lambda name="Lambda">
      <PatternLayout>
        <pattern>
          %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1}:%L%m%n
        </pattern>
      </PatternLayout>
    </Lambda>
  </Appenders>
  <Loggers>
    <Root level="info">
      <AppenderRef ref="Lambda"/>
    </Root>
  </Loggers>
</Configuration>

注意日志模式包括 Lambda 请求 ID(%X{AWSRequestId})。在我们之前的日志示例中,大多数输出行中并没有包含该请求 ID —— 它只在调用的开头和结尾出现。通过在每一行中包含它,我们可以将每个输出片段与特定请求关联起来,这在使用其他工具检查这些日志或进行离线分析时非常有帮助。

在我们的 Lambda 函数中,我们设置了日志记录器并使用其 error 方法记录了一个 ERROR level 的消息以及异常信息:

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class WeatherEventLambda {
  private static Logger logger = LogManager.getLogger();public APIGatewayProxyResponseEvent handler(
    APIGatewayProxyRequestEvent request, Context context)
    throws IOException {

    Exception e = new Exception("Test exception");
    logger.error("Log4J logger", e);
    ...
  }
}

Lambda Log4J2 appender 的输出显示在 图 7-3 中。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0703.png

图 7-3. 使用 Log4J2 在 CloudWatch Logs 中输出堆栈跟踪

它包括时间戳、AWS 请求 ID、日志级别(本例中为 ERROR)、调用日志方法的文件和行,以及正确格式化的异常。我们可以使用 Log4J 提供的桥接库将其他日志框架的日志消息路由到我们的 Log4J appender。这种技术最有用的应用之一,至少对于我们的 WeatherEventLambda 来说,是深入了解使用 Apache Commons Logging(以前称为 Jakarta Commons Logging 或 JCL)的 AWS Java SDK 的行为。

首先,我们将 Log4J JCL 桥接库添加到我们 pom.xml 文件的 dependencies 部分:

<dependency>
  <groupId>org.apache.logging.log4j</groupId>
  <artifactId>log4j-jcl</artifactId>
  <version>2.12.1</version>
</dependency>

接下来,我们在 log4j2.xml 文件的 Loggers 部分启用调试日志:

<Loggers>
  <Root level="debug">
    <AppenderRef ref="Lambda"/>
  </Root>
</Loggers>

现在我们可以看到来自 AWS Java SDK 的详细日志信息(参见图 7-4)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0704.png

图 7-4. AWS SDK 的详细调试日志

我们可能不希望始终获取此信息,但是如果出现问题,调试时这将非常有用——在本例中,我们确切地看到了 DynamoDB PutItem API 调用的正文内容。

通过使用更复杂的日志框架,我们可以更深入地了解围绕日志输出的上下文。我们可以使用请求 ID 将不同 Lambda 请求的日志分开。使用日志级别,我们可以了解某些日志行是否表示错误,或者关于应用程序状态的警告,或者其他行是否可以忽略(或稍后分析),因为它们包含大量但不太相关的调试信息。

结构化日志

如前一节所述,我们的日志系统捕获了大量有用的信息和上下文,准备用于检查和改进我们的应用程序。

然而,当我们需要从这些大量的日志数据中提取某些值时,通常很难访问,查询起来很棘手,而且由于实际消息仍然基本上是自由形式的文本,通常必须使用一系列难以理解的正则表达式来精确查找您正在寻找的行。虽然有一些标准化的格式已经为某些空格或制表符分隔字段的值建立了约定,但不可避免地,正则表达式会在下游流程和工具中出现。

我们可以使用一种称为结构化日志的技术,而不是继续使用自由文本方式,标准化我们的日志输出,并通过标准查询语言轻松搜索所有日志。

以这条 JSON 日志条目为例:

{
  "thread": "main",
  "level": "INFO",
  "loggerName": "book.api.WeatherEventLambda",
  "message": {
    "locationName": "Brooklyn, NY",
    "action": "record",
    "temperature": 78,
    "timestamp": 1564506117
  },
  "endOfBatch": false,
  "loggerFqcn": "org.apache.logging.log4j.spi.AbstractLogger",
  "instant": {
    "epochSecond": 1564506117,
    "nanoOfSecond": 400000000
  },
  "contextMap": {
    "AWSRequestId": "d814bbbe-559b-4798-aee0-31ddf9235a76"
  },
  "threadId": 1,
  "threadPriority": 5
}

我们可以使用 JSON 路径规范来提取信息,而不是依赖字段顺序。例如,如果我们想提取 temperature 字段,我们可以使用 JSON 路径 .message.temperature。CloudWatch Logs 服务支持在 Web 控制台中进行搜索(参见图 7-5),以及创建 Metric Filters,我们稍后会在本章中讨论。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0705.png

图 7-5. 使用 JSON Path 表达式在 CloudWatch Logs Web 控制台中进行搜索

Java 中的结构化日志记录

现在我们理解了使用 JSON 格式进行结构化日志记录的好处,不幸的是,在尝试从基于 Java 的 Lambda 函数记录 JSON 时,我们立即遇到了困难。Java 中的 JSON 处理以冗长而出名,为构建日志输出添加大量样板代码似乎不是正确的方式。

幸运的是,我们可以使用 Log4J2 生成 JSON 格式的日志输出(Log4J2 JSONLayout)。以下 log4j2.xml 配置将启用输出到STDOUT的 JSON 格式化输出,这对于我们的 Lambda 函数意味着输出将被发送到 CloudWatch Logs:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.amazonaws.services.lambda.runtime.log4j2">
  <Appenders>
    <Lambda name="Lambda">
      <JsonLayout
        compact="true"
        eventEol="true"
        objectMessageAsJsonObject="true"
        properties="true"/>
    </Lambda>
  </Appenders>
  <Loggers>
    <Root level="info">
      <AppenderRef ref="Lambda"/>
    </Root>
  </Loggers>
</Configuration>

在我们的 Lambda 代码中,我们将 Log4J2 日志记录器设置为静态字段:

...
private static Logger logger = LogManager.getLogger();
...

不再像Recorded a temperature of 78 F from Brooklyn, NY这样记录字符串,我们将构建一个包含键和值的Map,如下所示:

HashMap<Object, Object> message = new HashMap<>();
message.put("action", "record");
message.put("locationName", weatherEvent.locationName);
message.put("temperature", weatherEvent.temperature);
message.put("timestamp", weatherEvent.timestamp);

logger.info(new ObjectMessage(message));

这是那条日志行的输出:

{
  "thread": "main",
  "level": "INFO",
  "loggerName": "book.api.WeatherEventLambda",
  "message": {
    "locationName": "Brooklyn, NY",
    "action": "record",
    "temperature": 78,
    "timestamp": 1564506117
  },
  "endOfBatch": false,
  "loggerFqcn": "org.apache.logging.log4j.spi.AbstractLogger",
  "instant": {
    "epochSecond": 1564506117,
    "nanoOfSecond": 400000000
  },
  "contextMap": {
    "AWSRequestId": "d814bbbe-559b-4798-aee0-31ddf9235a76"
  },
  "threadId": 1,
  "threadPriority": 5
}

值得注意的一个警告是,与我们的应用程序相关的信息在message键下,但淹没在其他输出中。不幸的是,大部分输出都是 Log4J2 JsonLayout 固有的,因此我们无法在没有一些工作的情况下移除它。正如我们将在下一节看到的那样,然而,使用 JSON 格式化的日志事件的好处远远超过增加的冗长。

CloudWatch Logs Insights

结构化日志使我们能够使用更复杂的工具来分析我们的日志,无论是实时还是事后。虽然原始的 CloudWatch Logs Web 控制台对使用 JSONPath 表达式查询日志数据有一定支持(如前所示),但真正复杂的分析直到最近才需要直接下载日志或将其转发到另一个服务。

CloudWatch Logs Insights 是 CloudWatch Logs 生态系统的新成员,提供强大的搜索引擎和专门的查询语言,非常适合分析结构化日志。继续我们之前章节的示例 JSON 日志行,现在让我们假设我们有一个月的每小时数据已经记录到 CloudWatch Logs。我们可能希望对该日志数据进行一些快速分析,查看每天的最低、平均和最高温度,但仅限于 Brooklyn。

以下 CloudWatch Logs Insights 查询正好实现了这一点:

filter message.action = "record"
    and message.locationName = "Brooklyn, NY"
| fields date_floor(concat(message.timestamp, "000"), 1d) as Day,
    message.temperature
| stats min(message.temperature) as Low,
    avg(message.temperature) as Average,
    max(message.temperature) as High by Day
| order by Day asc

让我们逐行查看这个查询在做什么:

  1. 首先,我们将数据筛选到具有message.action字段中值为recordmessage.locationName字段中值为“Brooklyn, NY”的日志事件。

  2. 在第二行中,我们提取了message.timestamp字段,并在传递给date_floor方法之前在末尾添加了三个零,这样可以用给定日期的最早时间戳值替换时间戳值(因为需要添加零以表示毫秒)。我们还提取了message.temperature字段。

  3. 第三行计算了message.temperature字段在一天的日志事件中的最小值、平均值和最大值。

  4. 最后一行按天对数据进行排序,从最早的一天开始。

我们可以在 CloudWatch Logs Insights Web 控制台中看到此查询的结果(参见图 7-6)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0706.png

图 7-6. CloudWatch Logs Insights

这些结果可以导出为 CSV 文件,或使用内置的可视化工具绘制图表(参见图 7-7)。

关于 CloudWatch Logs Insights,需要记住一些注意事项。首先,尽管该工具可以有效地用于对日志数据进行即席探索,但目前还不能直接生成额外的自定义指标或其他数据产品(尽管我们将看到如何从 JSON 日志数据生成自定义指标的方法!)。但是,它提供了一个 API 接口用于运行查询和访问结果,因此可以自行解决问题。最后但同样重要的是,查询的定价是基于扫描的数据量。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0707.png

图 7-7. CloudWatch Logs Insights 可视化

指标

日志消息是对系统在特定时间点状态的离散快照。而指标则旨在在一段时间内产生系统状态的更高级别视图。虽然单个指标是时间点的快照,但一系列指标显示了系统在运行过程中的趋势和行为,长时间内的表现。

CloudWatch 指标

CloudWatch 指标是 AWS 的指标存储服务。它从大多数 AWS 服务接收指标。在最基本的层次上,指标只是一组按时间排序的数据点。例如,在某一时刻,传统服务器的 CPU 负载可能为 64%。几秒钟后,它可能是 65%。在给定的时间段内,可以计算指标的最小值、最大值和其他统计数据(例如百分位数)。

指标按命名空间(例如 /aws/lambda)和指标名称(例如 WeatherEventLambda)分组。指标也可以有相关的维度,这些维度只是更细粒度的标识符,例如在跟踪非服务器应用程序中的应用程序错误的指标中,一个维度可能是服务器 IP。

CloudWatch 指标是监控 AWS 服务及我们自己应用行为的主要工具。

Lambda 平台指标

AWS 提供了许多功能和账户级别的指标,用于监控无服务器应用程序的整体健康和可用性。我们将这些称为平台指标,因为它们由 Lambda 平台提供,无需额外配置。

对于各个函数,Lambda 平台提供以下指标:

调用次数

函数被调用的次数(无论成功与否)。

限流

平台限流平台尝试函数调用次数。

Errors

函数调用返回错误次数。

Duration

函数开始执行到停止之间的“经过的墙钟时间”的毫秒数。此指标还支持百分位数

ConcurrentExecutions

特定时间点函数的并发执行次数。

对于由 Kinesis 或 DynamoDB 流事件源调用的函数,IteratorAge指标跟踪函数接收记录批次与该批次中最后一条记录写入流之间的毫秒数。该指标有效地显示了 Lambda 函数在特定时间点在流中落后的程度。

对于配置了死信队列(DLQ)的函数,当函数无法将消息写入 DLQ 时会增加DeadLetterErrors指标(有关 DLQ 的更多信息,请参见“错误处理”)。

此外,平台会跨账户和地区聚合InvocationsThrottlesErrorsDurationConcurrentExecutions这些指标。UnreservedConcurrentExecutions指标会聚合账户和地区中所有未指定自定义并发限制的函数的并发执行次数。

Lambda 平台生成的指标还包括以下额外维度:FunctionNameResource(例如函数版本或别名)和ExecutedVersion(用于别名调用,在下一章中讨论)。提到的每个函数级指标都可以具有这些维度。

业务指标

平台指标和应用程序日志是监控无服务器应用程序的重要工具,但在评估我们的应用程序是否正确和完全执行其业务功能方面并不有用。例如,捕获 Lambda 执行持续时间的指标有助于捕获意外的性能问题,但它并不告诉我们 Lambda 函数(或整个应用程序)是否正确处理了客户事件。另一方面,捕获为我们最受欢迎的位置成功处理的天气事件数量的指标告诉我们,无论底层技术实现如何,应用程序(或至少与处理天气事件相关的部分)都在正确工作。

这些业务指标不仅可以作为我们业务逻辑的脉搏检测,也可以作为不依赖于具体实现或平台的聚合指标。以我们之前的例子为例,如果 Lambda 执行时间增加了,这意味着什么?我们只是在处理更多的数据,还是配置或代码变更影响了函数的性能?这真的重要吗?然而,如果我们的应用处理的天气事件数量意外减少,我们知道有些问题,并且需要立即调查。

在传统应用中,我们可能直接使用 CloudWatch 指标 API,通过使用PutMetricData API 调用在生成这些自定义指标时主动推送。更复杂的应用程序可能会定期以小批量推送指标。

Lambda 函数有两个特性使PutMetricData方法难以使用。首先,Lambda 函数可以快速扩展到数百或数千个并发执行。CloudWatch 指标 API 会对PutMetricData调用进行限流(CloudWatch 限制),因此,试图持久化重要数据的行为可能导致指标丢失。其次,由于 Lambda 函数是短暂的,几乎没有机会或好处可以在单个执行期间批处理指标。不能保证后续执行会在相同的运行时实例中进行,因此跨调用进行批处理是不可靠的。

幸运的是,CloudWatch 指标有两个功能以可扩展且可靠的方式处理此情况,通过完全将 CloudWatch 指标数据的生成移出 Lambda 执行的过程。第一个和最新的功能称为CloudWatch 嵌入式指标格式,它使用特殊的日志格式自动创建指标。这种特殊的日志格式目前 Log4J 还不支持(除非进行大量额外的工作),因此我们不会在这里使用它,但在其他情况下,这是在 Lambda 中生成指标的首选方法。

另一个功能,CloudWatch 指标过滤器,也可以使用 CloudWatch 日志数据生成指标。与嵌入式指标格式不同,它可以访问列格式和任意嵌套的 JSON 结构中的数据。这使得它成为我们这种情况的更好选择,因为我们不能轻松地将 JSON 键添加到日志语句的顶层。它通过扫描 CloudWatch 日志并将指标分批推送到 CloudWatch 指标服务来生成指标数据。

我们使用结构化日志记录使得设置度量过滤器变得简单,只需将以下内容添加到我们的template.yaml文件中:

BrooklynWeatherMetricFilter:
  Type: AWS::Logs::MetricFilter
  Properties:
    LogGroupName: !Sub "/aws/lambda/${WeatherEventLambda}"
    FilterPattern: '{$.message.locationName = "Brooklyn, NY"}'
    MetricTransformations:MetricValue: "1"
    MetricNamespace: WeatherApi
    MetricName: BrooklynWeatherEventCount
    DefaultValue: "0"

每当 JSON 日志行包含message.locationName字段为“纽约布鲁克林”时,此指标过滤器将增加BrooklynWeatherEventCount指标。我们可以通过 CloudWatch Metrics Web 控制台访问和可视化此指标,也可以像处理常规平台指标一样配置 CloudWatch 告警和操作。

在这个例子中,每次事件发生时我们有效地增加一个计数器,但在适当的情况下也可以(根据捕获日志行的数据)使用实际值。有关更多详情,请参阅MetricFilter MetricTransformation文档。

告警

与所有 CloudWatch 指标一样,我们可以使用数据来建立警报,以便在出现问题时发出警告。至少,我们建议为ErrorsThrottles平台指标设置警报,如果不是基于每个帐户的设置,则至少为生产函数设置。

对于由 Kinesis 或 DynamoDB 流事件源触发的函数,IteratorAge指标是函数是否跟上流事件数量的关键指示(这取决于流中的分片数、Lambda 事件源中配置的批量大小、ParallelizationFactor以及 Lambda 函数本身的性能)。

在上一节中我们配置的BrooklynWeatherEventCount指标,以下是关联的 CloudWatch 告警的配置方式。如果该指标值在 60 秒内降至零(表示我们停止接收“纽约布鲁克林”的天气事件),则此告警将通过 SNS 消息提醒我们:

BrooklynWeatherAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    Namespace: WeatherApi
    MetricName: BrooklynWeatherEventCount
    Statistic: Sum
    ComparisonOperator: LessThanThreshold
    Threshold: 1
    Period: 60
    EvaluationPeriods: 1
    TreatMissingData: breaching
    ActionsEnabled: True
    AlarmActions:!Ref BrooklynWeatherAlarmTopic

BrooklynWeatherAlarmTopic:
  Type: AWS::SNS::Topic

图 7-8 展示了在 CloudWatch Web 控制台中查看该告警的视图。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0708.png

图 7-8. BrooklynWeatherAlarm CloudWatch 告警

当前告警“触发”时生成的 SNS 消息可用于发送通知电子邮件,或触发像PagerDuty这样的第三方警报系统。

与 Lambda 函数和 DynamoDB 表等应用组件一样,我们强烈建议将 CloudWatch 指标过滤器、告警和所有其他基础设施都保存在与其他所有内容相同的template.yaml文件中。这不仅允许我们利用模板内部引用和依赖关系,还能将我们的指标和告警配置与应用程序紧密地联系在一起。如果您不希望为堆栈的开发版本生成这些运行资源,可以使用CloudFormation 的Conditions功能

分布式跟踪

到目前为止,我们介绍的度量和日志功能为我们提供了关于单个应用组件(如 Lambda 函数)的洞察力。然而,在涉及到许多组件的复杂应用中,我们很难将日志输出和度量数据拼凑成一个请求流,例如涉及 API Gateway、两个 Lambda 函数和 DynamoDB 表的情况。

幸运的是,AWS 的分布式追踪服务 X-Ray 正好可以处理这种用例。该服务基本上会为进入或由我们的应用程序生成的事件“打标记”,并在这些事件流经我们的应用程序时进行跟踪。当标记的事件触发 Lambda 函数时,X-Ray 可以跟踪 Lambda 函数所进行的外部服务调用,并将有关这些调用的信息添加到跟踪中。如果调用的服务也启用了 X-Ray,则跟踪将继续进行。通过这种方式,X-Ray 不仅跟踪特定事件,还生成了我们应用程序中所有组件的服务映射及其相互交互的图。

对于 AWS Lambda,有两种模式用于 X-Ray 追踪。第一种是 PassThrough,这意味着如果触发 Lambda 函数的事件已经被 X-Ray “标记”,则 Lambda 函数的调用将由 X-Ray 追踪。如果触发事件尚未被标记,则 Lambda 不会记录任何跟踪信息。相反,Active 追踪会主动将 X-Ray 跟踪 ID 添加到所有 Lambda 调用中。

在以下示例中,我们已启用 API Gateway 的追踪,该功能将为传入事件添加 X-Ray 跟踪 ID。Lambda 函数配置为 PassThrough 模式,因此当它由 API Gateway 的标记事件触发时,它将将该跟踪 ID 传播到下游服务。请注意,如果 Lambda 的 IAM 执行角色具有向 X-Ray 服务发送数据的权限,则默认情况下启用 PassThrough 模式;否则,如我们在此处所做的那样,可以显式配置(在这种情况下,SAM 将向 Lambda 执行角色添加适当的权限)。

这是我们 SAM template.yaml 文件中的 Globals 部分,从 第五章 更新以启用 API Gateway 追踪:

Globals:
  Function:
    Runtime: java8
    MemorySize: 512
    Timeout: 25
    Environment:
      Variables:
        LOCATIONS_TABLE: !Ref LocationsTable
    Tracing: PassThrough
  Api:
    OpenApiVersion: '3.0.1'
    TracingEnabled: true

启用追踪功能后,我们还可以将 X-Ray 库添加到我们的 pom.xml 文件中。通过添加这些库,我们将在 Lambda 函数与 DynamoDB 和 SNS 等服务交互时享受到 X-Ray 追踪的好处,而无需修改我们的 Java 代码。

像 AWS SDK 一样,X-Ray 提供了一个材料清单(BOM),可以确保我们项目中使用的所有 X-Ray 库的版本保持同步。要使用 X-Ray BOM,请将其添加到顶层 pom.xml 文件的 <dependencyManagement> 部分:

<dependency>
  <groupId>com.amazonaws</groupId>
  <artifactId>aws-xray-recorder-sdk-bom</artifactId>
  <version>2.3.0</version>
  <type>pom</type>
  <scope>import</scope>
</dependency>

现在我们需要添加三个 X-Ray 库,这些库将为我们的基于 Java 的 Lambda 函数进行仪器化:

<dependency>
  <groupId>com.amazonaws</groupId>
  <artifactId>aws-xray-recorder-sdk-core</artifactId>
</dependency>
<dependency>
  <groupId>com.amazonaws</groupId>
  <artifactId>aws-xray-recorder-sdk-aws-sdk</artifactId>
</dependency>
<dependency>
  <groupId>com.amazonaws</groupId>
  <artifactId>aws-xray-recorder-sdk-aws-sdk-instrumentor</artifactId>
</dependency>

图 7-9 展示了我们 API 的 X-Ray 服务地图,来自 第五章,展示了 API Gateway、Lambda 平台、Lambda 函数和 DynamoDB 表:

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0709.png

图 7-9. X-Ray 服务地图

我们还可以查看一个单独事件的追踪(在本例中为我们的 HTTP POST),该事件经过 API Gateway、Lambda 和 DynamoDB(图 7-10)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0710.png

图 7-10. X-Ray 追踪

查找错误

当我们的 Lambda 函数抛出错误时会发生什么?我们可以通过 X-Ray 控制台调查错误,通过服务地图和跟踪界面两种方式。

首先,让我们通过移除 WeatherEvent Lambda 访问 DynamoDB 的权限,向该 Lambda 函数引入一个错误:

  WeatherEventLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: target/lambda.zip
      Handler: book.api.WeatherEventLambda::handler
 #      Policies:
 #        — DynamoDBCrudPolicy:
 #            TableName: !Ref LocationsTable
      Events:
        ApiEvents:
          Type: Api
          Properties:
            Path: /events
            Method: POST

在部署我们的无服务器应用程序堆栈后,我们可以向 /events 端点发送一个 HTTP POST 事件。当 WeatherEvent Lambda 尝试将该事件写入 DynamoDB 时,它失败并抛出异常。在此之后的 X-Ray 服务地图显示如下(图 7-11)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0711.png

图 7-11. X-Ray 服务地图显示的错误

并且当我们深入研究导致错误的具体请求时,我们可以看到我们的 POST 请求返回了一个 HTTP 502 错误(图 7-12)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0712.png

图 7-12. X-Ray 追踪显示的错误

然后,我们可以通过悬停在显示 Lambda 调用轨迹部分的错误图标上,轻松看到导致 Lambda 函数失败的具体 Java 异常(图 7-13)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0713.png

图 7-13. X-Ray 追踪显示的 Java 异常

点击后,我们可以从 X-Ray 追踪控制台完整地查看堆栈跟踪,即从 图 7-14 开始。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0714.png

图 7-14. X-Ray 显示的 Java 异常堆栈跟踪

总结

在本章中,我们介绍了多种方式,可以详细了解我们的无服务器应用程序的执行和功能,无论是在单个函数或组件级别,还是作为完整应用程序。我们展示了如何使用结构化 JSON 日志记录实现可观察性,并使我们能够从高度可扩展的 Lambda 函数中提取有意义的业务指标,而无需超负荷使用 CloudWatch API。

最后,我们向我们的 Maven pom.xml 添加了一些依赖项,并解锁了完整功能的分布式跟踪能力,这不仅追踪单个请求,还自动构建了我们无服务器应用程序的所有组件地图,并允许我们轻松地深入错误或意外行为。

现在基础知识已经介绍完毕,在下一章中,我们将深入探讨高级 Lambda 技术,使我们的生产无服务器系统更加强大和可靠。

练习

  1. 本章基于第五章的 API 网关代码进行构建。在来自第六章的更新数据流水线代码中添加 X-Ray 仪器,观察与 SNS 和 S3 的交互如何显示在 X-Ray 控制台中。

  2. 除了像本章所做的那样增加一个度量标准外,CloudWatch Logs 度量过滤器可以解析日志行中的度量值。使用这种技术为纽约布鲁克林的温度生成 CloudWatch Logs 度量标准。为了额外加分,当温度低于 32 华氏度时,添加一个警报!

第八章:高级 AWS Lambda

随着我们接近本书的结尾,是时候学习一些 Lambda 的方面了,这些方面对于构建可用于生产的应用程序至关重要——例如错误处理、扩展以及 Lambda 的一些能力,我们并非总是使用,但在需要时很重要。

错误处理

到目前为止,我们所有的示例都生活在没有系统故障和没有人在编写代码时犯错误的美好世界中。当然,在现实世界中,事情会出错,任何有用的生产应用程序和架构都需要处理错误发生的时间,无论是在我们的代码中还是在我们依赖的系统中。

由于 AWS Lambda 是一个“平台”,在处理错误时有一定的限制和行为,本节我们将深入探讨可以发生哪些类型的错误,在哪些情境中发生以及我们如何处理它们。作为语言说明,我们将“错误”和“异常”这两个词互换使用,没有 Java 世界中两个术语之间的微妙差别。

错误类别

在使用 Lambda 时,可能会出现几种不同类别的错误。主要错误如下,按照事件处理过程中可能发生的时间顺序大致排列如下:

  1. 初始化 Lambda 函数时出现的错误(加载我们的代码、定位处理程序或函数签名时的问题)

  2. 将输入解析为指定函数参数时出现的错误

  3. 与外部下游服务(数据库等)通信时出现的错误。

  4. 在 Lambda 函数内部生成的错误(无论是在其代码中还是在其直接环境中,例如内存不足的问题)

  5. 函数超时引起的错误

我们可以将错误分为已处理错误和未处理错误两类另一种方法。

例如,让我们考虑与下游微服务通过 HTTP 进行通信并且它抛出错误的情况。在这种情况下,我们可以选择在 Lambda 函数内部捕获错误并在那里处理(已处理错误),或者让错误传播到环境中(未处理错误)。

或者,假设我们在 Lambda 配置中指定了一个不正确的方法名。在这种情况下,我们无法在 Lambda 函数代码中捕获错误,因此这始终是一个未处理错误。

如果我们在代码中自行处理错误,那么 Lambda 实际上与我们的特定错误处理策略无关。我们可以选择像日志记录到标准错误一样,但正如我们在第七章中所看到的,Lambda 将标准错误与标准输出视为相同,如果内容发送到其中,不会引发任何警报。

因此,在 Lambda 处理错误时,所有的微妙之处都在于未处理的错误——即通过未捕获的异常将错误传递给 Lambda 运行时或外部发生的错误。这些错误会发生什么?有趣的是,这显著取决于触发 Lambda 函数的事件源类型,现在我们将详细探讨这一点。

Lambda 错误处理的各种行为

Lambda 根据触发调用的事件源来处理错误。我们在第五章中列出了每一种事件源类型(表 5-1):

  • 同步事件源(例如,API 网关)

  • 异步事件源(例如,S3 和 SNS)

  • 流/队列事件源(例如,Kinesis 数据流和 SQS)

这些类别中的每一个都有一个不同的模型来处理 Lambda 函数抛出的错误,如下所示。

同步事件源

这是最简单的模型。对于以这种方式调用的 Lambda 函数,错误将向上传播到调用者,并且不会执行自动重试。错误如何暴露给上游客户端取决于调用 Lambda 函数的具体方式,因此您应该在代码中尝试强制错误,以查看此类问题如何暴露。

例如,如果 API 网关是事件源,那么 Lambda 函数抛出的错误将导致错误被发送回 API 网关。API 网关随后向原始请求者返回一个 500 的 HTTP 响应。

异步事件源

由于此调用模型是异步的或事件导向的,没有上游调用者可以对错误执行任何有用的操作,因此 Lambda 具有更复杂的错误处理模型。

首先,如果在这种调用模型中检测到错误,则 Lambda 将(默认情况下)重试处理事件多达两次(总共三次尝试),并在重试之间设置延迟(具体延迟未记录,但稍后我们将看到一个示例)。

如果 Lambda 函数在所有重试尝试失败时,事件将被发布到函数的错误目标和/或死信队列(如果已配置);否则,事件将被丢弃和丢失。

流/队列事件源

在没有配置错误处理策略的情况下(参见“处理 Kinesis 和 DynamoDB 流错误”),如果在处理来自流/队列事件源的事件时,错误向上冒泡到 Lambda 运行时,则 Lambda 将持续重试该事件,直到(a)上游源中的失败事件过期或(b)问题解决。这意味着流或队列的处理实际上被阻塞,直到错误解决。请注意,在使用扩展到多个分片的流时,存在特定的细微差别,如果适用,请建议进行研究。

在考虑 Lambda 错误处理时,以下文档页面非常有用:

深入了解异步事件源错误

异步事件源是 Lambda 的一种常见使用方式,并且具有复杂的错误处理模型,因此让我们通过一个例子更深入地了解这个主题。

重试

我们从以下代码开始:

package book;

import com.amazonaws.services.lambda.runtime.events.S3Event;

public class S3ErroringLambda {
  public void handler(S3Event event) {
    System.out.println("Received new S3 event");
    throw new RuntimeException("This function unable to process S3 Events");
  }
}

我们以与第五章中BatchEvents Lambda函数相同的方式将其与 S3 存储桶连接,稍后我们将看到该 SAM 模板。

如果我们将文件上传到与此函数关联的 S3 存储桶中,我们在日志中看到图 8-1。

注意,Lambda 尝试处理 S3 事件共三次——首次在 20:44:00,然后约一分钟后,再约两分钟后。这是 Lambda 为异步事件源承诺的三次事件处理尝试。

我们能够使用单独的 CloudFormation 资源配置 Lambda 将执行的重试次数——0、1 或 2 次。例如,让我们配置 Lambda 不对SingleEventLambda函数进行任何重试,该函数来自于“示例:构建无服务器数据管道”。我们可以向应用程序模板添加以下资源:

  SingleEventInvokeConfig:
    Type: AWS::Lambda::EventInvokeConfig
    Properties:
      FunctionName: !Ref SingleEventLambda
      Qualifier: "$LATEST"
      MaximumRetryAttempts: 0

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0801.png

图 8-1. S3 错误期间的 Lambda 日志

如果我们不做进一步的更改,Lambda 在所有重试(如果有)完成后将不会再执行任何操作——将会记录关于原始事件的简要数据,但最终将被丢弃。对于像 S3 这样的情况,这并不太糟糕——我们随时可以稍后列出 S3 中的所有对象。但对于其他事件源来说,如果在修复错误原因后无法重新生成事件,则可能会成为问题。这个问题有两种解决方案——DLQ 和目标。DLQ 已存在较长时间,因此我们将首先描述它们,但目标具有更多功能。

死信队列

Lambda 提供了自动转发事件的功能(对于失败所有重试的异步源)到死信队列(DLQ)。此 DLQ 可以是 SNS 主题或 SQS 队列。一旦事件进入 SNS 或 SQS,您可以立即处理,或稍后手动处理(对于 SQS 而言)。例如,您可以注册一个单独的 Lambda 函数作为 SNS 主题的监听器,将失败的事件副本发布到操作 Slack 频道进行手动处理。

DLQ 可以与 Lambda 函数的所有其他属性一起配置。例如,我们可以向我们的示例应用程序添加一个 DLQ,并且还可以添加一个 DLQ 处理函数,使用 SAM 模板。

示例 8-1. 带有 DLQ 和 DLQ 监听器的 SAM 模板
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter8-s3-errors

Resources:
  DLQ:
    Type: AWS::SNS::Topic

  ErrorTriggeringBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${AWS::AccountId}-${AWS::Region}-errortrigger

  S3ErroringLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.S3ErroringLambda::handler
      CodeUri: target/lambda.zip
      DeadLetterQueue:
        Type: SNS
        TargetArn: !Ref DLQ
      Events:
        S3Event:
          Type: S3
          Properties:
            Bucket: !Ref ErrorTriggeringBucket
            Events: s3:ObjectCreated:*

  DLQProcessingLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.DLQProcessingLambda::handler
      CodeUri: target/lambda.zip
      Events:
        SnsEvent:
          Type: SNS
          Properties:
            Topic: !Ref DLQ

这里需要注意的重要元素如下:

  • 我们定义自己的 SNS 主题以充当 DLQ。

  • 在应用程序函数(S3ErroringLambda)内部,我们告诉 Lambda 我们希望为该函数设置 DLQ,其类型为 SNS,并且 DLQ 消息应发送到我们在此模板中创建的主题。

  • 我们还定义了一个单独的函数(DLQProcessingLambda),该函数由发送到 DLQ 的事件触发。

我们的DLQProcessingLambda代码如下:

package book;

import com.amazonaws.services.lambda.runtime.events.SNSEvent;

public class DLQProcessingLambda {
  public void handler(SNSEvent event) {
    event.getRecords().forEach(snsRecord ->
        System.out.println("Received DLQ event: " + snsRecord.toString())
    );
  }
}

现在,如果我们向 S3 上传文件,我们会在DLQProcessing Lambda的日志中看到以下内容,显示了对S3ErroringLambda的最终交付尝试后的处理:

Received DLQ event: {sns: {messageAttributes:
    {RequestID={type: String,value: ff294606-e377-4bad-8f2a-4c5f88042656},
     ErrorCode={type: String,value: 200}, ...

发送到 DLQ 处理函数的事件包括失败的完整原始事件,允许您稍后保存并处理。它还包括原始事件的RequestID,允许您在应用程序 Lambda 函数的日志中搜索有关出错原因的线索。

虽然在这个示例中,我们将所有 DLQ 资源包含在应用程序模板中,但您可以选择在应用程序外使用资源,因此跨应用程序共享这些 DLQ 元素。

目标

在 2019 年底,AWS 推出了一个用于捕获失败事件的 DLQ 替代方案:destinations。目标实际上比 DLQ 更强大,因为您可以捕获错误和成功处理的异步事件。

此外,目标支持比 DLQ 更多类型的目标。支持 SNS 和 SQS,就像它们与 DLQ 一样,但您还可以直接路由到另一个 Lambda 函数(跳过消息总线部分)或 EventBridge。

要配置目标,我们使用与之前配置重试计数时创建的AWS::Lambda::EventInvokeConfig资源相同类型的资源(参见“重试”)。例如,让我们用目标替换前面示例中的 DLQ:

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter8-s3-errors

Resources:
  ErrorTriggeringBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${AWS::AccountId}-${AWS::Region}-errortrigger

  S3ErroringLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.S3ErroringLambda::handler
      CodeUri: target/lambda.zip
      Events:
        S3Event:
          Type: S3
          Properties:
            Bucket: !Ref ErrorTriggeringBucket
            Events: s3:ObjectCreated:*
      Policies:LambdaInvokePolicy:
            FunctionName: !Ref ErrorProcessingLambda

  ErrorProcessingLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.ErrorProcessingLambda::handler
      CodeUri: target/lambda.zip

  S3ErroringLambdaInvokeConfig:
    Type: AWS::Lambda::EventInvokeConfig
    Properties:
      FunctionName: !Ref S3ErroringLambda
      Qualifier: "$LATEST"
      DestinationConfig:
        OnFailure:
          Destination: !GetAtt ErrorProcessingLambda.Arn

从这个示例中可以注意到几个方面:

  • 没有显式的队列或主题。

  • 最后,目标定义了当S3ErroringLambda失败时,我们希望将事件发送到ErrorProcessingLambda

  • 应用程序函数需要被授予调用错误处理函数的权限,我们通过S3Erroring Lambda资源的Policies属性启用此权限。

发送到ErrorProcessingLambda的事件与发送到 DLQ 的事件类型不同。在撰写本文时,aws-lambda-java-events库尚未更新以包含目标类型,并且由于发送对象中字段的不幸命名,反序列化这些类型非常棘手。希望到您阅读本书时,这些问题已得到解决!

目标可能会取代大多数 DLQ 的使用方式,我们还对看到如何使用目标的OnSuccess版本来构建有趣的解决方案感兴趣。

处理 Kinesis 和 DynamoDB 流错误

2019 年末,AWS 向 Kinesis 和 DynamoDB 流事件源添加了许多故障处理功能。这些新功能使得可以避免“毒丸”场景,其中单个不良记录可能会阻塞流(或分片)处理长达一周(取决于流保留记录的时间)。

故障处理功能可以通过 SAM(或 CloudFormation)进行配置,并且在 Lambda 函数无法处理来自 Kinesis 或 DynamoDB 流的记录批次时应用。新功能如下所示:

函数错误的二分法

这个功能不是简单地重试整个记录批次以用于失败的 Lambda 调用,而是将批次分成两部分。这些较小的批次将分别重试。这种方法可以自动将故障缩小到导致问题的任何个别记录,并且可以通过其他错误处理功能处理这些记录。

最大记录年龄

这指示 Lambda 函数跳过早于指定的最大记录年龄的记录(可以从 60 秒到 7 天)。

最大重试尝试次数

此功能将失败的批次重试可配置的次数,然后将有关批次记录的信息发送到配置的失败目标(列表中的下一个特性)。

失败时的目标

这是一个将接收有关失败批次信息的 SNS 主题或 SQS 队列。请注意,它不接收实际失败的记录——这些记录必须在它们过期之前从流中提取。

一个全面的错误处理方法可以(并且应该)结合所有这些特性。例如,一组失败的记录可以被分割(可能多次),直到有一个导致失败的单一记录批次。这个单一记录批次可能会重试 10 次,或者直到记录达到 15 分钟之后,此时批次的详细信息(包含其单个失败记录)将被发送到一个 SNS 主题。一个独立的 Lambda 可以订阅该 SNS 主题,自动从流中检索失败的记录,并将其存储在 S3 中以供后续调查。

使用 X-Ray 跟踪错误

如果您使用 AWS X-Ray(讨论见“分布式跟踪”),它将能够显示组件图中发生错误的位置。有关更多详细信息,请参阅“查找错误”和 X-Ray 文档。

错误处理策略

因此,考虑到我们现在对错误的所有了解,以及 Lambda 在处理它们时的能力和行为,我们应该如何选择处理错误?

对于未处理的错误,我们应该设置监控(参见“警报”),当错误发生时,我们可能需要某种形式的手动干预。这种紧急性取决于上下文,也取决于事件源的类型——请记住,在流/队列事件源的情况下,直到错误被清除之前,处理都会被阻塞。

对于处理过的错误,我们有一个有趣的选择。我们应该处理错误并重新抛出,还是捕获错误并清晰地退出函数?再次强调,这将取决于上下文和调用类型,但以下是一些思考。

对于同步事件源,您可能希望向原始调用者返回某种错误。通常情况下,您会希望在 Lambda 代码中明确地执行此操作,并返回格式良好的错误。然而,这里的一个问题是 Lambda 不知道这是否是一个错误,因此您需要手动跟踪此度量。让同步调用的 Lambda 中未处理的错误冒出的问题在于,您无法控制返回给上游客户端的错误。

对于异步事件源,您要做的事情很大程度上取决于您是否想要使用 DLQ 或目的地。如果是这样,那么让错误冒出或抛出自定义错误,然后在处理来自 DLQ/目的地的消息的过程中处理错误通常不会有什么坏处。如果不使用 DLQ/目的地,则在代码内发生错误时,您可能至少希望记录失败的输入事件。

对于 Kinesis 和 DynamoDB 流事件源,使用之前描述的某种故障处理功能允许在某些记录导致错误时继续处理。通过正确配置的失败时目的地,这是一种有效的错误处理策略,尽管这假定您的应用程序可以安全地处理可能无序的记录。如果不是这种情况,请考虑省略故障处理功能,并依赖平台的自动重试行为(在这种情况下,将阻塞处理直到错误解决或记录过期)。

对于 SQS,通常希望在代码内部处理错误,否则会阻塞后续处理。一个有效的方法是在处理函数中放置一个顶层的try-catch块。在这个块中,您可以设置自己的重试策略或记录失败事件并清晰地退出函数。在某些情况下,您确实希望阻止进一步的事件处理,直到导致错误的问题解决,此时您可以从顶层的 try-catch 块中抛出一个新错误,并使用平台的自动重试行为。

扩展

在第五章中,我们提到了 Lambda 最宝贵的一个方面之一——其能够在没有任何努力的情况下自动扩展(参见图 5-10)。在数据管道示例中,我们利用这种自动扩展能力实现了“扇出”模式——并行处理许多小事件。

这是 Lambda 扩展模型的关键——如果当前所有函数实例在收到新事件时都在使用中,则 Lambda 将自动创建一个新实例,扩展该函数,以处理新事件。

最终,在一段不活动时间之后,函数实例将被收回扩缩容函数。

从成本的角度来看,Lambda 保证我们仅在处理事件时收费,因此以串行方式处理一百个 Lambda 事件在一个函数实例中与在一百个实例中并行处理它们的成本相同(在冷启动中可能存在额外的时间成本,我们稍后在本章中描述)。

当然,Lambda 的扩展是有限制的,我们稍后会详细讨论,但首先让我们来看一下 Lambda 的神奇自动扩展。

观察 Lambda 的扩展

让我们从以下代码开始:

package book;

public class MyLambda {
  private static final String instanceID =
    java.util.UUID.randomUUID().toString();

  public String handler(String input) {
    return "This is function instance " + instanceID;
  }
}

函数处理程序类的静态和实例成员会每个函数实例实例化一次。我们稍后在冷启动部分进一步讨论这一点。因此,如果我们连续五次调用前面的代码,它将始终为 instanceID 成员返回相同的值。

现在让我们稍微修改一下代码,加入一个 sleep 语句:

package book;

public class MyLambda {
  private static final String instanceID =
    java.util.UUID.randomUUID().toString();

  public String handler(String input) throws Exception {
    Thread.sleep(5000);
    return "This is function instance " + instanceID;
  }
}

确保如果您部署此代码,请包括至少六秒的 Timeout 配置;否则,您将看到超时错误的一个很好的例子!

现在并行多次调用该函数。一种方法是在多个终端标签页中运行相同的 aws lambda invoke 命令。根据您在导航终端会话时的快速程度,您将看到不同的容器 ID 用于不同的调用。

之所以能够观察到这种行为,是因为当 Lambda 收到第二个请求来调用您的函数时,之前用于第一个请求的容器仍在处理该请求,因此 Lambda 会创建一个新实例来处理第二个请求,自动扩展容量。如果您的速度足够快,这种新实例的创建也会发生在第三和第四个请求上。

这是直接调用 Lambda 函数的一个示例,但当 Lambda 被大多数事件源(包括 API Gateway、S3 或 SNS)调用时,我们看到相同的扩展行为,即当一个 Lambda 函数实例不足以跟上事件负载时,神奇的自动扩展,毫不费力!

缩放限制和限速

AWS 并不是一个无限的计算机,Lambda 的扩展是有限制的。亚马逊限制每个 AWS 帐户、每个区域的所有函数的并发执行次数。在撰写本文时,默认情况下,此限制为一千次,但您可以提出支持请求以增加此限制。部分原因是因为生活在物质宇宙的物理限制,部分原因是为了避免您的 AWS 账单激增到天文数字!

如果达到此限制,您将开始经历限流,您将因账户级别的 Throttles CloudWatch 指标为 Lambda 函数突然显示大于零的数量而知道这一点。这使其成为设置 CloudWatch 警报的优秀指标(我们在 “指标” 中讨论了内置指标和警报)。

当您的函数被限流时,AWS 表现出的行为类似于函数抛出错误时的行为(我们在本章前面讨论过的“Lambda 错误处理的各种行为”——“Lambda 错误行为”)——换句话说,这取决于事件源的类型。总结:

  • 对于同步事件源(例如 API Gateway),Lambda 将将限流视为错误,并作为 HTTP 状态码 500 错误返回给调用者。

  • 对于异步事件源(例如 S3),Lambda 默认会在最多六个小时内重试调用您的 Lambda 函数。可以通过例如使用 AWS::Lambda::EventInvokeConfig CloudFormation 资源MaximumEventAgeInSeconds 属性进行配置,如我们在 “重试” 中介绍的那样。

  • 对于流/队列事件源(例如 Kinesis),Lambda 将阻塞并重试,直到成功或数据过期。

基于流的源还可能有其他缩放限制,例如基于流的分片数量和配置的 ParallelizationFactor

由于 Lambda 并发限制是账户级别的,特别需要注意的一个方面是,一个扩展特别广的 Lambda 函数可能会影响同一 AWS 账户+地区中的每个其他 Lambda 函数。因此,强烈建议至少在生产和测试中使用单独的 AWS 账户——由于负载测试针对分级环境而故意造成 DoS(拒绝服务)攻击您的生产应用程序是一种特别尴尬的情况,需要解释清楚!

但是除了生产与测试账户分离之外,我们还建议在 AWS “组织” 内使用不同的 AWS “子账户” 为生态系统中的不同 “服务” 进行隔离,以进一步避免账户范围限制的问题。

突发限制

提及的限制和限流是指您的 Lambda 函数可用的总容量。然而,偶尔还需注意另一个限制——突发限制。这指的是您的 Lambda 函数可以扩展的速度(而不是范围)。默认情况下,Lambda 可以每分钟最多扩展一个函数到 500 个实例,可能在开始时有一个小的增加。如果您的工作负载比这更快地爆发(我们见过一些能做到的),那么您需要注意突发限制,并可能考虑请求 AWS 增加您的突发限制。

保留并发限制

我们刚才提到过一个 Lambda 函数,它的扩展特别广,可能会通过使用所有可用的并发量来影响账户中的其他函数。Lambda 有一个工具可以帮助解决这个问题——可选的 保留并发量 配置,可以应用于函数的配置中。

设置一个保留的并发值会做两件事:

  • 它保证该特定函数将始终具有该可用并发量,而不管账户中的其他函数在做什么。

  • 它将该函数限制在不超过该并发量的范围内。

这个第二个特性有一些有用的好处,我们在 “解决方案:使用保留的并发管理扩展” 中讨论过。

如果你正在使用 SAM 来定义应用程序的基础设施,你可以使用 AWS::Serverless::Function 资源类型的 ReservedConcurrentExecutions 属性来声明一个保留的并发设置。

线程安全

由于 Lambda 的扩展模型,我们可以保证每个函数实例在任何时候最多只处理一个事件。换句话说,在函数的运行时,你永远不需要担心多个事件同时被处理,更不用说在函数对象实例内部了。因此,除非你自己创建了任何线程,Lambda 编程是完全线程安全的。

垂直扩展

Lambda 几乎所有的扩展能力都是“水平”的——即,它能够扩展以处理多个事件并行处理。这与“垂直”扩展相对应——即通过增加单个节点的计算能力来处理更多的负载。

Lambda 还有一个基本的垂直扩展选项,但是它是通过内存配置来实现的。我们在 “内存和 CPU” 中讨论过这个问题。

版本和别名,流量转移

在你迄今为止对 Lambda 进行的实验中,你可能偶尔会看到字符串“$LATEST”出现。这是对 Lambda 函数的 版本 的引用。不过,版本远不止于 $LATEST,所以让我们来看看吧。

Lambda 版本

每当我们部署了新的配置或新代码到我们的 Lambda 函数中,我们总是覆盖之前的内容。旧的函数已经过时,新函数永存。

然而,Lambda 支持保留这些旧函数,如果你愿意的话,这是通过 Lambda 函数版本控制这个功能来实现的。

如果不显式使用版本控制,Lambda 在任何时候都只有一个版本的函数。它的名称是“$LATEST”,你可以明确引用它;或者,如果你不指定版本(或别名,我们马上就会看到的),你也隐含地引用了“$LATEST”。

当你创建或更新一个函数时,可以在当时或之后某个时间点对该函数进行版本快照。版本的标识符是一个线性计数器,从 1 开始。你无法编辑一个版本,这意味着只有从当前的$LATEST版本创建版本化快照才有意义。

调用函数的一个版本时,可以通过将:VERSION-IDENTIFIER添加到其 ARN 中显式调用它,或者如果使用 AWS CLI,则可以在aws lambda invoke命令的--qualifier *VERSION-IDENTIFIER*参数中添加它。

可以使用各种 AWS CLI 命令或 Web 控制台创建版本。不能直接使用 SAM 显式创建版本,但在使用别名时可以隐式创建版本,我们接下来会解释这一点。

Lambda 别名

尽管可以显式引用 Lambda 函数的编号版本,但在使用版本时,更典型的是使用别名。别名是指向 Lambda 版本的命名指针——可以是$LATEST,也可以是一个数字化的快照版本。可以随时更新别名以指向不同的版本。例如,您可以从$LATEST开始,但随后指向特定版本以增加别名的稳定性。

您以与函数版本完全相同的方式调用函数的别名——通过在 ARN 中指定它或在 CLI 的--qualifier参数中指定它。可以配置事件源以指向特定的别名,并且如果基础别名更新以指向新版本,则来自源的事件将流向该新版本。

在使用 SAM 部署 Lambda 函数时,可以定义一个别名,该别名会自动更新以指向最新发布的版本。您可以通过添加AutoPublishAlias属性并提供别名名称作为值来实现这一点。

然而,使用 SAM 时有一种更强大的使用别名的方式。

流量转移

如果在 SAM 中使用 Lambda 函数的AutoPublishAlias属性,则来自事件源的所有事件将立即路由到函数的新版本。如果出现问题,您可以手动更新别名以指向前一个版本。

Lambda 和 SAM 还具有通过首先给予分流流量的机能来改善此流程的功能,将一些流量发送到新版本,一些流量发送到旧版本。这意味着如果发生问题,并且需要回滚,则并非所有流量都受到问题的影响。

第二个改进是,如果检测到错误,可以自动执行回滚,您可以定义如何以几种不同的方式计算错误。

有许多移动部件涉及使其工作—Lambda 别名、Lambda 别名更新策略以及使用AWS CodeDeploy服务。幸运的是,SAM 能很好地将所有这些包装起来,以便你不需要担心所有这些繁琐的细节。你主要需要做的是在 SAM 模板中的 Lambda 函数中添加一个DeploymentPreference属性,这在详细文档中有说明。

使用流量转移时需要做出的选择是如何将你的流量转移到新别名上。这可以分为四个选项:

一次性全部

虽然这乍一看可能与AutoPublishAlias相同,但实际上它更加强大,因为你有机会通过“钩子”自动回滚部署,我们稍后将描述。这是 Lambda 的蓝绿部署的完全自动化实现。

金丝雀

向新版本发送少量流量,如果有效,则发送剩余流量;否则,回滚。

线性

与金丝雀类似,但向新版本发送逐渐增加的流量百分比,仍允许回滚。

自定义

决定如何在旧别名和新别名之间分配流量由你自己决定。

正如我们之前提到的,此功能的一个强大元素是可以通过两种不同的机制实现自动回滚—钩子警报

钩子触发的回滚适用于任何之前的方案。你可以定义预流量钩子和/或后流量钩子。这些钩子只是其他 Lambda 函数,它们将运行它们需要的任何逻辑来决定部署是否成功—无论是在任何流量路由到新别名之前还是在所有流量转移后。

警报适用于提供逐渐流量转移的方案。你可以定义任意数量的CloudWatch 警报(我们在“警报”中讨论过),如果其中任何警报转换为警报状态,则将执行回滚到原始别名。

欲了解有关 Lambda 流量转移的更多详细信息,请参阅SAM 文档

何时(不)使用版本和别名

Lambda 的流量转移能力非常强大,如果你在 Lambda 代码的上游尚未使用金丝雀发布方案,那么它可能对你有所帮助。

然而,除了流量转移之外,我们尽量避免使用版本和别名。我们发现它们通常增加了不必要的复杂性,而我们更倾向于使用其他技术。例如,对于代码的开发和生产版本的分离,我们更喜欢使用不同的部署堆栈。对于“回滚”代码,我们更倾向于使用快速运行的部署管道,并在源代码库中进行回滚,通过管道触发新的提交。

注意

偶尔您会看到一些事件源使用并推荐使用 Lambda 别名。其中一个例子是将 Lambda 与AWS 应用负载均衡器(ALB)集成时。

如果您使用版本和别名,请注意除了之前提到的函数实例警告之外的一些“陷阱”:

  • 版本不会自动清理,因此定期删除旧版本很重要。否则,您可能会发现自己达到账户级别的“函数和层存储”限制,即 75GB。

  • 当您在使用 CloudWatch 指标时,请确保您明确指定要查看数据的版本或别名,因为 AWS Web 控制台中默认的 CloudWatch 指标视图在使用版本和别名时有点奇怪。

冷启动

现在我们来讨论冷启动这个棘手的问题。根据您与谁交流的不同,冷启动可能是 Lambda 开发者生活中的一个小注脚,也可能是阻止 Lambda 被视为有效计算平台的一个完全阻碍因素。我们发现如何最好地处理冷启动在这两个极端之间——值得深入理解和严谨对待,但在大多数情况下并非不可抗拒的因素。

但是冷启动是什么,何时发生,它们会产生什么影响,以及我们如何减轻它们的影响?关于冷启动有很多恐惧、不确定性和怀疑(FUD),我们希望在这里消除其中一些 FUD。让我们深入探讨。

什么是冷启动?

回顾第三章,我们探讨了当第一次调用 Lambda 函数时发生的活动链(图 3-1)——从启动主机 Linux 环境到调用我们的处理函数。在这两个活动之间,JVM 将被启动,Lambda Java 运行时将被启动,我们的代码将被加载,根据我们 Lambda 函数的具体特性,可能会发生更多其他活动。我们将这个链条总称为冷启动,它导致我们的 Lambda 函数的新实例(执行环境、运行时和我们的代码)可以处理事件。

这里一个重要的观点是,所有这些活动都发生在我们的 Lambda 函数被调用时,而不是之前。换句话说,Lambda 不仅在部署 Lambda 代码时创建函数实例,而是根据需要创建它们。

然而,冷启动是特殊事件,而不是每次调用都会发生的事情,因为通常 Lambda 不会为每个触发函数的事件执行冷启动。这是因为一旦我们的函数执行完毕,Lambda 可以冻结实例并保留一段时间,以防接下来会有另一个事件发生。如果很快又发生了一个事件,Lambda 将解冻实例并用事件调用它。对于许多 Lambda 函数来说,冷启动实际上不到 1%的时间发生,但了解它们发生的时机仍然很有用。

冷启动何时发生?

当没有现有的函数实例可用来处理事件时,冷启动是必要的。这种情况发生在以下时候:

  1. 当 Lambda 函数的代码或配置更改时(包括首次部署函数的第一个版本时)

  2. 当所有之前的实例因为不活跃而被销毁

  3. 当所有之前的实例因“老化”而被“清理”时

  4. 当 Lambda 需要扩展,因为所有当前函数的实例都在处理事件

让我们更详细地看看这四种发生情况。

  1. 当我们首次部署我们的函数时,Lambda 会创建一个我们函数的实例,正如我们已经见过的那样。然而,每当我们部署函数代码的新版本,或者更改函数的 Lambda 配置后,Lambda 也会创建一个新的实例当函数被调用。这样的配置不仅涵盖环境变量,还包括运行时方面,如超时设置,内存设置,DLQ 等。

    这个推论是 Lambda 函数的一个实例无论被调用多少次,保证都有相同的代码和配置。

  2. Lambda 会保留函数实例一段时间,以防会有“快”事件发生。关于“快”具体的定义没有文档说明,但可能在几分钟到几小时之间(并不一定是固定的)。换句话说,如果您的函数处理一个事件,然后一分钟后又发生了另一个事件,第二个事件很有可能使用同一个函数实例来处理第一个事件。然而,如果事件之间有一天或更长的时间间隔,您的函数很可能每次事件都会经历冷启动。过去,有些人使用“ping hack”来解决这个问题,并保持其函数“活跃”,但在 2019 年底,AWS 推出了预置并发(见“预置并发”)来解决这种问题。

  3. 即使您的 Lambda 事件非常活跃,亚马逊也不会永远保留实例,即使它们每隔几秒钟被使用。AWS 保留实例的时间在撰写本文时为五到六小时,之后将被销毁。

  4. 最后,如果函数的所有当前实例都在忙于处理事件并且 Lambda“扩展”,就像我们在本章前面描述的那样,冷启动将会发生。

识别冷启动

什么时候可以判断发生了冷启动呢?有很多种方法可以做到这一点,以下是其中一些。

首先,你会注意到延迟急剧增加。冷启动通常会使函数的延迟增加从 100 毫秒到 10 秒不等,具体取决于函数的组成。因此,如果你的函数通常需要的时间少于这个范围,冷启动在函数延迟指标中将很容易看到。

接下来,由于 Lambda 的日志记录方式,你将能够知道何时发生了冷启动。正如我们在“Lambda 和 CloudWatch Logs”中讨论的那样,当 Lambda 函数记录日志时,输出将被捕获在 CloudWatch Logs 中。一个函数的所有日志输出都在一个 CloudWatch Log group中可用,但是每个函数实例将写入日志 stream中的一个单独的流,位于日志组内。因此,如果你看到日志组中的日志流数量增加,那么你就知道发生了冷启动。

此外,你可以在代码中自行跟踪冷启动。由于包装处理程序的 Java 对象仅在实际函数运行时的每个实例中实例化一次,任何实例成员或静态成员初始化都将发生在冷启动时,并且在函数实例的生命周期内再也不会发生。因此,如果在代码中添加构造函数或静态初始化程序,它将仅在函数经历冷启动时调用。你可以在处理程序类构造函数中添加显式日志记录,以查看函数日志中发生的冷启动。或者,我们在本章前面看到了识别冷启动的示例。

你还可以使用 X-Ray 和一些第三方 Lambda 监控工具来识别冷启动。

冷启动的影响

到目前为止,我们已经描述了什么是冷启动,它们何时发生以及如何识别它们。但是,为什么你要关心冷启动呢?

正如我们在前一节中提到的,识别冷启动的一种方法是,当发生冷启动时,你通常会在事件处理中看到延迟急剧增加,这也是人们最关心的原因。虽然一个小型 Lambda 函数的端到端延迟在正常情况下可能为 50 毫秒,但是冷启动可能会增加至少200 毫秒到这个数量,而且根据各种因素,可能会增加秒数,甚至十几秒。冷启动增加延迟的原因是因为在创建函数实例期间需要进行的所有步骤。

这是否意味着我们总是需要关心冷启动呢?这在很大程度上取决于你的 Lambda 函数在做什么。

例如,假设您的函数是异步处理在 S3 中创建的对象,并且您对处理这些对象需要花费几分钟的时间并不在意。在这种情况下,您是否关心冷启动?可能不会。特别是当考虑到 S3 并没有保证事件的亚秒级交付时。

下面是另一个例子,您可能不会太在意冷启动:假设您有一个函数正在处理来自 Kinesis 的消息,每个事件处理大约需要 100 毫秒,通常总是有足够的数据使您的 Lambda 函数保持繁忙状态。在这种情况下,您的一个 Lambda 函数实例可能会处理 200,000 个事件,然后被“清除”。换句话说,在 Lambda 调用中,冷启动可能仅影响 0.0005%。即使冷启动使启动延迟增加了 10 秒,考虑到在实例的生命周期内对这段时间的摊销,很可能您在这种情况下会接受这样的影响。

另一方面,假设你正在构建一个 Web 应用程序,并且有一个特定的元素调用了一个 Lambda 函数,但该函数在 AWS 中每小时只被调用一次。这可能意味着每次调用函数时都会出现冷启动。进一步说,假设对于这个特定的函数,冷启动的开销是五秒钟。这会成为问题吗?可能会。如果是这样,这个开销能够减少吗?也许可以,在下一节我们将讨论这个问题。

尽管关于冷启动的关注几乎总是涉及延迟开销,但也要注意,如果您的函数在启动时从下游资源加载数据,那么每次发生冷启动时它都会这样做。在考虑 Lambda 函数对下游资源影响时,特别是在部署后所有实例都进行冷启动时,您可能需要考虑这一点。

缓解冷启动

Lambda 总是会发生冷启动,除非我们使用预置并发(在下一节中描述),这样的冷启动将会时不时地影响我们函数的性能。如果冷启动给您带来问题,那么有各种技术可以减少它们的影响。但请确保它们确实给您带来问题—就像其他形式的性能优化一样,您希望只在真正需要时才进行这项工作。

减少构件大小

在减少冷启动影响中,最有效的工具通常是减少我们代码构件的大小。我们可以通过两种主要方式实现这一点:

  • 减少我们自己代码在构件中的量,只保留 Lambda 函数所需的部分(其中“量”指的是大小和类的数量)。

  • 精简依赖项,使得构件中仅存储 Lambda 函数所需的库。

这里还有几种后续技术。首先,为每个 Lambda 函数创建一个不同的构件,并为每个构件执行任务。这是我们在第五章中所做的努力的目的,当时我们创建了多模块 Maven 项目。

其次,如果你想进一步优化库的依赖关系,那么考虑将依赖的库拆分为仅包含你需要的代码。甚至可以在你自己的代码中重新实现库的功能。显然,这需要一些正确和安全地完成的工作,但对你来说可能是一个有用的技术。

这些技术通过两种方式减少了冷启动的问题。首先,启动运行时之前需要复制和解压的文件更少。但更重要的是,运行时需要加载和初始化的代码更少。

所有这些技术在现代服务器端软件开发中都有些不同寻常。我们习惯于可以任意向项目中添加依赖项,创建多百兆字节的部署文件,而 Maven 或 NPM 则“下载互联网”。在传统的服务器端开发中,这通常足够了,因为磁盘空间便宜,网络快速,最重要的是,我们不太关心服务器的启动时间,至少不会在这里或那里几秒钟的顺序上。

但是对于函数即服务(FaaS),尤其是 Lambda,我们对启动时间的关注程度要高得多,因此我们需要更审慎地构建和打包我们的软件。

为了在 JVM 项目中减少依赖关系,你可能希望考虑使用Apache Maven 依赖插件,它将报告项目中依赖项的使用情况,或者类似的工具。

使用更高效的加载速度的打包格式

正如我们在第四章中所提到的,AWS 建议使用 ZIP 文件方法打包 Lambda 函数,而不是使用 uberjar 方法,因为这样可以减少 Lambda 解压部署文件所需的时间。

减少启动逻辑

在本章后面,我们将讨论 Lambda 函数中的状态问题。不管你之前听到了什么,Lambda 函数并不是无状态的;只是在思考状态时有一个不同寻常的模型。

Lambda 函数中一个非常常见的做法是在首次调用函数时创建或加载各种资源。在第五章的示例中,我们在一定程度上看到了这一点,当时我们初始化了序列化库和 SDK。然而,对于某些函数来说,理解这一思想并创建一个大型的本地缓存,从其他资源加载,以更快地处理实例生命周期中的事件是有意义的。

这样的启动逻辑并非免费,会增加冷启动时间。如果你在冷启动时加载初始资源,你可能会发现在改善后续调用性能与初始调用时间之间需要做出权衡。如果可能的话,你可能希望考虑是否可以逐渐在一系列初始调用中“预热”函数的本地缓存。

警告

缓慢启动的一个主要原因是使用像 Spring 这样的应用框架。正如我们稍后讨论的(见 “Lambda 和 Java 应用框架”),我们强烈反对在 Lambda 中使用这样的框架。如果冷启动给你造成了问题,并且你正在使用应用框架,那么我们建议你首先调查是否可以从 Lambda 函数中移除该框架。

语言选择

另一个可能影响冷启动时间的领域是语言运行时的选择。JavaScript、Python 和 Go 启动所需的时间比 JVM 或 .NET 运行时少。因此,如果你编写的是不经常调用的小函数,并且你关心尽可能减少冷启动影响,你可能会希望在其他开发方面相等的情况下选择 JavaScript、Python 或 Go 而不是 Java。

由于启动时间的差异,我们经常听到人们在一般情况下将 JVM 和 .NET 运行时作为 Lambda 运行时而忽略,但这是一种短视的观点。例如,在我们早些时候描述的 Kinesis 处理函数的情况中,如果平均情况下 JVM 函数处理一个事件需要 80 毫秒,而 JavaScript 等效函数需要 120 毫秒呢?在这种情况下,你的代码的 JavaScript 版本运行成本将是 JVM 版本的两倍(因为计费 Lambda 时间会向上取整到下一个 100 毫秒)。在这种情况下,JavaScript 可能不是运行时的正确选择。

完全可以在 Lambda 中使用替代(非 Java)JVM 语言(我们将在本章末尾进一步讨论)。但要记住的一点是,通常这些语言都带有自己的“语言运行时”和库,这两者都会增加冷启动时间。

最后,在选择语言这个话题上,当涉及到语言对冷启动或事件处理性能的影响时,保持一些视角是值得的。在语言选择中,最重要的因素是你如何有效地构建和维护你的代码——软件开发中的人为因素。与 Lambda 语言运行时之间的运行时性能差异相比,成本可能微不足道。

内存和 CPU

函数配置的某些方面也会影响冷启动时间。其中一个主要例子是你选择的 MemorySize 设置。更大的内存设置也会提供更多的 CPU 资源,因此较大的内存设置可能会加快 JVM 代码的 JIT 编译时间。

注意

直到 2019 年底,Lambda 函数的另一个配置设置可能会显著增加冷启动时间,即是否使用虚拟私有云(VPC)。 我们稍后在本章中详细讨论 VPC,但目前您需要知道的是,如果您在任何地方看到有关因 VPC 导致 Lambda 启动时间恶化的警告文档,请放心,此问题现在已经解决。 有关 AWS 改进此问题的更多详细信息,请参见此文章

预配并发

2019 年底,AWS 宣布了一项新的 Lambda 功能——预配并发。 预配并发(PC)允许工程师有效地“预热”Lambda 函数,从而消除(几乎)所有冷启动的影响。 在我们描述如何使用此功能之前,请注意以下一些重要的警告:

  • PC 会破坏 Lambda 的基于请求的成本模型。 使用 PC,您无论是否调用函数都需要付费。 因此,使用带有 PC 的 Lambda 抵消了无服务器的主要好处之一:成本可以缩减到零(请参阅“Lambda 实现的 FaaS”)。

  • 为了避免支付与峰值使用相关的成本,您需要手动配置带有 PC 的 AWS 自动缩放(请参阅此 AWS 博客文章以了解如何实现此操作)。 这会增加您的额外运维工作量。

  • PC 会增加显著的部署时间开销。 在我们的实验中,在撰写本文时,部署具有设置为 1 的 PC 的 Lambda 函数的开销约为四分钟。 使用设置为 10 或 100 的情况约为七分钟。

  • PC 需要使用版本或别名,我们在本章早些时候描述了它们(请参阅“版本和别名,流量转移”)。 如我们在该部分中提到的,我们不建议在大多数情况下使用版本或别名,因为它们带来了额外的复杂性。

警告

鉴于这些重大注意事项,我们的建议是,只有在您绝对需要时才使用预配并发。 正如我们在本节摘要中提到的,我们发现,大多数最初关注冷启动的团队在开始在生产中大规模使用 Lambda 后,发现冷启动实际上没有什么效果,特别是如果团队遵循本章关于冷启动缓解的其他建议。

现在,我们告诉您为什么几乎肯定不应该使用预配并发,让我们谈谈它是什么!

PC,最简单地说,是一个数值(n),告诉 Lambda 平台始终保持至少 n 个函数执行环境处于“热”状态。 这里的“热”意味着执行环境已创建,并且已实例化您的 Lambda 函数处理程序代码。 事实上,在预热期间执行了整个执行链(请参阅图 3-1),除了实际调用处理程序方法。

由于在 PC 环境下,Lambda 不会调用未预热的函数(除了我们稍后描述的一个关于扩展的细节),这确保了您不会有任何性能影响的冷启动!换句话说,所有函数调用都将在其常规的“预热”时间内响应。

PC 的另一个好处是,它仅在部署配置中定义——您不需要更改代码即可使用它(尽管您可能想要更改代码,我们将在稍后描述关于代码实例化的内容)。

让我们来看一个例子。假设我们在 SAM 模板中配置了以下函数:

HelloWorldLambda:
Type: AWS::Serverless::Function
Properties:
  Runtime: java8
  MemorySize: 512
  Handler: book.HelloWorld::handler
  CodeUri: target/lambda.zip
  AutoPublishAlias: live
  ProvisionedConcurrencyConfig:
    ProvisionedConcurrentExecutions: 1

新增的内容在这里是最后三行。首先,您会看到我们正在使用别名——PC 要求为每个版本或别名配置ProvisionedConcurrentExecutions值。我们不能为$LATEST——默认版本配置ProvisionedConcurrentExecutions值。

在这个例子中,我们还指定要始终有一个实例的 Lambda 函数预热。

当我们首次部署此函数时,Lambda 将实例化 Java 类HelloWorld,其中包含我们的处理程序,甚至在发生任何调用之前。然后,当接收到函数的事件时,Lambda 将调用这个预热的函数。当我们重新部署函数时,Lambda 将继续将请求路由到旧版本(预热),并且只有在为该版本创建的所有预置实例之后才开始使用新版本。再次强调,这确保了函数调用不受冷启动的影响。

提示

在其他第三方 Lambda 文档中,您可能会看到建议使用次要的定时“ping”函数来调用应用程序函数,以避免冷启动。PC,在设置为 1 的情况下,几乎在任何情况下都是这种机制的更有效替代品。

现在,让我们讨论您应该注意的一些细节。

首先是定价。正如提到的,在写作时,PC 与常规的“按需”Lambda 有着不同的成本模型。如“Lambda 的成本有多高?”中所述,按需 Lambda 的成本基于您的 Lambda 函数接收了多少请求以及 Lambda 函数执行的时间(持续时间)。对于 PC,您仍需支付请求成本,以及一个(较小的)持续时间成本,但您还需为函数部署期间的整个时间支付费用,而不仅仅是处理请求时。

让我们继续探讨“Lambda 的成本有多高?”中的内容,特别是针对 Web API 的示例。我们仅使用按需 Lambda 的成本估算为每月$21.60。使用预置并发的成本是多少呢?

同样,我们将假设 512 MB RAM,少于 100 ms 来处理请求和 864,000 次/天的情况。让我们从使用 PC 值为 10 开始,因为这是我们预计的峰值。在这种情况下,我们的 Lambda 成本如下:

  • 请求成本每月保持不变为$5.18。

  • 持续成本为 0.1 × 864000 × 0.5 × $0.000009722 = $0.42/天,或$12.60/月。

  • 预置并发成本为 10 × 0.000004167 × 0.5 × 86400 = $1.80/天,或$54/月。

因此,总成本已经从每月约$22 增加到每月$72 的三倍多。哎呀!

现在,这很可能是一个“最坏的情况”,因为我们将 PC 设置为峰值。我们的一个选择是为 PC 手动配置自动扩展。这在AWS 博客介绍 PC中有描述。假设这样做意味着我们的 PC 配置平均约为 2。在这种情况下,我们的总成本为每月$29。这仍然比按需贵 30%,而且现在我们还增加了管理 PC 自动扩展的复杂性。

在某些场景中,如果您有非常一致的使用模式,那么按需使用可能比按需使用更便宜,但在大多数情况下,您应该期望支付显著的额外开销以使用按需。

与成本相关的另一个问题是,您可能希望针对开发和生产使用不同的配置,以避免为开发环境支付“全天候”成本。您可以使用 CloudFormation 技术来实现这一点,但这会增加额外的心理负担。

关于成本的讨论就到此为止。让我们转移到另一个主题!

如果在某个时间点您的 PC 配置有更多的调用次数,会发生什么情况?正如我们在本章前面所讨论的,Lambda 始终会增加活动执行环境的数量以满足负载。例如,假设 Lambda 需要为您的函数使用第 11 个执行环境,但您的 PC 设置为 10——现在会发生什么?在这种情况下,Lambda 将以“传统”的按需模式为额外负载启动新的执行环境。您将按通常的按需方式收取此额外容量的费用,但请注意——使用该新额外环境的第一个事件也会以正常方式产生冷启动延迟!

最后,快速注意一下如何充分利用按需计算。在过去几年中,AWS 在减少平台冷启动开销方面表现出色,因此按需计算的主要目的大多是缓解应用程序的开销——即实例化语言运行时、代码和处理程序类所需的时间。最后一个元素——类实例化——非常重要,因为在预热期间会调用您的处理程序类构造函数。因此,您应该尽可能将应用程序设置移至类和对象实例化时间,而不是在处理程序方法本身中执行此操作。我们在本书中始终使用此模式,但如果您使用按需计算,则尤为重要。

鉴于我们对使用按需计算的所有严重警告,我们什么时候建议使用它呢?以下是我们可以想象使用按需计算的几种情况:

  • 当您的 Lambda 函数调用非常不频繁(例如每小时一次或更长时间),而且您希望快速返回(亚秒级),并且愿意承担成本开销时。

  • 如果你的应用程序具有极端的“突发”规模场景(请参阅“突发限制”),Lambda 无法默认处理,则可以预热足够的容量。

  • 如果你的函数本身在代码级别有显着的冷启动时间(例如,几秒钟),这对于应用性能来说是不够的,并且你没有其他方法来缓解这种情况。如果你在 Lambda 代码中使用了一个庞大的应用程序框架,这种情况很典型。

冷启动摘要

冷启动可能不是你需要花太多精力的事情,这取决于你如何使用 Lambda,但这绝对是一个你应该了解的话题,因为冷启动是如何被缓解的通常与我们通常构建和打包系统的方式相反。

我们之前提到过关于冷启动的FUD,而冷启动也经常因实际上与冷启动无关的延迟问题而被“抛弃”。如果你担心延迟,请执行适当的延迟分析——确保你的实际问题不是,例如,你的代码如何与下游系统交互。

还要确保随着时间的推移继续测试延迟,特别是如果你因为冷启动而排除了 Lambda 的某种用法。AWS 在 Lambda 平台的这一部分已经做出了,并且正在继续做出重大改进。

根据我们的经验,当团队第一次使用 Lambda 时,冷启动会引起他们的关注,特别是在开发负载波动较大时,但一旦他们看到 Lambda 在生产负载下的表现,他们通常再也不会担心冷启动了。

状态

几乎任何应用程序都需要考虑状态。这种状态可能是持久的——换句话说,它捕获了需要满足后续请求的数据。或者,它可以是缓存状态——数据的副本,用于提高性能,持久化版本存储在其他位置。

尽管它偶尔会被认为是无状态的,Lambda 实际上不是无状态——数据可以在请求期间和跨请求期间存储在内存和磁盘上。

内存中的状态通过处理程序方法的对象和类成员可用——加载到这些成员中的任何数据在下次调用该函数实例时都可用,而且 Lambda 函数可以最多有 3GB RAM(其中一部分将被 Lambda 运行时使用)。

Lambda 函数实例还可以访问*/tmp*中的 512MB 本地磁盘存储。虽然这种状态不会自动在函数实例之间共享,但它将在同一函数实例的后续调用中再次可用。

然而,Lambda 的运行时模型的性质显著影响了这种状态如何被使用。

持久应用程序状态

Lambda 创建函数实例的方式,特别是它的扩展方式,对架构有重要影响。例如,我们绝对不能保证同一上游客户端的连续请求将由同一函数实例处理。Lambda 函数没有“客户端亲和性”。

这意味着我们不能假设在 Lambda 函数中一个请求中本地可用的任何状态(内存中或本地磁盘上)将在后续请求中可用。无论我们的函数是否扩展,这都是真实的——扩展只是强调这一点。

因此,我们想要在 Lambda 函数调用之间保留的所有持久应用程序状态都必须是外部化的。换句话说,这意味着我们想要在个别调用之外保留的任何状态都必须要么存储在我们的 Lambda 函数下游——在数据库、外部文件存储或其他下游服务中——要么在同步调用函数的情况下返回给调用者。

这听起来可能是一个巨大的限制,但事实上,这种构建服务器端软件的方式并不新鲜。多年来,许多人一直在宣扬12 因素架构的优点,这种将状态外部化的方式体现在该范例的第六因素中。

话虽如此,这绝对是 Lambda 的一个限制,并且可能需要您对要迁移到 Lambda 的现有应用程序进行重大重新架构。这也可能意味着一些需要对状态进行特别低延迟访问的应用程序(例如,游戏服务器)不适合 Lambda,也不适合需要大量数据集在内存中以达到足够性能的应用程序。

人们常用的一些服务用于外部化他们与 Lambda 的应用程序状态:

DynamoDB

DynamoDB 是 AWS 的 NoSQL 数据库。我们在“示例:构建无服务器 API”中的 API 示例中使用了 DynamoDB。DynamoDB 的好处是它快速、操作和配置相对容易,并且具有非常相似的扩展属性。DynamoDB 的主要缺点是建模数据可能会变得棘手。

RDS

AWS 有各种关系型数据库,它们都被分组到关系/SQL 数据库服务(RDS)家族中,并且所有这些数据库都可以从 Lambda 中使用。在这个家族中相对新的一个选项是Aurora Serverless——Amazon 自己的Aurora MySQL 和 Postgres 引擎的自动扩展版本,专为无服务器应用程序而设计。使用 SQL 数据库而不是 NoSQL 数据库的好处是几十年来构建这种应用程序的经验。相对于 DynamoDB,缺点通常是更高的延迟和更多的操作开销(非无服务器 RDS)。

S3

简单存储服务(S3)——我们在本书中多次使用过——可以用作 Lambda 的数据存储。它易于使用,但在与某些数据库服务相比,查询能力有限,而且延迟并不低,除非您还使用 Amazon Athena

ElastiCache

AWS 作为其 ElastiCache 家族的一部分提供了 Redis 持久缓存应用的托管版本。在这四个选项中,ElastiCache 通常提供最快的性能,但由于它不是真正的无服务器服务,因此需要一些操作开销。

自定义下游服务

或者,您可以选择在下游服务中实现自己的内存持久化,采用传统设计。

AWS 在这一领域继续进行有趣的发展,我们建议您在选择持久化解决方案时调查所有最近宣布的进展。

缓存

尽管我们不能依赖 Lambda 的状态能力来实现持久的应用程序状态,但我们绝对可以将其用于缓存数据,这些数据也可以存储在其他位置。换句话说,虽然我们无法保证一个 Lambda 函数实例将被多次调用,但根据调用频率,我们确实知道它可能会。因此,缓存状态是 Lambda 本地存储的候选项。

我们可以使用 Lambda 的内存或磁盘位置来缓存数据。例如,假设我们始终需要一组相当及时的参考数据来处理事件,但“相当及时”的定义是“在最后一天内有效”。在这种情况下,我们可以在函数实例的第一次调用时加载参考数据,然后将该数据存储在本地的静态或实例成员变量中。请记住,我们的处理函数实例对象将仅在运行时环境中实例化一次。

另一个例子,假设我们希望在执行过程中调用外部程序或库 —— Lambda 为我们提供了一个完整的 Linux 环境来执行此操作。该程序/库可能太大,无法适应 Lambda 代码存储库(未压缩时最多限制为 250MB)甚至 Lambda 层(请参见本章稍后有关层的部分)。因此,我们可以在函数实例首次需要它时,将外部代码从 S3 复制到 /tmp,然后对于后续对该实例的请求,代码将已经在本地可用。

这两个示例都涉及由数据块组成的状态——应用程序数据或库和可执行文件。我们 Lambda 应用程序中的另一种形式的状态是代码本身的运行时结构,包括表示与外部服务连接的结构。这些运行时结构在函数调用时可能需要一定时间来创建,在服务连接的情况下可能需要时间来初始化,例如身份验证程序。在 Lambda 中,我们经常会将这些结构存储在比方法调用本身生命周期更长的程序元素中——在 Java 中,这意味着将它们存储在实例或静态成员中。

我们在本书的早些时候展示了这些例子。例如,在第五章的示例 5-3 中,我们将以下内容存储在实例成员中:

  • ObjectMapper实例,因为这是一个需要一定时间来实例化的程序结构

  • DynamoDB 客户端,它是连接到外部 DynamoDB 服务的连接

虽然我们通常出于性能原因在某些情况下使用这种形式的对象缓存,但它也可以显著提高我们整个系统的成本效益——详见“Lambda 运行模型及对下游系统成本影响”了解更多详情。

有时 Lambda 自身的状态能力是不足的——例如,我们的总缓存状态可能太大而无法放入内存,加载速度在冷启动期间太慢,或者需要频繁更新(在 Lambda 函数中更新本地缓存版本是一个棘手的事情,虽然可以做到)。在这种情况下,您可以选择使用前一节中提到的持久化服务作为缓存解决方案。

Lambda 和 Java 应用程序框架

注意

到目前为止,本书大部分指导都是关于如何使用 AWS Lambda,途中也有一些警告。现在我们将稍作偏离,谈谈一些不建议做的事情。

在过去的二十年中,使用某种容器和/或框架构建服务器端 Java 应用程序非常普遍。早在 2000 年代初,“Java 企业版”(J2EE)非常流行,像 WebLogic、WebSphere 和 JBoss 这样的应用服务器允许您使用 Enterprise JavaBeans(EJB)或 Servlet 框架构建应用程序。如果您那时不在,我们可以从个人经验向您保证,这并不是一件有趣的事情。

人们意识到这些大型服务器通常难以控制和/或昂贵,因此它们在很大程度上被更“轻量级”的替代品所取代,其中 Spring 是最常见的。当然,Spring 本身也在发展中演变为 Spring Boot,人们还使用各种 Java Web 框架来构建应用程序。

因为我们行业中有很多关于如何使用这些工具构建“Java 应用程序”的机构知识,因此有很大的诱惑继续使用它们,并将运行时从运行中的进程移植到 Lambda 函数中。AWS 甚至投入了大量精力支持正是这种思维方式,通过 无服务器 Java 容器 项目。

尽管我们钦佩 AWS 以这种方式“接人待物”的愿望,但我们强烈不建议在使用 Lambda 构建应用程序时使用大多数 Java 框架,原因如下。

首先,在单个 Lambda 函数中构建完整的应用程序违背了 Lambda 的基本理念。Lambda 函数应该是小型、独立、短暂的函数,是事件驱动的,并且被设计为接受特定的输入事件。“Java 应用程序”,相反,实际上是具有生命周期和状态的服务器,通常设计用于处理多种类型的请求。如果你在构建迷你服务器,那就不是在考虑无服务器的方式了。

其次,大多数应用服务器假设从一个请求到另一个请求存在一定的共享状态。虽然可以不按这种方式工作,但在这些环境中这并不是一种自然的工作方式。

我们认为这是一个坏主意的另一个原因是,它削弱了其他 AWS 无服务器服务提供的价值。例如,在前面提到的 AWS 项目中,使用了 API Gateway,但是在“全代理”模式下。这里有一个来自 Spring Boot 示例 的 SAM 模板片段:

Resources:
  PetStoreFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /{proxy+}
            Method: any

以这种方式使用 API Gateway 意味着所有请求,无论路径如何,都将发送到一个 Lambda 函数,并且需要在 Lambda 函数中实现路由行为。虽然 Spring Boot 可以做到这一点,(a) API Gateway 将免费提供这个功能,而且 (b) 将它保留在 Lambda 函数中会使你的 Java 代码变得混乱。

本书前面我们提到,总体上我们对使用过多 API Gateway 功能持谨慎态度;例如,参见 “API Gateway 代理事件” 中关于请求和响应映射的讨论。然而,我们认为去除路由通常是在抽象出 API Gateway 使用过程中走得太远的一步。

正如我们在冷启动部分讨论过的那样,应用程序框架通常会减慢函数的初始化速度。虽然有些人可能会认为这是使用预置并发的好理由,但我们认为这只是一个权宜之计,而不是解决方案。

最后,基于容器和框架的应用程序往往具有大型的可分发构件——部分原因是因为依赖的库的数量,部分原因又是因为这类应用程序通常实现了许多功能。在整本书中,我们一直在试图通过最小化依赖关系,并将应用程序划分为多个可分发元素,以保持我们的 Lambda 函数干净而精简。使用应用程序框架与此思维方式背道而驰。

总而言之,以这种方式构建 Java Lambda 应用程序实际上是一个“方枘圆凿的问题”。是的,你可以让它工作,但这样做效率低下,并且如果你以这种方式工作,你将无法获得 Lambda 的所有好处。有一种真正的危险,即在 Lambda 的价值上达到“局部最大值”,并假设没有进一步的好处。

因此,如果我们不推荐使用这些框架,我们建议您如何使用您辛苦获得的知识和技能呢?

通常,我们发现程序员切换到“纯”Lambda 开发并不需要太长时间来摆脱他们过去习惯于的框架。只编写处理程序函数会带来一种“轻盈感”。此外,将旧的 Java 代码带到项目中并没有什么问题,只要它没有太多依赖于应用程序框架。如果您可以将您的领域逻辑提取为仅表达您业务需求的内容,那么您就走在了正确的道路上。

同样,使用“依赖注入”(DI)的理念仍然可以,这通常由框架提供。您可以选择“手工制作”这种 DI(我们的偏好),就像您在一些示例中看到的那样(请参见“添加构造函数”)。或者,您可以尝试使用框架仅提供依赖注入,而不使用它们通常附带的其他功能。

虚拟专用云

到目前为止,在我们的所有示例中,由 Lambda 函数调用的任何外部资源都是通过 HTTPS/“第 7 层”认证进行保护的。例如,当我们在示例 5-3 中的无服务器 API 示例中调用 DynamoDB 时,该连接仅通过从我们的 Lambda 函数传递给 DynamoDB 的凭据进行保护。

换句话说,DynamoDB 不是一个“防火墙”服务——它对互联网开放,并且任何其他地方的互联网上的任何机器都可以连接到它。

虽然这个“无防火墙”的新世界正在加速发展,但仍然有许多情况下,Lambda 函数将需要连接到一个被某种 IP 地址限制保护的资源。AWS 中完成这种操作的常见方法是使用 VPC。

VPC 比我们在本书中迄今讨论的任何其他内容都要低级。它们需要了解诸如 IP 地址、弹性网络接口(ENIs)、CIDR 块和安全组之类的东西,还向我们展示了 AWS 区域由多个 AZ 组成的事实。换句话说,“此处有龙!”

Lambda 函数可以配置为能够访问 VPC。Lambda 函数需要这样做的三个典型原因是:

  • 要能够访问 RDS SQL 数据库(参见 图 8-2)

  • 要能够访问 ElastiCache

  • 要能够使用基于 IP/VPC 的安全性调用在容器集群上运行的内部微服务

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0802.png

图 8-2. 连接到 VPC 以访问 RDS 数据库的 Lambda

只有当 Lambda 实际需要时,才应配置 Lambda 使用 VPC。添加 VPC 不是“免费”的 —— 它会影响其他系统,改变 Lambda 与其他服务交互的行为方式,并给您的配置和架构增加复杂性。

此外,我们建议仅在以下情况下配置 Lambda 使用 VPC:(a) 您理解 VPC 并了解这样做的影响,或者 (b) 您已与组织中了解此要求的其他团队讨论过。

在本节的其余部分中,我们假设您对 VPC 有一个广泛的理解,但不一定了解 Lambda 和 VPC 的任何具体信息。因此,有一些 VPC 术语,如 ENIs 和安全组,我们会提及但不会解释。

使用 VPC 的 Lambda 的架构上的注意事项

即使在启用 Lambda 使用 VPC 之前,还有一些事项需要注意,这可能会改变您的想法!

首先,在您的 VPC 配置中指定的每个 子网 都是特定于一个 AZ 的。Lambda 的一个好处是,到目前为止,我们完全忽略了 AZ。如果您正在使用 Lambda + VPC,您需要确保配置足够多的子网,涵盖足够多的 AZ,以便您继续拥有所需的高可用性(HA)水平。

其次,当配置 Lambda 函数使用 VPC 时,那么 所有 来自该 Lambda 的网络流量都将通过 VPC 路由。这意味着,如果您的 Lambda 函数正在使用非-VPC AWS 资源(如 S3)或正在使用 AWS 外部 的资源,则您需要考虑这些资源的网络路由,就像您对 VPC 内的任何其他服务一样。例如,对于 S3,您可能需要设置一个 VPC 终端节点,而对于外部服务,则需要确保您的 NAT 网关配置正确。

配置 Lambda 使用 VPC

您已经阅读了所有警告,并确定了要使用的子网和安全组。现在,您如何实际配置 Lambda 来使用 VPC?

幸运的是,SAM 来帮忙了,而且使这变得相当简单。通过查看 AWS 提供的 示例(稍作裁剪),我们可以看到您需要对每个 Lambda 函数进行的更改:

AWSTemplateFormatVersion : '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Parameters:
  SecurityGroupIds:
    Type: List<AWS::EC2::SecurityGroup::Id>
    Description: Security Group IDs that Lambda will use
  VpcSubnetIds:
    Type: List<AWS::EC2::Subnet::Id>
    Description: VPC Subnet IDs that Lambda will use (min 2 for HA)

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      Policies:VPCAccessPolicy: {}
      VpcConfig:
        SecurityGroupIds: !Ref SecurityGroupIds
        SubnetIds: !Ref VpcSubnetIds

总之,您需要:

  • 为 Lambda 函数添加权限以附加到 VPC(例如通过使用VPC AccessPolicy

  • 添加 VPC 配置,包括安全组 ID 列表和子网 ID

就是这样了!这个特定示例假设你将使用CloudFormation 参数在部署时传递实际的安全组和子网 ID,但你也可以随意在模板中硬编码它们。

替代方案

如果我们所有的严重警告都足以让你不再使用带有 Lambda 的 VPC,那么你应该做什么?以下是几种方法。

第一种方法是使用不需要 VPC 的大致等效服务。例如,如果你打算使用 VPC 来访问 RDS 数据库,考虑改用 DynamoDB(尽管我们承认 DynamoDB 不是关系型数据库!)。或者考虑使用 Aurora 无服务器和其Data API

接下来是重新架构你的解决方案。例如,是否可以使用消息总线作为中介,而不是直接调用下游资源?

第三个——如果你需要连接的是一个内部服务,那么考虑给该内部服务添加一个“第 7 层”认证边界。一种方法是向内部服务添加一个 API Gateway(或者如果它已经有一个,更新现有的 API Gateway),然后使用 API Gateway 的IAM/Sigv4 认证方案

最后,如果你无法修改你的服务,你可以做类似于前面的想法,但在这种情况下使用API Gateway 作为代理到你的下游服务。

当然,还有一个选择——等待并看看 AWS 接下来会推出什么!例如,我们提到的无服务器 Aurora 的数据 API 是相对较新的,这表明可能会推出更多功能,帮助 Lambda 开发者避免 VPC 的危险!

层和运行时

如果你在 AWS Web 控制台中查看 Lambda 函数之一,现在你几乎知道那里的每一样东西都是用来做什么的了。角色、环境变量、内存、VPCs、DLQs、保留并发等等。然而,对于你们中观察力敏锐的人来说,你会看到页面顶部有一些到目前为止遗漏的内容:。为了结束本章,我们将解释层是什么,为什么你(作为 Java 开发者)可能不会太在意它们,以及它们与另一种称为自定义运行时的能力有什么关系。

什么是层?

正如你现在所知,通常情况下,当你部署一个 Lambda 函数的新版本时,你会将代码及其所有依赖项打包成一个 ZIP 文件,并上传到 Lambda 服务。然而,随着依赖项的增加,这个构件变得越来越大,部署速度变慢。能不能有一个方法可以加快这个过程呢?

这就是 Lambda 层的用武之地。层是您 Lambda 函数的部署资源的一部分,与函数本身分开部署。如果您的层保持不变,那么当您部署 Lambda 函数时,您只需部署不在层内的代码更改。

这里有一个例子。假设您正在实现来自第一章(“文件处理”](ch01.html#file-processing-example)的照片处理示例,假设您 Lambda 函数实际执行图像处理的部分使用像ImageMagick这样的第三方工具。

现在,ImageMagick 可能是一个很少更改的依赖项。使用 Lambda 层,您可以定义一个层(它只是一个包含任何所需内容的 ZIP 文件),其中包含 ImageMagick 工具,然后在照片处理 Lambda 中引用该层。现在,当您更新 Lambda 函数时,您只需上传自己的代码,而不是同时上传 ImageMagick 和代码。

提示

ImageMagick 通常通过从应用程序调用外部进程而不是通过库 API 调用来使用。从 Lambda 函数内部调用外部进程是完全可以的——Lambda 运行时是一个完整的 Linux 环境。

层的另一个有用方面是,您可以在 Lambda 函数之间以及其他 AWS 账户之间共享层 —— 实际上,层可以公开共享。

何时使用层,何时不使用层

当层被宣布时,Lambda 使用世界的某些部分非常兴奋,因为他们认为层是 Lambda 函数的一种通用依赖系统。对于使用 Python 语言的人来说尤为如此,因为 Python 的依赖管理工具对某些人(例如,您的作者!)来说可能有点棘手。然而,尽管存在某些缺陷,Java 生态系统在依赖管理方面有着非常强大的表现能力。

我们认为有些特定情况下层非常有用。然而,我们对全面采用它们也有一些顾虑,例如:

  • 由于层是在上传函数后与 Lambda 函数结合的,因此在测试时使用的依赖版本与部署版本可能不同。对我们来说,这是一种(通常是)不必要的协调头疼问题,需要加以管理。

  • Lambda 函数仅限于可以使用的层数(五层),因此如果您有超过五个依赖项,您仍然需要使用本地部署工具,那么为什么要增加层的额外复杂性呢?

  • 层并不特别提供任何功能上的好处 —— 它们是一种部署优化工具(我们将讨论跨切面行为作为此的一个警告)。

  • 特别是在开发 Java Lambda 时,Java 非常擅长定义其“独立世界”。例如,在 Java 中,通常只依赖于在 JVM 中运行的第三方代码,而不是调用系统库或可执行文件。基于此,以及 Maven 依赖的普遍性,可以轻松地在不使用 Lambda 层的 Java 应用中拥有一个统一的依赖管理系统。

  • 有些人喜欢层可以手动更新函数而无需部署函数本身的事实。我们个人坚信,除非有特殊情况,将任何变更部署到生产环境的最佳方式是通过自动化持续交付过程,因此更改应用程序库依赖与配置模板层依赖的区别几乎总是无关紧要。

如果不指出层可以发挥作用的地方,我们会觉得有所遗漏。

首先,如果 Lambda 函数执行的部分与应用程序无关,而更多与组织的横切技术平台相关,则使用层作为替代部署路径可能会有用。例如,假设有一个需要运行的安全流程,但就应用程序开发人员而言,它只是一个“发出并忘记”的调用。在这种情况下,将该代码发布为一个层,并能够查询组织中所有 Lambda 函数配置,并确保它们使用正确版本的层,有助于组织治理。

另一个层次特别有用的地方是依赖是一个很大且很少更改的系统二进制文件。在这种情况下,使用层的额外复杂性可能值得改进部署速度的价值,特别是如果使用该层的函数的部署次数每天达到数百次或更多。

这第二种情况的一个有用示例是 Lambda 函数使用自定义运行时,我们现在将进行探讨。

自定义运行时

在本书中,除了我们的第一个例子使用了 Node 10 运行时之外,我们一直在使用 Java Lambda 运行时。AWS 提供了与不同编程语言相关联的多种运行时,并且此列表经常更新。

但是,如果您想使用 AWS 不支持的语言或运行时会发生什么?例如,如果您有一些 Cobol 代码要在 Lambda 函数中运行怎么办?或者,更可能的是,如果您想运行一个高度定制的 JVM,而不是 AWS 提供的那一个?

答案在于使用自定义运行时。自定义运行时是在 Lambda 执行环境中运行的 Linux 进程,可以处理 Lambda 事件。有一个特定的执行模型需要自定义运行时满足,但基本思想是当 Lambda 平台启动运行时实例时,它会配置一个实例特定的 URL,以便查询下一个要处理的事件。换句话说,自定义运行时使用轮询架构。

作为 Java 开发者,你通常很少需要或需要为生产使用使用自定义运行时。其原因有两点:

  • 自定义运行时代码本身需要成为函数部署的一部分资产。虽然您可以将运行时打包到 Lambda 层中以避免在每次部署时上传它,但它仍会使用您的250MB 总解压缩部署包大小限制中的一部分。如果要运行自定义 JVM,则大多数 JVM 将会占用相当一部分空间,因此这将减少可用于应用代码的空间。

  • 您需要在自定义运行时中重新实现许多 AWS 标准运行时中已经实现的内容,例如事件和响应的反序列化/序列化、错误处理等。

话虽如此,对于某些规模的组织来说,构建一个处理各种组织平台相关任务的自定义运行时可能会使 Lambda 开发变得更加高效,但我们建议在投入使用之前进行彻底分析!

摘要

在本章中,我们深入探讨了 Lambda 的一些高级方面。一些行为和配置在您将无服务器应用程序部署到生产环境时将至关重要。

您了解了以下内容:

  • Lambda 的各种不同的错误处理策略以及您可能选择配置和编程函数来处理错误的方式

  • Lambda 如何在您无需任何努力的情况下自动扩展的解放方式,您如何控制这种扩展,并且在多线程编程背景下这种行为意味着什么

  • Lambda 版本和别名是什么,以及如何使用它们进行“流量转移”方式发布新功能

  • 冷启动是什么时候发生的,是否应该担心它们,以及如何减少它们对应用程序的影响(如果需要的话)

  • 如何考虑 Lambda 开发中的持久性和缓存状态

  • 如何将 Lambda 与 AWS VPCs 配合使用

  • Lambda 层和自定义运行时是什么,以及何时考虑使用它们。

在下一章中,我们将继续讨论 Lambda 的更高级方面,但这次是在 Lambda 如何与其他服务交互的背景下。

练习

  1. 在“示例:构建无服务器 API”中更新 WeatherQueryLambda 以抛出异常。在尝试调用 API 时会看到什么行为?

  2. 如果你已经按照第五章的练习实现了使用 SQS 队列,那么请更新从 SQS 读取的 Lambda 函数以抛出异常。Lambda 的重试行为符合你的预期吗?

  3. 研究后台线程和 Lambda 之间的交互——从第二章的“Hello World”示例开始(参见“Lambda Hello World(正确的方式)”),在处理程序中使用 ScheduledExecutorService 及其 scheduleAtFixedRate 方法来重复记录接收到的事件。会发生什么?尝试使用一些 Thread.sleep 语句。

  4. 更新“示例:构建无服务器 API”以使用流量转移,从 Linear10PercentEvery10Minutes 部署偏好开始。

  5. 扩展任务:如果你在 JVM 上使用不同的语言编程——比如 Clojure、Kotlin 或 Scala——尝试在其中一种语言中构建一个 Lambda 函数。

第九章:高级无服务器架构

在 第八章 中,我们讨论了 Lambda 的一些更高级的方面,这些方面在您开始将应用程序投入生产时变得重要。在本章中,我们继续这一主题,更广泛地探讨 Lambda 对架构的影响。

无服务器架构中的“陷阱”

首先,我们来看看无服务器架构的各个领域,如果不考虑它们可能会给您带来问题,并针对您的情况提供不同的解决方案。

至少一次交付

Lambda 平台保证,当上游事件源触发 Lambda 函数,或者另一个应用显式调用 Lambda 的 invoke API 调用 时,相应的 Lambda 函数将被调用。但平台不保证 函数将被调用的次数:即使没有错误发生,“偶尔,您的函数可能会多次接收相同的事件”。这就是“至少一次交付”,这是因为 Lambda 平台是分布式系统的原因。

大多数情况下,Lambda 函数每个事件只会被调用一次。但有时(远少于 1% 的时间),Lambda 函数会被多次调用。这为什么会成为问题?如何处理这种行为?让我们来看一下。

示例:Lambda 的“cron job”

如果您在工业界开发软件已经足够长的时间,您可能会遇到运行多个“cron job”(定时任务,可能每小时或每天运行一次)的服务器主机。因为这些任务通常不会一直运行,因此仅在每个主机上运行一个任务是低效的,因此在一个主机上运行多种类型的任务非常典型。这样做更有效率,但可能会引起运维方面的头痛:依赖冲突、所有权不确定性、安全问题等。

您可以将许多类型的定时任务作为 Lambda 函数实现。要获得 cron 的调度行为,可以使用 CloudWatch Scheduled Event 作为触发器。SAM 为您提供了一种 简洁的语法 来指定这一功能的触发器,并且甚至可以使用 cron 语法来指定 调度表达式。使用 Lambda 作为 cron 平台有各种好处,包括解决前面段落中的所有运维头痛。

使用 Lambda 实现定时任务的主要缺点是,如果函数运行时间超过 15 分钟(Lambda 的最大超时时间)或者需要超过 3GB 的内存。在这两种情况下,如果无法将任务分解为较小的块,则可能需要考虑使用 Step Functions 和/或者 Fargate

但是,使用 Lambda 还有另一个缺点:非常非常地偶尔您的定时任务可能会在其计划时间或附近运行多次。通常这不会是一个值得考虑的问题——也许您的任务是一个清理工作,两次执行相同的清理操作略微低效但功能上是正确的。然而,有时这可能是一个很大的问题——如果您的任务是计算月度抵押利息,您不希望向客户收取两次费用。

Lambda 的这种至少一次交付特性适用于所有事件源和调用,不仅限于定时事件。幸运的是,有多种方法来解决这个问题。

解决方案:构建一个幂等系统。

面对这个问题的第一个,通常也是最好的解决方案是构建一个幂等系统。我们说这通常是最好的解决方案,是因为它接受我们在使用 Lambda 时构建分布式系统的思想。与其绕过或忽视分布式系统的特性,我们积极设计来与其协同工作。

当特定操作可以应用一次或多次,并且无论应用多少次都具有相同效果时,系统是幂等的。考虑到任何分布式架构时,幂等性是一个非常普遍的要求,更不用说无服务器架构了。

一个幂等操作的例子是将文件上传到 S3(忽略任何可能的触发器!)。无论您将同一文件上传到相同位置一次还是十次,最终结果是预期的键中 S3 中存储的正确字节。

当函数的任何重大副作用本身是幂等时,我们可以使用 Lambda 构建一个幂等系统。例如,如果我们的 Lambda 函数将文件上传到 S3,则 Lambda + S3 的整个系统是幂等的。类似地,如果您在写入数据库时可以使用upsert操作(“更新或插入”),如 DynamoDB 的UpdateItem方法,来创建幂等性。最后,如果您调用任何外部 API,则可能需要查看它们是否提供幂等操作。

解决方案:接受重复,并在问题出现时进行处理。

有时处理可能发生的多次调用的一个完全合理的方法是意识到它可能会发生,并接受它,特别是因为它发生得如此之少。例如,假设您有一个定时任务生成报告然后将其电邮发送到公司内部邮件列表。如果偶尔发送两次电子邮件,您是否在意?也许不会。

同样地,也许构建一个幂等系统的工作量是显著的,但是处理非常偶尔的任务重复的影响实际上是简单和廉价的。在这种情况下,与其内置幂等性,也许更好的方法是监控某个事件的多次运行,然后有一个手动或自动的任务在它发生时执行清理。

解决方案:检查之前的处理

如果重复的副作用从不可接受,但是您的 Lambda 函数还使用了不具有幂等操作的下游系统,那么您有另一种解决此问题的方式。关键在于使您的 Lambda 函数本身具有幂等性,而不是依赖下游组件提供幂等性。

但是,您如何做到这一点,知道 Lambda 可能会多次为同一事件调用函数?关键在于还要知道,即使 Lambda 为同一事件调用多次函数,Lambda 附加到事件的AWS 请求 ID将对每次调用都相同。我们可以通过在我们的处理程序方法中调用.getAwsRequestId()来读取 AWS 请求 ID,我们可以选择接受Context对象。

假设我们可以跟踪这些请求 ID,我们将知道我们以前是否见过某个请求 ID,如果是,则可以选择丢弃第二次调用,从而保证总体语义上的“仅一次”保证。

现在,我们只需要一种方法来检查每次函数调用是否已经看到过请求 ID。因为理论上事件的多个函数调用可能重叠,所以我们需要一个原子性的来源来提供这种能力,这表明使用数据库会有所帮助。

DynamoDB 可以通过其条件写入功能为我们提供这一点。在一个简单的场景中,我们可以有一张只有request_id主键的表;我们可以在处理程序开始时尝试写入该表,使用事件的请求 ID;如果 DynamoDB 操作失败,则立即停止执行;否则,我们可以像往常一样继续 Lambda 的功能,知道这是第一次处理事件(参见图 9-1)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0901.png

图 9-1. 使用 DynamoDB 检查以前的事件

如果您选择沿着这条路走,您的实际解决方案可能会有一些细微差别。例如,如果发生错误,您可以选择在 DynamoDB 中删除行(以便继续使用 Lambda 的重试语义 - 重试的事件也将具有相同的 AWS 请求 ID!)。或者,您可以选择更复杂的“带超时的锁定”风格行为,以允许第一个调用失败时的重叠调用。

使用这种解决方案时,还需要考虑一些 DynamoDB 的问题。例如,您可能希望在表上设置存活时间(TTL)属性,以在一段时间后自动删除行以保持清洁,通常设置为一天或一周。另外,您可能需要考虑 Lambda 函数的预期吞吐量,并使用它来分析 DynamoDB 表的成本 —— 如果成本太高,您可能需要选择另一种解决方案。此类替代方案包括使用 SQL 数据库;构建自己的(非 Lambda)服务来管理此重复操作;或者,在极端情况下,将 Lambda 完全替换为具有更传统计算平台的特定功能。

Lambda 扩展对下游系统的影响

在第八章中,我们看到了 Lambda 的“神奇”自动扩展(“扩展”)。简单总结一下,Lambda 将自动创建所需数量的函数实例及其环境,以处理所有待处理的事件。默认情况下,它会创建每个帐户最多一千个 Lambda 实例,并且如果您要求 AWS 增加您的限制,它还会创建更多。

总的来说,这通常是一个非常有用的功能,也是人们发现 Lambda 有价值的关键原因之一。但是,如果您的 Lambda 函数与下游系统进行交互(大多数情况下都是如此!),那么您需要考虑这种扩展如何影响这些系统。作为一项练习,让我们考虑第五章中的示例。

在 “示例:构建无服务器 API” 中,我们有两个函数 —— WeatherEventLambdaWeatherQueryLambda —— 都调用了 DynamoDB。我们需要知道 DynamoDB 是否能够处理存在的任何上游 Lambda 实例的负载。由于我们使用了 DynamoDB 的“按需”容量模式,我们知道事实上情况确实如此。

在 “示例:构建无服务器数据管道” 中,我们还有两个函数 —— BulkEventsLambdaSingleEventLambdaBulkEventsLambda 调用 SNS,具体来说是为了发布消息,因此我们可以查看AWS 服务限制文档以了解我们可以向 SNS API 发出多少发布调用。该页面说限制在每秒 300 到 30,000 “事务”之间,取决于我们所在的地区。

我们可以使用这些数据来判断我们是否认为 SNS 能够处理我们从 Lambda 函数上可能产生的负载。此外,文档指出这是一个软限制——换句话说,我们可以请求 AWS 为我们增加它。值得知道的是,如果我们超过了限制,那么我们对 SNS 的使用将受到限制——我们可以将此错误通过 Lambda 函数传递回去,作为未处理的错误,从而使用 Lambda 的重试机制。另外,值得一提的是这是一个账户范围的限制,因此,如果我们的 Lambda 函数导致我们达到了 SNS API 的限制,同一账户中使用 SNS 的任何其他组件也将受到限制。

SingleEventLambda 只通过 Lambda 运行时间接调用 CloudWatch Logs。CloudWatch Logs 有限制,但它们非常高,所以目前我们将假设它具有足够的容量。

总之,我们在这些示例中使用的服务可以扩展到高吞吐量。这应该不足为奇——这些示例被设计为无服务器架构的好例子。

然而,如果你正在使用的下游系统要么(a)不像你的 Lambda 函数可能会扩展的那么,要么(b)不像你的 Lambda 函数可能会扩展的那么,那会发生什么?(a)的一个例子可能是下游关系型数据库——它可能只设计用于一百个并发连接,而五百个连接可能会给它带来严重的问题。 (b)的一个例子可能是使用基于 EC2 的自动缩放的下游微服务——这里该服务最终可能会扩展到足以处理意外负载,但 Lambda 可以在内扩展,而不是 EC2,后者将在分钟内扩展。

在这两种情况下,Lambda 函数的不可预见的扩展可能会对下游系统产生性能影响。通常,如果出现这样的问题,则这些效果也会被这些系统的其他客户感受到,而不仅仅是产生负载的 Lambda 函数。由于这个原因,你应该始终考虑 Lambda 对下游系统的扩展的影响。有多种可能的解决方案来处理这个问题。

解决方案:使用相似的扩展基础设施

一种解决方案是,在可能的情况下,使用具有与 Lambda 本身相似的扩展行为和容量的下游系统。我们选择 DynamoDB 和 SNS 在第五章的示例部分是部分由于这个设计动机。同样,有时我们可能会选择积极迁移离开某些解决方案,正是因为扩展的考虑。例如,如果我们可以轻松地从 RDS 数据库切换到使用 DynamoDB,那么这样做可能是有道理的。

解决方案:管理上游的扩展

另一个解决 Lambda 扩展超出下游系统的问题的方法是确保它根本不需要扩展,或者换句话说,限制触发执行的事件数量。如果您正在实施公司内部的无服务器 API,则可能意味着确保 API 的客户端不要发出过多的请求。

某些 Lambda 事件源还提供了帮助管理规模的功能。API 网关具有速率限制(具有 使用计划节流限制),Lambda 的 SQS 集成允许您配置批量大小 (https://oreil.ly/LxNTp)

解决方案:使用保留并发管理扩展性

如果您无法在上游管理规模,但仍希望限制函数的规模,可以使用 Lambda 的保留并发功能,我们在 “保留并发” 中进行了介绍。

当使用保留并发时,Lambda 平台将最多按照您配置的数量扩展函数。例如,如果将保留并发设置为 10,则在任何时候最多运行 10 个 Lambda 函数实例。在这种情况下,如果已有 10 个 Lambda 实例正在处理事件,当另一个事件到达时,您的函数会被节流,就像我们在 第八章 中讨论过的那样。

当您的事件源(如 SNS 或 S3)可能会轻松产生“突发”事件时,这种规模限制非常适用——使用保留并发意味着这些事件会在一段时间内处理,而不是立即全部处理。由于 Lambda 对于节流错误和异步来源的重试能力,您可以确保所有事件最终会被处理,只要在六小时内可以处理完毕。

您应该了解有关保留并发的一项行为是它不仅限制并发——它通过从全局 Lambda 并发池中移除配置的数量来保证并发。如果您有 20 个函数,每个函数的保留并发为 50,假设全局并发限制为 1,000,则将没有更多容量用于其他 Lambda 函数。全局并发限制可以增加,但这是一个需要记住执行的手动任务。

解决方案:有意构建混合解决方案

最后一个想法是有意“混合”构建解决方案(而不是意外混合解决方案),包括无服务器和传统组件。

例如,如果您使用 Lambda 和亚马逊的(非无服务器)RDS SQL 数据库服务,而没有考虑扩展性问题,我们将这称为“意外”混合解决方案。然而,如果您考虑了如何通过 Lambda 更有效地使用您的 RDS 数据库,那么我们将其称为“故意”混合。并且明确一点——我们认为某些架构解决方案由于像 DynamoDB 这样的服务和 Lambda 本身的性质,混合使用无服务器和非无服务器组件会更好。

让我们考虑一个例子,您通过 Lambda 函数将数据注入关系数据库,可能是在 API Gateway 的背后(参见图 9-2)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0902.png

图 9-2. 从 Lambda 函数直接写入关系数据库

此设计的一个问题是,如果您有太多的入站请求,那么您可能会过载您的下游数据库。

您可能首先考虑的解决方案是为支持 API 的 Lambda 函数添加保留并发,但问题在于现在您的上游客户端将不得不处理由并发限制引起的节流问题。

因此,更好的解决方案可能是引入一个消息主题、一个新的 Lambda 函数,并在第二个 Lambda 函数上使用保留并发(参见图 9-3)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0903.png

图 9-3. 通过主题从 Lambda 函数间接写入关系数据库

使用这种设计,例如,您的 API Lambda 函数仍然可以执行输入验证,必要时向客户端返回错误消息。但是,它不会直接写入数据库,而是会将消息发布到一个主题,例如,使用 SNS,在假设您的消息系统可以比数据库更有效地处理突然负载的情况下。然后,该消息的监听者将是另一个 Lambda 函数,其工作纯粹是执行数据库写入(或“upsert”,以处理重复调用!)。但这次 Lambda 函数可以应用保留并发以保护数据库,同时利用 AWS 内部的重试语义,而不是要求原始外部客户端执行重试。

虽然这种结果设计具有更多的移动部件,但它成功解决了扩展性问题,同时仍然混合使用了无服务器和非无服务器组件。

小贴士

2019 年底,亚马逊宣布了RDS 代理服务。截至撰写本文时,该服务仍处于“预览”阶段,因此发布到普遍可用(GA)时的许多细节和功能尚未明确。然而,它肯定会在连接 Lambda 到 RDS 的一些讨论中帮助解决一些问题。

Lambda 事件源的“细则”

本章的前几节讨论的是 Lambda 本身的微妙架构问题。由于 Lambda 上游存在的服务的细微差别,还有其他领域可能会影响无服务器设计。就像“至少一次”交付不是您在关于 Lambda 的第一篇文档中看到的核心内容一样,只有通过深入探索文档或艰辛的经验,您才能发现这些服务的一些微妙差别。

当您开始超越任何 Lambda 事件源的“试验性”阶段时,请尽可能阅读关于您正在使用的服务的所有 AWS 文档。也要寻找非 AWS 的文章——尽管它们不具权威性,有时是错误的,但有时候它们可以在架构上推动您朝着可能否则不会考虑的方向前进。

由无服务器思维启用的新架构模式

有时,当我们构建无服务器系统时,从某种距离来看,我们的架构可能并不比使用容器或虚拟机(VM)设计的方式看起来有多不同。“云原生”架构并不仅仅是 Kubernetes 的专属领域,无论您之前听到过什么!

例如,我们构建的无服务器 API 回溯到“示例:构建无服务器 API”,从“黑盒”视角来看,看起来就像任何其他微服务风格的 API。事实上,我们可以用运行在容器中的应用程序替换 Lambda 函数,从架构上讲,系统将会非常相似。

随着无服务器开始成熟,我们看到了一些新的架构模式,这些模式要么在传统服务中没有意义,要么甚至是不可能的。我们在第五章中提到过其中一种,当我们讨论“无 Lambda 的无服务器”时。在本章的结束部分,我们将看看其他几种模式,使用 Lambda,打破进入新领域。

使用无服务器应用程序库发布组件

在本书中我们多次提到“无服务器应用程序”——作为一个单元部署的组件集合。我们有我们的无服务器 API,使用 API Gateway,两个 Lambda 函数和一个 DynamoDB 表,全部作为一个单元分组。我们使用 Serverless Application Model(SAM)模板定义了这些资源集合。

AWS 提供了一种通过无服务器应用程序库(SAR)重用和共享这些 SAM 应用程序的方式。使用 SAR,您可以发布您的应用程序,然后稍后可以部署它,多次部署到不同的区域、帐户甚至不同的组织,如果您选择将 SAR 应用程序公开可用的话。

传统上,您可能有分发的代码或一个环境无关的部署配置。使用 SAR,代码(通过打包的 Lambda 函数)、基础架构定义和(可参数化的)部署配置全部捆绑在一个可共享的、版本化的 组件中。

SAR 应用程序可以通过几种不同的部署方式部署,这使它们在不同情况下都很有用。

首先,它们可以部署为 独立应用程序,就像您直接调用 sam deploy 一样,而不是使用 SAR。当您希望在多个位置或跨多个帐户或组织部署相同的应用程序时,这很有用。在这种情况下,SAR 在某种程度上就像应用程序部署模板的存储库,但通过打包代码,它还包括实际的应用程序代码。

此类用途的 SAR 应用示例在公共 SAR 存储库中数不胜数——对于希望简化客户将集成组件部署到其 AWS 帐户的第三方软件提供商来说,这尤其有用。例如,这是来自 DataDog 的日志转发器

SAR 应用程序也可以作为其他 父级 无服务器应用程序中的 嵌入式 组件通过CloudFormation 嵌套堆栈使用。SAM 通过 AWS::Serverless::Application 资源类型 实现了 SAR 组件的嵌套。当以这种方式使用 SAR 时,您正在将高级组件抽象为 SAR 应用程序,并在多个应用程序中实例化这些组件。以这种方式使用 SAR 有点像在基于容器的应用程序中使用 “旁车”,但没有旁车需要的低级网络通信模式。

这些嵌套组件可能包括可以直接调用的 Lambda 函数,也可能是通过父应用程序(例如,通过 SAR 也许也包含在其中的 SNS 主题)间接调用的。或者,这些嵌套组件可能根本不包含任何函数,而是仅定义基础资源。一个很好的例子是标准化监控资源的 SAR 应用程序。

通常情况下,我们更喜欢嵌入式部署方案,即使父应用程序中没有其他组件。这是因为部署 SAR 应用程序以及可以在模板文件中作为 AWS::Serverless::Application 资源的一部分定义的参数值与部署任何其他 SAM 定义的无服务器应用程序没有任何区别。此外,如果您选择更新已部署的 SAR 应用程序的 版本,那么这也可以像任何其他模板更新一样在版本控制中跟踪。

SAR 应用程序 可以进行安全设置,以便仅对特定 AWS 组织中的帐户可访问,因此它们是定义可在整个公司中使用的标准组件的绝佳方式。 使用此功能的示例包括 API Gateway 的自定义授权程序、标准运行组件(例如警报、日志过滤器和仪表板)以及消息传递式跨服务通信的常见模式的使用。

SAR 确实有一些限制。 例如,您无法在其中使用所有 CloudFormation 资源类型(例如,EC2 实例)。 但是,这是一种有趣的构建、部署和组合基于 Lambda 的应用程序的方式。

有关如何将 SAM 应用程序发布到 SAR 的详细信息,请参阅文档,有关部署 SAR 应用程序的详细信息,请参阅前面链接的 AWS::Serverless::Application 资源类型。

全球分布式应用程序

在很久以前(即大约 15 年前),我们大多数构建基于服务器的应用程序的人通常对我们的软件实际运行的地点有一个相当清楚的概念,至少在一百米左右,甚至更近。 我们可以准确指出数据中心、服务器房间,甚至我们的代码正在运行的机架或个别机器。

然后“云”出现了,我们对应用程序地理部署的理解变得有点,嗯,模糊了。 例如,使用 EC2,我们大致知道我们的代码正在“北弗吉尼亚”或“爱尔兰”等地区运行,我们还知道两台服务器在同一数据中心运行时,通过它们的可用性区域(AZ)位置。 但是我们极少有可能能够在地图上指出软件运行的建筑物。

无服务器计算立即将我们的考虑半径进一步扩大。 现在我们考虑区域——AZ 概念隐藏在抽象之中。

知道应用程序运行在何处的原因之一是考虑可用性。 当我们在数据中心运行应用程序时,我们需要知道如果数据中心失去互联网连接,那么我们的应用程序将不可用。

对于许多公司,特别是习惯于部署到一个数据中心的公司来说,云提供的这种区域级别的可用性已经足够了,特别是由于无服务器服务保证了区域内的高可用性。

但是如果你想要思考更大的问题呢? 例如,如果您希望即使 AWS 的整个区域变得不稳定,也能保证应用程序的弹性? 这种情况时有发生——只要与使用 us-east-1 的人交谈至少有几年的人。 好消息是 AWS 很少出现任何类型的跨区域中断。 绝大多数 AWS 的停机时间都限制在一个区域。

或者,不仅仅关注可用性,如果您的用户分布在世界各地,从圣保罗到首尔,您希望他们所有人都能低延迟访问您的应用程序,那怎么办呢?

云中存在多区域后,解决这些问题就变得可能。然而,在多个区域中运行应用程序是复杂的,并且随着增加更多区域,成本可能会变得很高。

然而,Serverless 可显著简化和降低这个问题的成本。现在可以在全球多个区域部署您的应用程序,而不会增加太多复杂性,也不会破坏您的预算。

全球部署

当您在 SAM 模板中定义应用程序时,通常不会将任何特定于区域的资源硬编码。如果您需要在 CloudFormation 字符串中引用堆栈部署的区域(例如我们在 第五章 中的数据管道示例中所做的),我们建议使用 AWS::Region 伪参数。对于需要访问的任何特定于区域的资源,我们建议通过 CloudFormation 参数引用这些资源。

使用这些技术,您可以以与区域无关的方式定义您的应用程序模板,并将其部署到任意数量的 AWS 区域。

实际上,将您的应用程序部署到多个区域并不像我们希望的那样容易。例如,使用 CloudFormation 部署应用程序(例如使用 sam deploy)时,在模板文件中引用的 CodeUri 属性中的任何包必须在部署的同一区域内的 S3 存储桶中可用。因此,如果您希望将应用程序部署到多个区域,则其打包的构件需要在多个 S3 存储桶中可用,每个区域一个。这并不是无法解决的小问题,但这是您需要考虑的事情。

AWS 通过在 CodePipeline 中启用 “跨区域操作”,改善了多区域部署的体验。CodePipeline 是亚马逊的 “持续交付” 编排工具,允许我们定义项目的源代码存储库;通过调用 CodeBuild 构建和打包应用程序;最后使用 SAM/CloudFormation 部署应用程序。CodePipeline 实际上是在本书中我们手动运行的命令之上的自动化系统。它将做的远不止这些——这里的流程只是一个示例。

“跨区域操作” 在 CodePipeline 中允许您并行部署到多个区域,目前支持 CodePipeline 的区域数量。这意味着一个 CD 流水线可以将应用程序部署到美国、欧洲、日本和南美。

设置所有这些仍然有些棘手。有关更多信息,请参阅我们在 Github 上的 示例项目

另一个有助于多区域部署的工具是无服务器应用程序存储库,我们在前一节中描述过。当您通过一个区域将应用程序发布到 SAR 时,它将在全球所有区域提供。在撰写本文时,这仅适用于公开共享的应用程序,但我们希望这个功能很快也能适用于私有应用程序。

本地化连接,具备故障转移能力

一旦您在全球范围内部署了您的应用程序,用户如何连接到他们附近的版本呢?毕竟,全球部署的一个重要目的是接受光速有限的事实,因此将用户的请求路由到他们客户端附近的应用程序的最接近地理版本,为用户提供尽可能低延迟的体验。

一种方法是在客户端内部硬编码区域特定位置,通常是一个 DNS 主机名。这有点粗糙,但有时是有效的,特别是对于组织内部的应用程序。

另一个通常更好的选择是,因为它可以 动态 适应用户的位置,是采用亚马逊的 Route53 DNS 服务,特别是其 地理位置 功能。例如,如果用户通过部署在三个不同区域并行的 API 网关连接到您的应用程序,那么您可以在 Route53 中设置 DNS,使用户连接到距离他们最近的 API 网关所在的区域。

由于您在这一点上已经在使用 Route53 的一些高级功能,您可以进一步使用 健康检查和 DNS 故障转移。通过 Route53 的这一特性,如果用户最接近的应用程序版本不可用,那么 Route53 将将该用户重定向到下一个 近可用的应用程序版本。

现在我们有我们应用程序的主动-主动版本 本地化路由。我们构建了一个既具有弹性 性能更好的应用程序。到目前为止,我们的应用程序架构没有更新,只有操作性的更新。然而,我们确实应该面对房间里的大象。

全局状态

我们之前说过,无服务器使得可以将您的应用程序部署到全球多个区域,而几乎不增加复杂性。我们刚刚描述了部署过程本身,并讨论了用户如何通过互联网访问您的应用程序。

然而,全球应用程序的一个重要关注点是如何处理状态。最简单的解决方案是将您的状态仅放在一个区域,并将使用该状态的服务部署到多个区域中(图 9-4)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0904.png

图 9-4. 多个计算区域和一个数据库区域

这是内容传递网络(CDN)使用的相同模型——世界某处有一个“起点”,然后 CDN 在全球的数十甚至数百个“点位”上缓存状态。

这对于可缓存状态是可以接受的,但不可缓存情况怎么办呢?

在这种情况下,单区域状态模型崩溃,因为所有您的区域将调用集中数据库区域的每个请求。您失去了本地化延迟的好处,并且面临区域性故障的风险。

幸运的是,AWS 和其他主要云提供商现在提供全球复制的数据库。AWS 上的一个很好的例子是DynamoDB 全局表。假设您正在使用第五章中的无服务器 API 模式——您可以将设计中的 DynamoDB 表从该示例替换为全局表。然后,您可以将您的 API 快乐地部署到全球多个地区,AWS 将为您安全地在全球范围内移动数据。这为您提供了弹性和改进的用户延迟,因为 DynamoDB 的表复制是异步进行的(见图 9-5)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0905.png

图 9-5. 具有复制数据库的多个区域

AWS 确实对全球表收取额外费用,但与在每个地区建立状态复制系统相比,费用并不是太高。

按使用付费

关于成本问题,当涉及到多区域部署时,无服务器计算真正确定交易的地方在这里。在第一章中,我们说无服务器服务的一个具体区别是它“根据精确的使用情况收费,从零使用到高使用。”这不仅适用于一个区域,而是跨区域。

例如,假设您已经将 Lambda 应用程序部署到三个地区,因为您希望有两个备份地区用于灾难恢复。如果您只使用其中一个地区,那么您只需支付该地区中 Lambda 使用的费用——您在其他两个地区的备份版本是免费的!这与任何其他计算范式有很大的不同。

另一方面,假设您从一个地区部署应用程序开始,然后将您的 API Gateway + Lambda 应用程序部署到十个地区,使用我们之前讨论过的地理位置 DNS 路由。如果您这样做,您的 Lambda 账单不会改变——无论您在一个地区还是十个地区运行,因为 Lambda 仍然只按您函数中发生的活动量收费。您之前的使用量没有增加;现在只是分布在十个地区之间。

我们认为,与传统平台相比,这种极其不同的成本模型将使全球分布式应用比过去更加普遍。

注意

在 Lambda 成本“没有变化”的观点上,这里有一个小小的警告。AWS 可能会根据不同地区对 Lambda 收费略有不同。然而,这是区域特定定价的一部分,而不是因为在多个地区运行应用程序。

边缘计算/“无区域”

到目前为止,在本节中我们讨论的示例都是关于在全球多个地区部署,但它们仍然要求我们理解亚马逊的整个云被划分为这些不同的地区。

如果你根本不需要考虑地区会怎么样?如果你能够将你的代码部署到一个全球服务,并且 AWS 只需执行运行代码所需的一切操作,以提供用户最佳的延迟,并确保即使一个位置下线也能保持可用性?

结果证明,这种未来的疯狂想法已经实现了。有点像。首先,AWS 已经有一些被称为“全球服务”的服务——IAM 和 Route53 就是其中两个。但 AWS 的 CloudFront:AWS 的 CDN 也是。虽然 CloudFront 做了你期望的任何其他 CDN 所做的事情——缓存 HTTP 流量以加快网站速度——它还具有通过名为 Lambda@Edge 的服务调用特殊类别的 Lambda 函数的能力。

Lambda@Edge 函数与 Lambda 函数大多类似——它们具有相同的运行时模型和大多数相同的部署工具。当你部署一个 Lambda@Edge 函数时,AWS 会在全球范围内复制你的代码,因此你的应用程序真正变成了“无区域”。

然而,Lambda@Edge 有许多显著的限制,包括:

  • 唯一可用的事件源是 CloudFront 本身——因此,你只能在 CloudFront 发布中的 HTTP 请求处理过程中运行 Lambda@Edge。

  • Lambda@Edge 函数在撰写本文时,只能用 Node 或 Python 编写。

  • Lambda@Edge 环境相比常规 Lambda 函数在内存、CPU 和超时方面有更多限制。

Lambda@Edge 函数令人着迷,即使在撰写本文时,它们也非常适合解决特定问题。但更重要的是,它们指向了真正全球化的云计算未来,其中局部性完全抽象化。如果 AWS 能够将 Lambda@Edge 的能力更接近常规 Lambda,那么作为架构师和开发者,我们将摆脱区域思维的道路已经走得很远。也许当人们在火星上运行应用程序时,我们仍然需要考虑局部性,但这距离还有几年。Lambda 承诺无服务器,但并非无行星!

摘要

当我们构建无服务器系统时,我们在代码和运维方面的投入减少了,但其中一部分工作需要用更多的架构思考来代替,特别是关于我们正在使用的托管服务的能力和限制方面。在本章中,您更详细地了解了其中一些问题,并审视了一些缓解方法。

无服务器计算还提供了完全新的软件架构方式。您了解到了两个这样的概念——无服务器应用程序仓库和全球分布的应用程序。随着 Lambda 和无服务器技术的进化,在未来几年中,我们预计会看到更多新的应用程序架构模型的出现。

练习

  1. 更新从“示例:构建无服务器数据管道”中的数据管道示例——将SingleEventLambda的预留并发设置为 1。现在上传示例数据——如果需要,请向sampledata.json文件中添加几个更多的元素以产生限流现象。使用 Lambda Web 控制台中的“限流”行为将预留并发设置为零。

  2. 更新“示例:构建无服务器 API”以使用 DynamoDB 全局表——确保将表本身分离到自己的 CloudFormation 堆栈中!然后将 API 组件(及其 Lambda 函数)部署到多个区域。您能够将数据写入一个区域然后从另一个区域读取吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值