使用 Amazon Linux 2 在 Elastic Beanstalk 上输出 JSON 日志

使用 Amazon Linux 2 在 Elastic Beanstalk 上输出 JSON 日志

我们正在尝试将 Java 应用程序从当前的 Elastic Beanstalk JDK 8 平台迁移到在 Amazon Linux 2 上运行 Corretto 11 的新平台。该应用程序运行良好,但日志处理方式发生了变化。现在,Web 进程的输出存储在 中/var/log/web.stdout.log,每行都以时间戳和进程名称为前缀,即:

May 20 17:00:00 ip-10-48-41-129 web: {"timestamp":"2020-05-20T17:00:00.035Z","message":"...","logger":"...","thread":"MessageBroker-2","level":"INFO"}

我们如何才能去掉前缀?这些日志被流式传输到 CloudWatch,我们将它们以 JSON 格式输出到 stdout,以便我们稍后可以使用 Logs Insights 查询它们。但是使用前缀,Insights 不会“看到”JSON,而只是将整行视为文本 blob。

我在 AWS 上找不到任何相关文档。几乎所有 Elastic Beanstalk 文档都涉及 Amazon Linux 的第一个版本。

答案1

我用了平台挂钩来实现这一点。唯一的问题是,/etc/rsyslog.d/web.conf应用程序和配置部署都会被替换,因此您需要一个钩子来同时处理这两者。

这种方法避免了干扰 Elastic Beanstalk 的内部文件/opt/elasticbeanstalk/config/private(自上次回答以来,这些文件已发生更改 -rsyslog.conf不再存在)。此外,现在平台挂钩比 ebextensions 更受欢迎。

如果您使用 CodeBuild,请不要忘记在输出工件中包含platformFiles目录(或放置文件的任何位置)。

笔记:此代码假定进程名称为web。如果您在 中定义了不同的进程名称Procfile,请使用该名称。但是,我思考/etc/rsyslog.d/web.conf无论进程名称如何,rsyslog 配置都应该始终存在。

确保所有.sh文件都可以使用 执行chmod +x

.platform/hooks/predeploy/10_logs.sh

#!/bin/sh
sudo platformFiles/setupLogs.sh

.platform/confighooks/predeploy/10_logs.sh

#!/bin/sh
sudo platformFiles/setupLogs.sh

platformFiles/setupLogs.sh

#!/bin/sh
# By default logs output to /var/log/web.stdout.log are prefixed. We want just the raw logs from the app.
# This updates the rsyslog config. Also grants read permissions to the log files.

set -eu

mv platformFiles/rsyslogWebConf.conf /etc/rsyslog.d/web.conf

touch /var/log/web.stdout.log
touch /var/log/web.stderr.log
chmod +r /var/log/web.stdout.log
chmod +r /var/log/web.stderr.log

systemctl restart rsyslog.service

platformFiles/rsyslogWebConf.conf

# This file is created from Elastic Beanstalk platform hooks.

template(name="WebTemplate" type="string" string="%msg%\n")

if $programname == 'web' then {
  *.=warning;*.=err;*.=crit;*.=alert;*.=emerg; /var/log/web.stderr.log;WebTemplate
  *.=info;*.=notice /var/log/web.stdout.log;WebTemplate
}

推测

看起来已/opt/elasticbeanstalk/config/private/rsyslog.conf被替换为/opt/elasticbeanstalk/config/private/rsyslog.conf.template

# This rsyslog file redirects Elastic Beanstalk platform logs.
# Logs are initially sent to syslog, but we also want to divide
# stdout and stderr into separate log files.

{{range .ProcessNames}}if $programname  == '{{.}}' then {
  *.=warning;*.=err;*.=crit;*.=alert;*.=emerg /var/log/{{.}}.stderr.log
  *.=info;*.=notice /var/log/{{.}}.stdout.log
}
{{end}}

基于此,我推测 Elastic Beanstalk 使用此模板生成一个/etc/rsyslog.d/web.conf文件,其中包含每个已定义流程名称的块。由于应用程序和配置部署都可以更改已定义的流程,因此在两者之后重新创建此文件是有意义的。

答案2

我找到了一个效果很好的解决方案,所以我会在这里发布以供后人参考。如果有人能提出更好的建议,请提出来。

Amazon Linux 2 上的 Elastic Beanstalk 依赖于进行日志处理和输出。部署期间会将 复制到 处的rsyslog一个文件,该文件会将应用程序的所有输出定向到 处。/opt/elasticbeanstalk/config/private/rsyslog.conf/etc/rsyslog.d/web.confweb/var/log/web.stdout.log

该文件不包含任何自定义模板。它依赖于rsyslog的默认模板,该模板在任何 前面都加上%msg%时间戳和前缀$programname(在本例中为web)。

