AWS无服务器上的权威性崩溃课程:使用Kinesis和Lambda进行集中日志记录

当API出现故障并且您完全不知道为什么时,您不只是讨厌它吗? 现在,假设您无权访问运行软件的VM,群集或容器。 要我继续这场噩梦吗?

是的,这就是调试AWS Lambda函数的样子。 不知道发生了什么,也不知道为什么失败的可怕噩梦。 本文将向您展示记录函数调用的方法。 使您可以跟踪和监视故障和错误,同时还为您提供了一个很好的结构,用于记录信息和调试日志,以供您在需要对行为进行故障排除时使用。

关键是将所有日志发送到中央位置,您以后可以在其中进行分组,筛选和使用它们。 Sematext是整个软件堆栈的全栈可观察性解决方案。 这意味着您可以与任何现有基础架构(例如Kubernetes集群和容器)一起实现功能日志记录。

准备? 让我们开始吧!

使用CloudWatch for Logs

CloudWatch是用于显示AWS Lambda日志的默认解决方案。

CloudWatch以日志,指标和事件的形式收集监视和运营数据,为您提供在AWS和本地服务器上运行的AWS资源,应用程序和服务的统一视图。
— AWS文档

用外行的话来说,它是一项AWS服务,用于显示所有AWS服务中的日志。 我们有兴趣了解其如何处理AWS Lambda日志。 执行Lambda函数时,无论您写到控制台中的什么内容,Go中的fmt.printf()或Node.js中的console.log()都会在后台异步发送到CloudWatch。 对我们来说幸运的是,它不会为函数执行时间增加任何开销。

在函数运行时中使用日志记录代理将增加执行开销,并增加不必要的延迟。 我们希望避免这种情况,并在将日志添加到CloudWatch后对其进行处理。 在下面,您可以查看从通用Hello World函数生成的示例日志事件。

让我们退后一步,看看大局。 每个功能都会在CloudWatch中创建一个称为日志组的东西。 单击特定的日志组。

这些日志组将包含日志流 ,这些实际上等同于来自特定功能实例的日志事件。

对于系统洞察力以及对软件的运行情况有适当的概述,这几乎不是一个足够好的解决方案。 由于其结构,很难查看和区分日志。 使用中心位置存储日志更有意义。 您可以使用自己的Elasticsearch或托管设置。 Sematext为您提供基础结构各部分的全栈可观察性,并公开了Elasticsearch API 。 让我向您展示为您的AWS Lambda函数创建CloudWatch日志处理并将其通过管道传递到Sematext Logs App多么容易。

创建集中式日志记录解决方案

通过使用CloudWatch日志组订阅和Kinesis,您可以将所有Lambda日志集中到一个专用功能,该功能会将其发送到Sematext的Elasticsearch API。 在那里,您可以找到所有日志的中央位置。 您可以搜索和过滤所有功能的日志,而无需花费太多精力就可以了解功能的行为和运行状况。

我将演示如何构建一个可以自己使用的单命令部署解决方案 。 它是使用无服务器框架和Node.js构建的。 但是,您可以随时使用AWS SAMTerraform以及所需的任何编程语言。 这个概念将保持不变。

这就是最终的样子。

比CloudWatch更漂亮,您实际上可以找到所需的内容!

设置无服务器项目

首先,安装无服务器框架,配置IAM用户,然后创建一个新项目。 完整的指南可以在这里找到。

$ npm install -g serverless
$ sls config credentials \
    --provider aws \
    --key xxxxxxxxxxxxxx \
    --secret xxxxxxxxxxxxxx
$ sls create --template aws-nodejs --path lambda-cwlogs-to-logsene
$ cd lambda-cwlogs-to-logsene
$ npm init -y
$ npm i logsene-js zlib serverless-iam-roles-per- function

甜! 现在移至serverless.yml。

配置资源

在代码编辑器中打开lambda-cwlogs-to-logsene目录,并检出serverless.yml 。 随时删除所有内容并将其粘贴。

# serverless.yml
service: lambda-cwlogs-to-logsene

plugins:
  - serverless-iam-roles-per- function

custom :
  stage : $ {opt:stage, self :provider.stage}
  secrets: ${file(secrets.json)}

provider:
  name: aws
  runtime: nodejs8 .10
  stage: dev
  region: ${ self :custom.secrets.REGION, 'us-east-1' }
  versionFunctions: false

