创建了一个程序包以支持Go语言的AWS Lambda开发


我经常在工作中使用Go语言AWS Lambda,尤其是开发用于与安全监控相关的基础架构(此或此或此)的后端处理。

在推进开发时,有各种各样的提示说"这很方便",但是它过于分散,因此我通过在项目之间复制来进行开发。但是,随着管理的项目数量的增加,行为也有所不同,并且技巧的数量已在一定程度上积累,因此我将其打包打包。

https://github.com/m-mizutani/golambda

我知道AWS正式提供的Powertools(Python版本,Java版本),但出于完全复制它的目的,我没有这么做。此外,并非所有Go Lambda开发人员都认为"您应该遵循这种方法!"例如,由API网关调用的Lambda可能不会从此包中受益匪浅,因为各种Web应用程序框架都支持类似的功能。因此,我希望您可以将其视为一个故事,例如"将此类处理组合在一起很方便"。

基本上,假定用于数据处理管道的Lambda和少许集成,并实现了以下4个功能。

  • 检索事件
  • 结构化日志
  • 错误处理
  • 获得隐藏的价值

实现的功能

事件检索

AWS Lambda可以指定事件源并从中触发通知。此时,通过传递事件源(例如SQS和SNS)的数据结构来启动Lambda函数。因此,有必要从各种结构数据中提取自己要使用的数据。如果在函数golambda.Start()中指定了回调(在下面的示例中为Handler),则必要的信息将存储在golambda.Event中,并可以从那里检索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
    "strings"

    "github.com/m-mizutani/golambda"
)

type MyEvent struct {
    Message string `json:"message"`
}

// SQSのメッセージをconcatして返すHandler
func Handler(event golambda.Event) (interface{}, error) {
    var response []string

    // SQSのbodyを取り出す
    bodies, err := event.DecapSQSBody()
    if err != nil {
        return nil, err
    }

    // SQSはメッセージがバッチでうけとる場合があるので複数件とみて処理する
    for _, body := range bodies {
        var msg MyEvent
        // bodyの文字列をmsgにbind(中身はjson.Unmarshal)
        if err := body.Bind(&msg); err != nil {
            return nil, err
        }

        // メッセージを格納
        response = append(response, msg.Message)
    }

    // concat
    return strings.Join(response, ":"), nil
}

func main() {
    golambda.Start(Handler)
}

此示例代码位于./example/deployable目录中,您可以在其中进行部署和试用。

与实现数据检索过程的功能DecapXxx相反,嵌入数据的过程准备为EncapXxx。这使您可以为上述Lambda函数编写测试,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main_test

import (
    "testing"

    "github.com/m-mizutani/golambda"
    "github.com/stretchr/testify/require"

    main "github.com/m-mizutani/golambda/example/decapEvent"
)

func TestHandler(t *testing.T) {
    var event golambda.Event
    messages := []main.MyEvent{
        {
            Message: "blue",
        },
        {
            Message: "orange",
        },
    }
    // イベントデータの埋め込み
    require.NoError(t, event.EncapSQS(messages))

    resp, err := main.Handler(event)
    require.NoError(t, err)
    require.Equal(t, "blue:orange", resp)
}

当前,我们在SQS(订阅SNS的SQS队列)上支持SQS,SNS和SNS,但是我们计划稍后再实现DynamoDB流和Kinesis流。

结构化日志

Lambda的标准日志输出目标是CloudWatch Logs,但是Logs或Logs Viewer Insights支持JSON格式的日志。因此,拥有可以以JSON格式输出而不是使用Go语言标准log包的日志记录工具很方便。

登录Lambda的要求(包括日志输出格式)通常是相同的。许多日志记录工具对于输出方法和格式具有不同的选项,但是您并不经常更改每个Lambda函数的设置。同样,在大多数情况下,只有解释消息+上下文所需的变量才足以满足输出内容的需要,因此我们在golambda中准备了具有这种简化的包装器。实际的输出部分使用zerolog。实际上,按原样公开用Zerolog创建的记录器是很好的,但是我认为缩小我的工作范围会更容易,所以我敢于包装它。

导出全局变量

Logger,以便可以输出每个日志级别TraceDebugInfoError的消息。我们提供SetWith允许您永久地嵌入任何变量,而With允许您在方法链中添加值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ------------
// 一時的な変数を埋め込む場合は With() を使う
v1 := "say hello"
golambda.Logger.With("var1", v1).Info("Hello, hello, hello")
/* Output:
{
    "level": "info",
    "lambda.requestID": "565389dc-c13f-4fc0-b113-xxxxxxxxxxxx",
    "time": "2020-12-13T02:44:30Z",
    "var1": "say hello",
    "message": "Hello, hello, hello"
}
*/