我尝试通过替换此文件.ebextensions配置,但这不起作用,因为 Elastic Beanstalk 似乎在.ebextensions运行后覆盖了这个文件。所以我添加了一个附加平台挂钩删除该文件,保留我添加的自定义文件。

这是.ebextensions/logs.config文件:

files:
  "/etc/rsyslog.d/web-files.conf":
    mode: "000644"
    owner: root
    group: root
    content: |
      template(name="WebTemplate" type="string" string="%msg%\n")

      if $programname == 'web' then {
        *.=warning;*.=err;*.=crit;*.=alert;*.=emerg; /var/log/web.stderr.log;WebTemplate
        *.=info;*.=notice /var/log/web.stdout.log;WebTemplate
      }

commands:
  remove-.bak-rsyslog:
    command: rm -f *.bak
    cwd: /etc/rsyslog.d

并且.platform/hooks/predeploy/remove-default-rsyslog-conf.sh(请确保你chmod +x这一个):

#!/bin/sh
rm /etc/rsyslog.d/web.conf
systemctl restart rsyslog.service

答案3

我将在这里提出我的解决方案,尽管它与 op 的问题略有不同 - 但它围绕着相同的想法,并希望能回答其他评论者关于 nodejs 的一些问题。

这是为了运行 Node.js 12.x 的 Amazon Linux 2

问题:nodejs stdout 日志与“web”下的 nginx 日志混合在一起,并且格式很糟糕。

以前,在运行 Nodejs 的 Amazon Linux 1 中,这些日志被分成/var/log/nodejs/nodejs.log/var/log/nginx/access.log。将它们合并并加上 ip 地址前缀只会让它们变得一团糟。

我遵循了所提出的解决方案并对其进行了一些修改。

  1. .ebextension 配置可修改 rsyslog.conf,将日志拆分为两个不同的文件。我正在根据日志文件中看到的模式进行过滤,但您可以使用任何与 RainerScript 兼容的正则表达式。

我不确定他们是否希望你编辑这个文件,正如另一位评论者指出的那样,因为它是私密的。如果你对此不满意,我建议你写入自己的日志文件而不是 stdout。这样你就可以拥有更多的控制权。

files:
  "/opt/elasticbeanstalk/config/private/rsyslog.conf" :
    mode: "000755"
    owner: root
    group: root
    content: |
        template(name="WebTemplate" type="string" string="%msg%\n")

        if $programname  == 'web' and $msg startswith '#033[' then {
            *.=warning;*.=err;*.=crit;*.=alert;*.=emerg; /var/log/web.stderr.log
            *.=info;*.=notice /var/log/web.stderr.log;
        } else if( $programname == 'web') then /var/log/node/nodejs.log;WebTemplate

  1. 现在我有了一个新的日志文件,它或多或少只是节点 stdout,需要将其流式传输到 cloudwatch 并包含在日志包中。这些配置已记录(尽管未针对 Amazon Linux 2 进行更新)。这里没有覆盖任何配置,它们只是添加新配置。
files:
  "/opt/elasticbeanstalk/config/private/logtasks/bundle/node.conf" :
    mode: "000755"
    owner: root
    group: root
    content: |
      /var/log/node/nodejs.log

files:
  "/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.d/node.json" :
    mode: "000755"
    owner: root
    group: root
    content: |
        {
            "logs": {
                "logs_collected": {
                    "files": {
                        "collect_list": [
                           
                            {
                                "file_path": "/var/log/node/nodejs.log",
                                "log_group_name": "/aws/elasticbeanstalk/[environment_name]/var/log/node/nodejs.log",
                                "log_stream_name": "{instance_id}"
                            }
                        ]
                    }
                }
            }
        }

commands:
    append_and_restart:
        command: /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a append-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/etc/node.json -s

答案4

如果您使用 lambda 将内容移动到 loggly 中(根据此处的文档)https://documentation.solarwinds.com/en/success_center/loggly/content/admin/cloudwatch-logs.htm)您可以简单地修改 lambda 以提取 JSON 并删除导致问题的前导字符串。这是我正在使用的 lambda,它可以很好地清理内容并发布我的 JSON 日志。

附注:我使用 NodeJS - Fastify,它包含 Pino,用于生成漂亮的 JSON 日志。有关设置日志的一些精彩见解可在此处找到https://jaywolfe.dev/blog/setup-your-fastify-server-with-logging-the-right-way-no-more-express/

'use strict';

/*
 *  To encrypt your secrets use the following steps:
 *
 *  1. Create or use an existing KMS Key - http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html
 *
 *  2. Expand "Encryption configuration" and click the "Enable helpers for encryption in transit" checkbox
 *
 *  3. Paste <your loggly customer token> into the kmsEncryptedCustomerToken environment variable and click "Encrypt"
 *
 *  4. Give your function's role permission for the `kms:Decrypt` action using the provided policy template
*/