functions:
  shipper:
    handler: shipper.handler
    description: Sends CloudWatch logs from Kinesis to Sematext Elastic Search API
    memorySize: 128
    timeout: 3
    events:
      - stream:
          type: kinesis
          arn:
            Fn::GetAtt:
              - LogsKinesisStream
              - Arn
          batchSize: ${ self :custom.secrets.BATCH_SIZE}
          startingPosition: LATEST
          enabled: true
    environment:
      LOGS_TOKEN: ${ self :custom.secrets.LOGS_TOKEN}
      LOGS_BULK_SIZE: 100
      LOG_INTERVAL: 2000
  
  subscriber:
    handler: subscriber.handler
    description: Subscribe all CloudWatch log groups to Kinesis
    memorySize: 128
    timeout: 30
    events:
      - http:
          path: subscribe
          method: get
      - cloudwatchEvent:
          event:
            source:
              - aws.logs
            detail-type:
              - AWS API Call via CloudTrail
            detail:
              eventSource:
                - logs.amazonaws.com
              eventName:
                - CreateLogGroup
      - schedule:
          rate: rate( 60 minutes)
    iamRoleStatements:
      - Effect: "Allow"
        Action:
          - "iam:PassRole"
          - "sts:AssumeRole"
          - "logs:PutSubscriptionFilter"
          - "logs:DeleteSubscriptionFilter"
          - "logs:DescribeSubscriptionFilters"
          - "logs:DescribeLogGroups"
          - "logs:PutRetentionPolicy"
        Resource: "*"
    environment:
      filterName: ${ self :custom.stage}-${ self :provider.region}
      region: ${ self :provider.region}
      shipperFunctionName: "shipper"
      subscriberFunctionName: "subscriber"
      prefix: "/aws/lambda"
      retentionDays: ${ self :custom.secrets.LOG_GROUP_RETENTION_IN_DAYS}
      kinesisArn: 
        Fn::GetAtt:
          - LogsKinesisStream
          - Arn
      roleArn: 
        Fn::GetAtt:
          - CloudWatchLogsRole
          - Arn

resources:
  Resources:
    LogsKinesisStream:
      Type: AWS::Kinesis::Stream
      Properties: 
        Name: ${ self :service}-${ self :custom.stage}-logs
        ShardCount: ${ self :custom.secrets.KINESIS_SHARD_COUNT}
        RetentionPeriodHours: ${ self :custom.secrets.KINESIS_RETENTION_IN_HOURS}

    CloudWatchLogsRole:
      Type: AWS::IAM::Role
      Properties: 
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement: 
            - Effect: Allow
              Principal: 
                Service: 
                  - logs.amazonaws.com
              Action: 
                - sts:AssumeRole
        Policies:
          - PolicyName: root
            PolicyDocument: 
              Version: "2012-10-17"
              Statement: 
                - Effect: Allow
                  Action: 
                    - kinesis:PutRecords
                    - kinesis:PutRecord
                  Resource:
                    Fn::GetAtt:
                      - LogsKinesisStream
                      - Arn
        RoleName: ${ self :service}-${ self :custom.stage}-cloudwatchrole

让我们将其分解。 发货人功能将由Kinesis流触发,它具有一些用于配置Sematext Logs的环境变量。 Kinesis流本身在资源部分的底部定义,并通过使用其ARN在函数事件中引用。

转到订户功能。 它可以通过三种方式触发。 由您选择。 如果您有很多现有的日志组,则可能需要单击HTTP端点以最初将它们全部订阅。 否则,每隔一段时间或仅在创建新的日志组时触发它会很好。

LogsKinesisStream是我们订阅日志组的Kinesis流,而CloudWatchLogsRole是IAM角色,它将允许CloudWatch将记录放入Kinesis。

这样,您现在可以看到我们缺少了secrets.json文件。 但是,在继续之前,请跳至Sematext登录并创建一个Logs App 。 按下绿色的小按钮以添加Logs App。

添加应用程序名称和一些基本信息后,您会看到一个等待数据屏幕弹出。 按集成指南并复制您的令牌。

现在,您可以将令牌粘贴到secrets.json文件中。

{
  "LOGS_TOKEN" : "your-token" ,
  "REGION" : "us-east-1" ,
  "BATCH_SIZE" : 1000 ,
  "LOG_GROUP_RETENTION_IN_DAYS" : 1 ,
  "KINESIS_RETENTION_IN_HOURS" : 24 ,
  "KINESIS_SHARD_COUNT" : 1
}

添加订户功能

我喜欢说Kinesis是Kafka的简单版本。 基本上是管道。 您订阅要发送到其中的数据,并告诉它在满足特定批处理大小后触发Lambda函数作为事件。

具有订阅者功能的目的是为所有日志组订阅Kinesis流。 理想情况下,应该在创建时就对它们进行订阅,当然,当然也要在您最初希望将所有现有日志组订阅到新的Kinesis流时进行订阅。 作为备用,当我要手动触发订户时,我还希望有一个HTTP端点。