// ------------
// request ID など、永続的に出力したい変数を埋め込む場合はSet()を使う
golambda.Logger.Set("myRequestID", myRequestID)
// ~~~~~~~ snip ~~~~~~
golambda.Logger.Error("oops")
/* Output:
{
    "level": "error",
    "lambda.requestID": "565389dc-c13f-4fc0-b113-xxxxxxxxxxxx",
    "time": "2020-11-12T02:44:30Z",
    "myRequestID": "xxxxxxxxxxxxxxxxx",
    "message": "oops"
}
*/

另外,CloudWatch Logs编写日志的成本相对较高,如果您不断输出详细的日志,将会极大地影响成本。因此,通常仅输出最小日志较为方便,以便仅在进行故障排除或调试时才能执行详细的输出。在golambda中,可以通过设置LOG_LEVEL环境变量来从外部篡改日志输出级别。 (因为仅可以从AWS控制台等轻松更改环境变量。)

错误处理

实现AWS Lambda,以便每个功能尽可能单一,并且在实现复杂的工作流时,将使用SNS,SQS,Kinesis Stream,Step Functions等将多个Lambda组合在一起。因此,如果在处理过程中发生错误,请不要尝试在Lambda代码中强行恢复,而应尽可能直接地返回错误,以使其更易于通过外部监视来发现,或者受益于Lambda自身的重试功能。会更容易接收。

另一方面,Lambda本身并不非常仔细地处理错误,因此您需要准备自己的错误处理。如前所述,配置Lambda函数很方便,这样,如果发生某种情况,它将仅返回错误并失败。因此,在大多数情况下,如果发生错误,则如果主函数(在后面描述的示例中为Handler())返回错误,则它将输出有关该错误的所有信息,并且错误会四处出现。需要编写一个过程以在某个位置输出日志或跳过某个地方的错误。

