美文网首页PHP开发程序员
devops| 日志服务实践

devops| 日志服务实践

作者: daydaygo | 来源:发表于2018-04-22 23:55 被阅读117次

    date: 2018-4-4 18:28:08
    title: devops| 日志服务实践
    description: 阿里云日志服务实践: nginx access log; yii 框架接入阿里云日志服务

    devops| 日志服务实践
    技术分享 - devops| 日志服务实践

    提纲:

    1. 日志服务功能点一览
    2. 阿里云日志服务实践
    3. 示例一: nginx access log
    4. 示例二: yii 框架接入阿里云日志服务
    5. 再探 protobuf

    日志服务可以说的上是构建软件项目的基石之一, 系统持续稳定运行必不可少的一部分. 这里从阿里云日志服务入手, 借助云平台带来的技术更新迭代, 聊一聊日志服务实践.

    以前日志服务的打开方式

    日志最常见的方式是写入到文件中. 「小作坊」的情况下, 把服务器的权限给开发, 开发自己 ssh 到服务器上面用 grep 查日志. 是的, 我就是这样过来的, 所以常用的几个 grep 命令, 甚至一些稍微高级的命令, 还能默写出来:

    grep xxx xxFile # 正则匹配查询字符串
    grep 'xxx xxx' # 查询包含特殊字符, 比如空格的字符串
    grep -i xxx # 忽略大小写
    grep -n xx # 显示行号
    grep -v xxx # 查询不包含字符串的行
    grep -r xxx xxDir # 在文件夹中递归查询
    ps aux | grep xxx | grep -v 'grep' # -v 常用的一种方式
    
    # 2个复杂些的例子
    # 获取访问 ip 统计
    cat /var/log/nginx/access.log|awk '{print $1}'|sort|uniq -c|sort -nr|more
    # 获取 http 状态码
    cat /var/log/nginx/access.log|grep -ioE 'HTTP/1.[0|1]" [0-9]{3}'|awk '{print $2}'
    

    grep 查询可以使用多种 正则 方式: 基础, 扩展, perl. 支持的正则功能一次增多, 部分细节有些许差异.

    -E, --extended-regexp     PATTERN is an extended regular expression (ERE)
    -G, --basic-regexp        PATTERN is a basic regular expression (BRE)
    -P, --perl-regexp         PATTERN is a Perl regular expression
    

    一句话概括这种方式: 简单直接. 当然有时直接 vim 打开, 然后再查看的. 不过数据量一大, vim 的速度就不乐观了. 所以通常会对日志文件进行 切分, 这样也便于以后 归档:

    • 按照业务切分: 服务器各项日志, 不同业务模块的日志, 第三方接口的日志
    • 按照时间维度: 日切 xxx_20180421.log; 月切 xxx_201804.log
    • 按照文件大小: 比如到 1G 了, 从 xxx.log.1xxx.log.2, 一次递增
    • 多种方式组合使用

    文件一多, 查询就变得困难起来了.

    数据量大, 还要考虑日志的 写入性能, 通常的做法是 加缓存: 这里称之为 刷新(flash):

    • 一定时间间隔写入一次
    • 日志达到多少条写入一次
    • 日志超过多大写入一次

    开放服务器 ssh 权限出来, 会带来 安全隐患, 有开发上去误操作就不好了. 所以有了新的替代方案:

    • 运维开日志的 ftp, 需要看日志自己去下载
    • 存储到数据库中, 比如 MongoDB, 走数据库查询
    • 自建日志中心

    当然 自建日志中心 是最高级的玩法, 之前鹅厂的分享提到过, 会走 UDP 进行日志的上传与统一分析.

    关于日志的其他细节:

    • 全链路跟踪: 去年很火的方案, 请求进来时生成一个 trace_id, 之后的所有调用都会带上这个 trace_id, 这样就可以在日志中通过 trace_id 查询到整个调用链路
    • 安全问题: 日志中可能有用户未经处理的敏感信息, 比如手机号, 甚至没有经过处理的密码
    • 日志归档的问题: 打包归档历史数据来降低日志存储的成本

    最后, 对大部分使用日志的人(通常是开发, 定位 犯罪现场)而言, 好查 好用 才是重中之重, 日志的存储/归档都不用自己操心, 由日志系统来解决.

    上手阿里云日志服务

    阿里云的日志服务上手比较容易, 在控制台点点点即可, 大致的分层设计如下:

    • 开通日志服务: 总的入口
    • project: 项目, 第一级分层, project + region 构成 api 的访问地址
    • logStore: 日志存储, 每个 project 下可以建立多个 logstore, logStore 可以配置多个 shared过期时间
    • log data source: 需要为每个 logStore 配置数据源
    • 日志投递: 日志数据除了供日志服务消费外, 还可以投递给其他云产品, 比如 OSS 进行归档处理
    • 日志查询: 重点功能, 包含 search analysis chart 3个主要部分

    配置数据源常用的方式:

    • nginx access log: 下面还会详细提到
    • 文本 + logtail 工具收集 + 自定义日志分割
    • sdk 接入

    关于 logtail: 阿里云提供的日志收集工具, 安装到 ecs 上就可以按照 logStore 配置的日志路径进行搜集

    PS: 如果 ecs 和 日志服务是不同的账号下的, 需要配置授权

    日志查询快捷指南:

    • search: 支持部分正则的查询语法; 直接点击日志即可查询
    • analysis: 使用 | 管道对查询结果进行分析; 类 sql 的语法
    • chart: 将 analysis 得到的结果转化为图表, 更直观
    • 其他小技巧: 保存常用查询
    日志数据源配置

    实践一: nginx access log

    nginx access log 的接入提供了很好的支持:

    • 配置好 logtail 收集 access log
    • logStore 中配置 log_format 就可以自动分割日志建立索引
    • 配置常用 查询/分析/图表

    推荐下面的 log_format:

    log_format main '$remote_addr||$remote_user||$time_local||$request||$http_host||$status||$request_length||$body_bytes_sent||$http_referer||$http_user_agent||$request_time||$upstream_response_time||$request_body';
    

    PS: 细节出魔鬼, 之前没有采用 || 的方式, 导致部分日志解析出现问题, 字段没有对上

    日志记录:

    nginx-access-log 示例

    配置好图表:

    image

    关于 request_body:

    • 推荐加到 log_format
    • 当前 nginx 版本(我的是 1.13) 直接配置上就可以收集 form-data x-www-form-urlencoded application/json 等格式的 post 数据

    如何解析 form-data 格式的数据:

    function hextostr($hex) {
        return preg_replace_callback('/\\\x([0-9a-fA-F]{2})/', function($matches) {
            return chr(hexdec($matches[1]));
        }, $hex);
    }
    echo hextostr('----------------------------400719531552868304622917\x0D\x0AContent-Disposition:');
    

    如果 request_body 无法记录, 网上提供了 2 种方案(当前版本并不需要):

    • access log 记录到 fastcgi_pass 配置处

      location ~ .php$ {
      fastcgi_pass fpm:9000;
      fastcgi_index index.php;
      fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
      access_log /var/log/nginx/yii_access.log main;
      include fastcgi_params;
      }

    • 使用 nginx_lua 模块

    lua_need_request_body on;
    content_by_lua 'local s = ngx.var.request_body';
    

    实践二: 接入日志服务sdk(以 yii 框架为例)

    使用 logtail 来作为数据源实在是 简单, 搜集的数据通过 分隔符 或者 正则 进行分割, 有时候会很麻烦. 写过 正则 的人都知道, 正则这东西并不难, 它就是 , 稍微有一点点变动, 正则可能就需要调整了. 而且用 logtail 来收集日志, 看似和业务 比较隔离, 实际感觉确实 偏离业务 更多一点. 接入 日志服务sdk 会是一个不错的选择.

    在之前 yii| 最佳实践之黑箱思维 提到过 yii 的日志服务, 这里再简要复述一下:

    • 分层设计: logger - dispatch - target 3层, logger 专注于日志功能, dispatch 来调度, target 来适配不同日志存储
    • 日志标记: 包括 level / category / tag / perfix 等多种日志标记方式, 方便对日志更细粒度的控制
    • flush, 刷新, 比如 1000 条后再输出(落地). 缓冲(buffer) 的思想可以说在系统设计中比比皆是.
    • 切片, 比如说日志按照时间日切, 或者按照大小 10m 一切

    PS: 这就是成熟框架的威力, 常用功能近乎全面无死角的解决掉

    具体 yii 中接入, 其实就是新增一个 target, 通过阿里云日志服务SDK写入日志:

    <?php
    Yii::setAlias('@common', dirname(__DIR__));
    Yii::setAlias('@frontend', dirname(dirname(__DIR__)) . '/frontend');
    Yii::setAlias('@backend', dirname(dirname(__DIR__)) . '/backend');
    Yii::setAlias('@console', dirname(dirname(__DIR__)) . '/console');
    
    require __DIR__ . '/../sdk/aliyun-log-php-sdk/Log_Autoload.php';
    
    ?php
    namespace common\components;
    
    use yii\base\Component;
    
    /**
     *  https://github.com/aliyun/aliyun-log-php-sdk
     */
    class AliyunLog extends Component
    {
        /**
         * 服务入口: https://help.aliyun.com/document_detail/29008.html
         * @var string
         */
        public $endPoint = 'cn-shanghai-intranet.log.aliyuncs.com';
        public $ak;
        public $sk;
        public $token = '';
        public $project;
        public $logStore;
        public $topic = 'TestTopic';
        /** @var \Aliyun_Log_Client $client */
        public $client;
    
        public function init()
        {
            $this->client = new \Aliyun_Log_Client(
                $this->endPoint,
                $this->ak,
                $this->sk,
                $this->token
            );
        }
    
        public function putLogs(array $logs)
        {
            $logitems = [];
            foreach ($logs as $log) {
                $logItem = new \Aliyun_Log_Models_LogItem();
                $logItem->setTime(time());
                $logItem->setContents($log);
                $logitems[] = $logItem;
            }
    
            $request = new \Aliyun_Log_Models_PutLogsRequest(
                $this->project,
                $this->logStore,
                $this->topic,
                null,
                $logitems
            );
    
            $this->client->putLogs($request);
        }
    }
    
    <?php
    namespace common\components;
    
    use yii\di\Instance;
    use yii\helpers\VarDumper;
    use yii\log\Logger;
    use yii\log\Target;
    
    class AliyunLogTarget extends Target
    {
        /** @var AliyunLog $log */
        public $log = 'aliyunLog';
        public $project;
        public $logStore;
        public $topic;
    
        public function init()
        {
            $this->log = Instance::ensure($this->log);
        }
    
        public function export()
        {
            $rows = [];
            foreach ($this->messages as $message) {
                list($text, $level, $category, $timestamp) = $message;
                $level = Logger::getLevelName($level);
                if (!is_string($text)) {
                    // exceptions may not be serializable if in the call stack somewhere is a Closure
                    if ($text instanceof \Throwable || $text instanceof \Exception) {
                        $text = (string) $text;
                    } else {
                        $text = VarDumper::export($text);
                    }
                }
                $rows[] = [
                    'level' => $level,
                    'category' => $category,
                    'prefix' => $this->getMessagePrefix($message),
                    'message' => $text,
                ];
            }
    
            if ($this->project) {
                $this->log->project = $this->project;
            }
            if ($this->logStore) {
                $this->log->logStore = $this->logStore;
            }
            if ($this->topic) {
                $this->log->topic = $this->topic;
            }
            $this->log->putLogs($rows);
        }
    }
    
    ...
        'components' => [
            'log' => [
                'targets' => [
                    [
                        'class' => 'yii\log\FileTarget',
                        'levels' => ['error', 'warning'],
                    ],
                    [
                        'class' => \common\components\AliyunLogTarget::class,
                        'levels' => ['info', 'warning', 'error'],
                        'except' => $_info_except,
                        'logVars' => [],
                        'exportInterval' => YII_ENV_PROD ? 1000 : 1,
                        'topic' => 'console',
                    ],
                ],
            ],
        ],
    ...
    

    来张效果图:

    aliyun-log-yii

    题外: 什么是好的SDK

    yii| 最佳实践之黑箱思维 里我还提到如何判断 好的sdk:

    好用的 SDK, 只用看一下 sample 或者 quick start 就能分辨出来.

    不过这次实践下来, 我要收回这句话, 从 「进化论」 的角度来看才更趋于真理:

    好用的 sdk, 应该是能跟上社区最佳标准与实践, 不断进化的.

    说一下项目实践中遇到的问题: 同时使用 阿里云日志服务和OSS服务的sdk, 而 2 这的 sdk 中都定义了 RequestCore 来作为 http 请求基类, 导致类冲突

    <?php
    /**
     * Copyright (C) Alibaba Cloud Computing
     * All rights reserved
     */
    $version = '0.6.0';
    function Aliyun_Log_PHP_Client_Autoload($className) {
        $classPath = explode('_', $className);
        if ($classPath[0] == 'Aliyun') {
            if(count($classPath)>5)
                $classPath = array_slice($classPath, 0, 5);
            if(strpos($className, 'Request') !== false){
                $lastPath = end($classPath);
                array_pop($classPath);
                array_push($classPath,'Request');
                array_push($classPath, $lastPath);
            }
            if(strpos($className, 'Response') !== false){
                $lastPath = end($classPath);
                array_pop($classPath);
                array_push($classPath,'Response');
                array_push($classPath, $lastPath);
            }
            $filePath = dirname(__FILE__) . '/' . implode('/', $classPath) . '.php';
            if (file_exists($filePath))
                require_once($filePath);
        }
    }
    spl_autoload_register('Aliyun_Log_PHP_Client_Autoload');
    
    require_once realpath(dirname(__FILE__) . '/../Log_Autoload.php');
    

    本来只是想对现有日志功能进行改造, 要是导致原有的 OSS 功能不能用了, 那就不好了. 基于此, 就动了直接接入日志服务 api 的念头:

    public function actionAliyunlog2()
    {
        $ak = 'bq2sjzesjmo86kq35behupbq';
        $sk = '4fdO2fTDDnZPU/L7CHNdemB2Nsk=';
    
        // 服务入口: https://help.aliyun.com/document_detail/29008.html
        $project = 'test-project';
        $endpoint = 'cn-hangzhou-devcommon-intranet.sls.aliyuncs.com';
    
        // 请求签名: https://help.aliyun.com/document_detail/29012.html
        // get
        $httpMethod = 'GET';
        $contentMd5 = '';
        $contentType = '';
        $gmDate = 'Mon, 09 Nov 2015 06:11:16 GMT';
        $logHeaders = [
            'x-log-apiversion:0.6.0',
            'x-log-signaturemethod:hmac-sha1',
        ];
        $logHeadersStr = join("\n", $logHeaders);
        $logResource = '/logstores?' . http_build_query(['logstoreName' => '', 'offset' => 0, 'size' => 1000]);
        $signStr = $httpMethod . "\n" . $contentMd5 . "\n" . $contentType . "\n" . $gmDate . "\n" .
            $logHeadersStr . "\n" . $logResource;
        $sign = base64_encode(hash_hmac('sha1', $signStr, $sk, true));
    
        // 公共请求头: https://help.aliyun.com/document_detail/29010.html
        $headers = [
            "Date: $gmDate",
            "Host: {$project}.{$endpoint}",
            "Authorization:LOG {$ak}:{$sign}",
        ];
        $headers = array_merge($headers, $logHeaders);
    
        // post
        // 数据编码方式 - protobuf: https://help.aliyun.com/document_detail/29055.html
        $body = [
            'TestKey' => 'TestContent',
        ];
        $contents = [];
        foreach ($body as $k => $v) {
            $content = new \Protobuf\Aliyunlog\Log_Content();
            $content->setKey($k);
            $content->setValue($v);
            $contents[] = $content;
        }
        $log = new \Protobuf\Aliyunlog\Log();
        $log->setTime(1447048976);
        $log->setContents($contents);
        $logGroup = new \Protobuf\Aliyunlog\LogGroup();
        $logGroup->setLogs([$log]);
        $logGroup->setTopic('');
        $logGroup->setSource('10.230.201.117');
        $bodyProto = $logGroup->serializeToString();
    
        $httpMethod = 'POST';
        $contentMd5 = strtoupper(md5($bodyProto));
        $contentType = 'application/x-protobuf';
        $contentLen = strlen($bodyProto);
        $gmDate = 'Mon, 09 Nov 2015 06:11:16 GMT';
        $logHeaders = [
            'x-log-apiversion:0.6.0',
            'x-log-signaturemethod:hmac-sha1',
        ];
        $logHeadersStr = join("\n", $logHeaders);
        $logResource = '/logstores?' . http_build_query(['logstoreName' => '', 'offset' => 0, 'size' => 1000]);
        $signStr = $httpMethod . "\n" . $contentMd5 . "\n" . $contentType . "\n" . $gmDate . "\n" .
            $logHeadersStr . "\n" . $logResource;
        $sign = base64_encode(hash_hmac('sha1', $signStr, $sk, true));
    
        $headers = [
            "Date: $gmDate",
            "Host: {$project}.{$endpoint}",
            "Authorization:LOG {$ak}:{$sign}",
            "Content-MD5: $contentMd5",
            "Content-Length: $contentLen",
        ];
        $headers = array_merge($headers, $logHeaders);
    }
    

    事实证明 我还是太年轻了, 日志传输用的 protobuf. 这东西说实话并不难, 之前的服务器系列有protobuf 的入门使用(blog - 服务器开发系列 1), 无非是安装一个 protobuf 的编译器(protoc), 然后安装一个protobuf的解析器(对应 php 中的 ext-protobuf 扩展)

    message Log
    {
        required uint32 time = 1; // UNIX Time Format
        message Content
        {
            required string key = 1;
            required string value = 2;
        }
        repeated Content contents= 2;
    }
    message LogGroup
    {
        repeated Log logs= 1;
        optional string reserved =2; // 内部字段,不需要填写
        optional string topic = 3;
        optional string source = 4;
    }
    message LogGroupList
    {
        repeated LogGroup logGroupList = 1;
    }
    
    syntax="proto3";
    package Protobuf.Aliyunlog;
    
    message Log
    {
        uint32 time = 1; // UNIX Time Format
        message Content
        {
            string key = 1;
            string value = 2;
        }
        repeated Content contents= 2;
    }
    message LogGroup
    {
        repeated Log logs= 1;
        string reserved =2; // 内部字段,不需要填写
        string topic = 3;
        string source = 4;
    }
    message LogGroupList
    {
        repeated LogGroup logGroupList = 1;
    }
    

    导致的结果就是, protobuf序列化的数据大小, 和 demo 对上, api 自然就不通了. 而官方 SDK 中, 是用 pack() 自己一点点实现的. 这事我在刚接触服务器开发的时候也干过...

    不过好在, OSS的SDK按照 psr-4 标准进行组织了, 引入命名空间后就不会有现在类冲突的尴尬了.

    写在最后

    日志服务是一个深究起来还颇为复杂的话题, 重要实践, 让日志真正起到 系统保驾护航异常时还原犯罪现场的作用

    推荐资源:

    相关文章

      网友评论

        本文标题:devops| 日志服务实践

        本文链接:https://www.haomeiwen.com/subject/hhgclftx.html