在代码编辑器中,创建一个新文件,并将其命名为subscriber.js 。 粘贴此代码段。

// subscriber.js

const AWS = require ( 'aws-sdk' )
AWS.config.region = process.env.region
const cloudWatchLogs = new AWS.CloudWatchLogs()
const prefix = process.env.prefix
const kinesisArn = process.env.kinesisArn
const roleArn = process.env.roleArn
const filterName = process.env.filterName
const retentionDays = process.env.retentionDays
const shipperFunctionName = process.env.shipperFunctionName
const filterPattern = ''

const setRetentionPolicy = async (logGroupName) => {
  const params = {
    logGroupName : logGroupName,
    retentionInDays : retentionDays
  }
  await cloudWatchLogs.putRetentionPolicy(params).promise()
}

const listLogGroups = async (acc, nextToken) => {
  const req = {
    limit : 50 ,
    logGroupNamePrefix : prefix,
    nextToken : nextToken
  }
  const res = await cloudWatchLogs.describeLogGroups(req).promise()

  const newAcc = acc.concat(res.logGroups.map( logGroup => logGroup.logGroupName))
  if (res.nextToken) {
    return listLogGroups(newAcc, res.nextToken)
  } else {
    return newAcc
  }
}

const upsertSubscriptionFilter = async (options) => {
  console .log( 'UPSERTING...' )
  const { subscriptionFilters } = await cloudWatchLogs.describeSubscriptionFilters({ logGroupName : options.logGroupName }).promise()
  const { filterName, filterPattern } = subscriptionFilters[ 0 ]

  if (filterName !== options.filterName || filterPattern !== options.filterPattern) {
    await cloudWatchLogs.deleteSubscriptionFilter({
      filterName : filterName,
      logGroupName : options.logGroupName
    }).promise()
    await cloudWatchLogs.putSubscriptionFilter(options).promise()
  }
}

const subscribe = async (logGroupName) => {
  const options = {
    destinationArn : kinesisArn,
    logGroupName : logGroupName,
    filterName : filterName,
    filterPattern : filterPattern,
    roleArn : roleArn,
    distribution : 'ByLogStream'
  }

  try {
    await cloudWatchLogs.putSubscriptionFilter(options).promise()
  } catch (err) {
    console .log( `FAILED TO SUBSCRIBE [ ${logGroupName} ]` )
    console .error( JSON .stringify(err))
    await upsertSubscriptionFilter(options)
  }
}

const subscribeAll = async (logGroups) => {
  await Promise .all(
    logGroups.map( async logGroupName => {
      if (logGroupName.endsWith(shipperFunctionName)) {
        console .log( `SKIPPING [ ${logGroupName} ] BECAUSE IT WILL CREATE CYCLIC EVENTS FROM IT'S OWN LOGS` )
        return
      }

      console .log( `SUBSCRIBING [ ${logGroupName} ]` )
      await subscribe(logGroupName)

      console .log( `UPDATING RETENTION POLICY TO [ ${retentionDays} DAYS] FOR [ ${logGroupName} ]` )
      await setRetentionPolicy(logGroupName)
    })
  )
}

const processAll = async () => {
  const logGroups = await listLogGroups([])
  await subscribeAll(logGroups)
}

exports.handler = async () => {
  console .log( 'subscriber start' )
  await processAll()
  console .log( 'subscriber done' )
  return {
    statusCode : 200 ,
    body : JSON .stringify({ message : `Subscription successful!` })
  }
}

processAll()processAll()函数。 它将从CloudWatch中获取所有与前缀匹配的日志组 ,并将它们放入易于访问的阵列中。 然后,将它们传递给subscribeAll()函数,该函数将在将它们订阅到您在serverless.yml定义的Kinesis流时,通过它们进行映射。

另一个很酷的事情是将保留策略设置为7天。 您几乎不需要更多的东西,而且会削减将日志保存在AWS账户中的成本。

请记住,您还可以编辑filterPattern通过它可以提取日志。 目前,我选择将其保留为空白并且不过滤任何内容。 但是,根据您的需求,您可以将其与您选择的记录器创建的模式相匹配。

很好,完成之后,让我们继续发送一些日志!

添加托运人功能

Kinesis流从CloudWatch接收日志后,它将触发专用于将日志发送到Elasticsearch端点的Lambda函数。 对于此示例,我们将使用LogseneJS作为日志传送器 。 如果将其分解,这相当简单。 一批记录将在event参数中发送到托运人功能。 您解析日志,为其提供所需的结构,然后将其发送到Sematext。 这是它的样子。 创建一个新文件,将其命名为shipper.js,并将此代码粘贴到其中。