const AWS = require('aws-sdk');
const http = require('http');
const zlib = require('zlib');


// loggly url, token and tag configuration
// user needs to edit environment variables when creating function via blueprint
// logglyHostName, e.g. logs-01.loggly.com
// logglyTags, e.g. CloudWatch2Loggly
const logglyConfiguration = {
    hostName: process.env.logglyHostName,
    tags: process.env.logglyTags,
};

// use KMS to decrypt customer token in kmsEncryptedCustomerToken environment variable
const decryptParams = {
    CiphertextBlob: Buffer.from(process.env.kmsEncryptedCustomerToken, 'base64'),
    EncryptionContext: { LambdaFunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME },
};

const kms = new AWS.KMS({ apiVersion: '2014-11-01' });

kms.decrypt(decryptParams, (error, data) => {
    if (error) {
        logglyConfiguration.tokenInitError = error;
        console.log(error);
    } else {
        logglyConfiguration.customerToken = data.Plaintext.toString('ascii');
    }
});

// entry point
exports.handler = (event, context, callback) => {
    const payload = Buffer.from(event.awslogs.data, 'base64');

    // converts the event to a valid JSON object with the sufficient infomation required
    function parseEvent(logEvent, logGroupName, logStreamName) {
        return {
            // remove '\n' character at the end of the event
            message: extractJSON(logEvent.message.trim())[0],
            logGroupName,
            logStreamName,
            timestamp: new Date(logEvent.timestamp).toISOString(),
        };
    }

    // joins all the events to a single event
    // and sends to Loggly using bulk endpoint
    function postEventsToLoggly(parsedEvents) {
        if (!logglyConfiguration.customerToken) {
            if (logglyConfiguration.tokenInitError) {
                console.log('error in decrypt the token. Not retrying.');
                return callback(logglyConfiguration.tokenInitError);
            }
            console.log('Cannot flush logs since authentication token has not been initialized yet. Trying again in 100 ms.');
            setTimeout(() => postEventsToLoggly(parsedEvents), 100);
            return;
        }

        // get all the events, stringify them and join them
        // with the new line character which can be sent to Loggly
        // via bulk endpoint
        const finalEvent = parsedEvents.map(JSON.stringify).join('\n');

        // creating logglyURL at runtime, so that user can change the tag or customer token in the go
        // by modifying the current script
        // create request options to send logs
        try {
            const options = {
                hostname: logglyConfiguration.hostName,
                path: `/bulk/${logglyConfiguration.customerToken}/tag/${encodeURIComponent(logglyConfiguration.tags)}`,
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Content-Length': finalEvent.length,
                },
            };

            const req = http.request(options, (res) => {
                res.on('data', (data) => {
                    const result = JSON.parse(data.toString());
                    if (result.response === 'ok') {
                        callback(null, 'all events are sent to Loggly');
                    } else {
                        console.log(result.response);
                    }
                });
                res.on('end', () => {
                    console.log('No more data in response.');
                    callback();
                });
            });

            req.on('error', (err) => {
                console.log('problem with request:', err.toString());
                callback(err);
            });

            // write data to request body
            req.write(finalEvent);
            req.end();
        } catch (ex) {
            console.log(ex.message);
            callback(ex.message);
        }
    }
    
    function extractJSON(str) {
        var firstOpen, firstClose, candidate;
        firstOpen = str.indexOf('{', firstOpen + 1);
        do {
            firstClose = str.lastIndexOf('}');
            console.log('firstOpen: ' + firstOpen, 'firstClose: ' + firstClose);
            if(firstClose <= firstOpen) {
                return null;
            }
            do {
                candidate = str.substring(firstOpen, firstClose + 1);
                console.log('candidate: ' + candidate);
                try {
                    var res = JSON.parse(candidate);
                    console.log('...found');
                    return [res, firstOpen, firstClose + 1];
                }
                catch(e) {
                    console.log('...failed');
                }
                firstClose = str.substr(0, firstClose).lastIndexOf('}');
            } while(firstClose > firstOpen);
            firstOpen = str.indexOf('{', firstOpen + 1);
        } while(firstOpen != -1);
    }

    zlib.gunzip(payload, (error, result) => {
        if (error) {
            callback(error);
        } else {
            const resultParsed = JSON.parse(result.toString('ascii'));
            const parsedEvents = resultParsed.logEvents.map((logEvent) =>
                    parseEvent(logEvent, resultParsed.logGroup, resultParsed.logStream));

            postEventsToLoggly(parsedEvents);
        }
    });
};

相关内容