golambda主要处理由golambda.Start()调用的以下两个错误。

  • golambda.NewErrorgolambda.WrapError生成的错误的详细日志输出

  • 将错误发送到错误监视服务(Sentry)
  • 我将详细解释每个。

    详细的错误日志输出

    根据经验,当发生错误时,您需要了解两个主要的调试信息:"发生的位置"和"发生的情况"。

    有一些策略可以找出发生错误的位置,例如使用Wrap函数添加上下文,或者具有类似github.com/pkg/errors包的堆栈跟踪。对于Lambda,如果实现起来尽可能简单,则在大多数情况下,您可以在堆栈跟踪中查明错误发生的位置以及错误的发生方式。

    您还可以通过了解引起错误的变量的内容来了解??错误的再现条件。这可以通过记录每次发生错误时可能相关的变量来解决,但是这将导致跨多个输出行的日志可见性较差(尤其是在调用较深的情况下)。另外,您必须简单地重复编写日志输出代码,这使其变得多余,并且难以简单编写,并且在进行与日志输出相关的更改时很麻烦。

    因此,对于golambda.NewError()golambda.WrapError() 1生成的错误,可以通过函数With()路由与该错误相关的变量。实体仅以键/值的形式存储在map[string]interface{}变量中。当主逻辑(在下面的示例中为Handler())返回由golambda.NewError()golambda.WrapError()生成的错误时,由With()存储的变量和生成该错误的函数的堆栈跟踪将发送到CloudWatch Logs。输出。下面是代码示例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package main

    import (
        "github.com/m-mizutani/golambda"
    )

    // Handler is exported for test
    func Handler(event golambda.Event) (interface{}, error) {
        trigger := "something wrong"
        return nil, golambda.NewError("oops").With("trigger", trigger)
    }

    func main() {
        golambda.Start(Handler)
    }

    执行此操作时,包含以下内容的日志将输出,该日志包含存储在error.valuesWith中的变量和error.stacktrace中的堆栈跟踪。堆栈跟踪也以github.com/pkg/errors的%+v格式作为文本输出,但是,根据结构化日志的输出,它也支持JSON格式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    {
        "level": "error",
        "lambda.requestID": "565389dc-c13f-4fc0-b113-f903909dbd45",
        "error.values": {
            "trigger": "something wrong"
        },
        "error.stacktrace": [
            {
                "func": "main.Handler",
                "file": "xxx/your/project/src/main.go",
                "line": 10
            },
            {
                "func": "github.com/m-mizutani/golambda.Start.func1",
                "file": "xxx/github.com/m-mizutani/golambda/lambda.go",
                "line": 127
            }
        ],
        "time": "2020-12-13T02:42:48Z",
        "message": "oops"
    }

    将错误发送到错误监视服务(Sentry)

    没有特别的理由使它成为Sentry,但是希望不仅对API还要对Web应用程序等Lambda函数使用某种错误监视服务。原因如下。

    • 由于默认情况下无法从输出到CloudWatch Logs的日志中确定日志是正常结束还是异常结束,因此很难仅提取异常结束的执行日志。
    • CloudWatch Logs没有将错误分组的功能,因此很难在100个错误中找到一个具有不同类型错误的错误。

    通过设计错误日志输出方法可以在某种程度上解决这两个问题,但是建议您乖乖地使用错误监视服务,因为您必须小心并实施它。

    golambda通过将Sentry的DSN(数据源名称)指定为环境变量SENTRY_DSN(Sentry Go详细信息),将主要逻辑返回的错误发送到Sentry。发送哪个错误都没有关系,但是golambda.NewErrorgolambda.WrapError生成的错误实现了一个名为StackTrace()的函数,该函数与github.com/pkg/errors兼容,因此堆栈跟踪为Sentry。也显示在侧面。

     src=

    这与输出到CloudWatch Logs的输出相同,但是由于您还可以在Sentry侧屏幕上检查它,因此"查看通知"→"查看Sentry屏幕"→"使用CloudWatch Logs搜索日志并检查详细信息您可以在第二步"是"中猜出错误。此外,对CloudWatch Logs的搜索相当轻松,因此,如果不必搜索,则更好...

    顺便说一句,当您向Sentry发送错误时,Sentry事件ID会以error.sentryEventID的形式嵌入CloudWatch Logs日志中,因此您可以从Sentry错误中进行搜索。

    获取隐藏值

    在Lambda中,根据执行环境而变化的参数通常存储在环境变量中并使用。如果它是个人使用的AWS账户,则将其存储在环境变量中就足够了,但是通过将秘密值和环境变量分开,Lambda信息可以将其存储在一个由多个人共享的AWS账户中或角色)只能引用秘密值,而个人(或角色)也可以引用秘密值。即使您亲自使用它,如果您处理的是真正危险的信息,在某些情况下,权限也可能分开,因此即使某些访问密钥泄漏,您也不会立即死亡。

    就我而言,我经常使用AWS Secrets Manager来分隔权限 2。通过调用API从Secrets Manager检索值相对容易,但是我厌倦了编写大约100次相同的过程,因此我将其模块化。您可以通过将json元标记添加到结构的字段中来获取值。

    1
    2
    3
    4
    5
    6
    7
    type mySecret struct {
        Token string `json:"token"`
    }
    var secret mySecret
    if err := golambda.GetSecretValues(os.Getenv("SECRET_ARN"), &secret); err != nil {
        log.Fatal("Failed: ", err)
    }

    功能未实现

    我认为它会很有用,但是我忘了实现它。

    • 在超时之前执行任意处理:Lambda将在设置的最大执行时间后静默死,因此有一种技术可以在超时之前调用某些处理以输出性能分析信息。但是,就我而言,我几乎没有Lambda函数因超时而死的经验,因此我认为这很有用,但我没有碰它。
    • 跟踪:Python的Powertools提供了使用注释和更多功能来评估AWS X-Ray上的性能的功能。当我尝试使用Go进行此操作时,我没有想到一种比使用官方SDK更容易的方法,因此我没有做任何特别的事情。

    概括

    因此,这是我在Go中实现Lambda的最佳实践的总结,以及对编码版本的介绍。正如我在一开始所写的那样,我只是满足了我的需要,所以我认为它可以为每个人所用,但我希望它能有所帮助。

  • 我认为有一个惯例,这些错误生成方法是errors.New()errors.Wrap(),但是就个人而言,很难直观地了解您使用的是哪个程序包,因此我敢于为其命名,因此我改变了法律。 ?

  • 另一个选项是在AWS Systems Manager参数存储中放置一个秘密值。我个人使用的Secrets Manager具有旋转功能,例如RDS密码,因为我认为它更适合作为服务概念。但是,成本和API速率限制也不同,因此最好根据要求正确使用它们。 ?