// shipper.js
const Zlib = require ( 'zlib' )
const Logsene = require ( 'logsene-js' )
const logger = new Logsene(process.env.LOGS_TOKEN)
const errorPatterns = [
  'error'
]
const configurationErrorPatterns = [
  'module initialization error' ,
  'unable to import module'
]
const timeoutErrorPatterns = [
  'task timed out' ,
  'process exited before completing'
]
/**
 * Sample of a structured log
 * ***************************************************************************
 * Timestamp                RequestId                            Message
 * 2019-03-08T15:58:45.736Z 53499d7f-60f1-476a-adc8-1e6c6125a67c Hello World!
 * ***************************************************************************
 */
const structuredLogPattern = '[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T(2[0-3]|[01][0-9]):[0-5][0-9]:[0-5][0-9].[0-9][0-9][0-9]Z([ \t])[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}([ \t])(.*)'
const regexError = new RegExp (errorPatterns.join( '|' ), 'gi' )
const regexConfigurationError = new RegExp (configurationErrorPatterns.join( '|' ), 'gi' )
const regexTimeoutError = new RegExp (timeoutErrorPatterns.join( '|' ), 'gi' )
const regexStructuredLog = new RegExp (structuredLogPattern)
const lambdaVersion = ( logStream ) => logStream.substring(logStream.indexOf( '[' ) + 1 , logStream.indexOf( ']' ))
const lambdaName = ( logGroup ) => logGroup.split( '/' ).reverse()[ 0 ]
const checkLogError = ( log ) => {
  if (log.message.match(regexError)) {
    log.severity = 'error'
    log.error = {
      type : 'runtime'
    }
  } else if (log.message.match(regexConfigurationError)) {
    log.severity = 'error'
    log.error = {
      type : 'configuration'
    }
  } else if (log.message.match(regexTimeoutError)) {
    log.severity = 'error'
    log.error = {
      type : 'timeout'
    }
  }
  return log
}
const splitStructuredLog = ( message ) => {
  const parts = message.split( '\t' , 3 )
  return {
    timestamp : parts[ 0 ],
    requestId : parts[ 1 ],
    msg : parts[ 2 ]
  }
}

/**
 * Create payload for Logsene API
 */
const parseLog = ( functionName, functionVersion, message, awsRegion ) => {
  if (
    message.startsWith( 'START RequestId' ) ||
    message.startsWith( 'END RequestId' ) ||
    message.startsWith( 'REPORT RequestId' )
  ) {
    return
  }

  // if log is structured
  if (message.match(regexStructuredLog)) {
    const { timestamp, requestId, msg } = splitStructuredLog(message)
    return checkLogError({
      message : msg,
      function : functionName,
      version : functionVersion,
      region : awsRegion,
      type : 'lambda' ,
      severity : 'debug' ,
      timestamp : timestamp,
      requestId : requestId
    })
  } else { // when log is NOT structured
    return checkLogError({
      message : message,
      function : functionName,
      version : functionVersion,
      region : awsRegion,
      type : 'lambda' ,
      severity : 'debug'
    })
  }
}

const parseLogs = ( event ) => {
  const logs = []

  event.Records.forEach( record => {
    const payload = Buffer.from(record.kinesis.data, 'base64' )
    const json = (Zlib.gunzipSync(payload)).toString( 'utf8' )
    const data = JSON .parse(json)
    if (data.messageType === 'CONTROL_MESSAGE' ) { return }

    const functionName = lambdaName(data.logGroup)
    const functionVersion = lambdaVersion(data.logStream)
    const awsRegion = record.awsRegion

    data.logEvents.forEach( logEvent => {
      const log = parseLog(functionName, functionVersion, logEvent.message, awsRegion)
      if (!log) { return }
      logs.push(log)
    })
  })

  return logs
}

const shipLogs = async (logs) => {
  return new Promise ( ( resolve ) => {
    if (!logs.length) { return resolve( 'No logs to ship.' ) }
    logs.forEach( log => logger.log(log.severity, 'LogseneJS' , log))
    logger.send( () => resolve( 'Logs shipped successfully!' ))
  })
}

exports.handler = async (event) => {
  try {
    const res = await shipLogs(parseLogs(event))
    console .log(res)
  } catch (err) {
    console .log(err)
    return err
  }
  return 'shipper done'
}

发货人Lambda的核心在于parseLogs()shipLogs()函数。 前者将采用event参数,提取所有日志事件,解析它们,将它们添加到数组中,然后返回该数组。 尽管后者将采用相同的日志数组,但是将每个单个日志事件添加到LogseneJS缓冲区中,然后一次性发送所有这些事件。 该位置是您在上面创建的Logs App。

您还记得从文章开头看到的典型函数调用的日志事件吗? 在那里您可以看到它生成4种不同类型的日志事件。

START RequestId 
... 
END RequestId 
REPORT RequestId

它们可以从这三种模式中的任何一种开始,其中省略号代表在函数运行时(Node.js中的console.log()打印到stdout的任何类型的字符串。

parseLog()函数将完全跳过START,END和REPORT日志事件,并且仅基于调试或错误(根据用户定义的stdout还是函数运行时中的任何类型的错误)返回用户定义的日志事件。 ,配置或持续时间。

日志消息本身可以默认构造,但并非总是如此。 在Node.js运行时中,默认情况下,它的结构如下所示。

Timestamp                 RequestId                             Message 
2019 -03 -08 T15: 58 : 45.736 Z  53499 d7f -60 f1 -476 a-adc8 -1e6 c6125a67c  Hello World!

托运人中的代码配置为与上面的结构或仅包含消息部分的结构一起使用。 如果您使用的是其他运行时,建议您使用结构化日志记录,以使日志事件具有通用结构。

完成编码部分后,您就可以部署和测试自定义日志传送程序了。

部署和测试您的集中式日志记录解决方案

使用无服务器框架之类的基础结构作为代码解决方案的好处在于部署的简单性。 您可以使用一个命令将所有内容推送到云中。 跳回到您的终端并在项目的目录中运行:

$ sls deploy

您会看到输出被打印到控制台。

[output]
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 ( 2.15 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
............
Serverless: Stack update finished...
Service Information
service: lambda-cwlogs-to-logsene
stage: dev
region: us-east -1
stack: lambda-cwlogs-to-logsene-dev
api keys:
  None
endpoints:
  GET - https: //.execute-api.us-east-1.amazonaws.com/dev/subscribe
functions:
  shipper: lambda-cwlogs-to-logsene-dev-shipper
  subscriber: lambda-cwlogs-to-logsene-dev-subscriber
layers:
  None
Serverless: Removing old service artifacts from S3…

而已。 现在,您具有将所有日志从Lambda函数传送Sematext Cloud的设置 。 确保触发订阅者功能以将日志组订阅到Kinesis流。 触发订阅者后,您将在Sematext中看到订阅者生成的日志,您可以放心它可以工作。

在上方,您可以看到我如何添加严重性过滤。 您可以轻松选择要过滤的值,从而可以轻松地跟踪错误,超时和调试日志。

费用呢?

在您的AWS账户中进行这样的设置的成本相当便宜。 单个分片Kinesis流的固定成本大约为$ 14 /月,其中包含流数据量的额外成本 。 单个分片的摄取容量为1MB /秒或1000条记录/秒,这对大多数用户而言都可以。

Kinesis成本分为碎片时间和大小为25KB的PUT有效负载单位 。 一个分片每天的费用为$ 0.36,而一百万个PUT有效负载单位的费用为$ 0.014。 假设,如果您有一个分片和每秒100个PUT有效负载单元,那么在30天的时间里 ,分片的成本为$ 10.8,有效负载单元的成本为$ 3.6288

Lambda函数配置为使用最小的内存量,即128MB,这意味着在适度使用期间,成本通常会停留在免费层上。 那是您最少的担心。

包起来

拥有日志中心位置至关重要。 尽管CloudWatch以其自己的方式有用,但缺乏概览性。 通过使用中心位置,您无需切换上下文即可调试不同类型的应用程序。 Sematext可以监视您的整个软件堆栈。 在Sematext Logs中拥有Kubernetes日志, 容器日志和Lambda 日志 ,您可以在其中轻松跟踪所有内容,这是一个主要好处。

如果您需要再次签出代码, 这是repo ,如果您希望更多的人在GitHub上看到它,请给它加星号。 您还可以克隆存储库并立即进行部署。 不要忘记先添加您的Logs App令牌。

如果您需要软件堆栈的可观察性解决方案,请签出Sematext 。 我们正在努力开源产品并产生影响。

希望你们和我喜欢写这本书一样喜欢阅读。 如果您喜欢它,请点一下这个很小的共享按钮,以便更多的人看到本教程。 在下一次之前,请保持好奇并开心。

最初于 2019 年3月15日 发布在 sematext.com 上。

From: https://hackernoon.com/the-definitive-crash-course-on-serverless-with-aws-centralized-logging-with-kinesis-and-lambda-bfbc3439ceac

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值