date: 2018-4-4 18:28:08
title: devops| 日志服務(wù)實(shí)踐
description: 阿里云日志服務(wù)實(shí)踐: nginx access log; yii 框架接入阿里云日志服務(wù)
提綱:
- 日志服務(wù)功能點(diǎn)一覽
- 阿里云日志服務(wù)實(shí)踐
- 示例一: nginx access log
- 示例二: yii 框架接入阿里云日志服務(wù)
- 再探 protobuf
日志服務(wù)可以說的上是構(gòu)建軟件項(xiàng)目的基石之一, 系統(tǒng)持續(xù)穩(wěn)定運(yùn)行必不可少的一部分. 這里從阿里云日志服務(wù)入手, 借助云平臺(tái)帶來的技術(shù)更新迭代, 聊一聊日志服務(wù)實(shí)踐.
以前日志服務(wù)的打開方式
日志最常見的方式是寫入到文件中. 「小作坊」的情況下, 把服務(wù)器的權(quán)限給開發(fā), 開發(fā)自己 ssh 到服務(wù)器上面用 grep 查日志. 是的, 我就是這樣過來的, 所以常用的幾個(gè) grep 命令, 甚至一些稍微高級(jí)的命令, 還能默寫出來:
grep xxx xxFile # 正則匹配查詢字符串
grep 'xxx xxx' # 查詢包含特殊字符, 比如空格的字符串
grep -i xxx # 忽略大小寫
grep -n xx # 顯示行號(hào)
grep -v xxx # 查詢不包含字符串的行
grep -r xxx xxDir # 在文件夾中遞歸查詢
ps aux | grep xxx | grep -v 'grep' # -v 常用的一種方式
# 2個(gè)復(fù)雜些的例子
# 獲取訪問 ip 統(tǒng)計(jì)
cat /var/log/nginx/access.log|awk '{print $1}'|sort|uniq -c|sort -nr|more
# 獲取 http 狀態(tài)碼
cat /var/log/nginx/access.log|grep -ioE 'HTTP/1.[0|1]" [0-9]{3}'|awk '{print $2}'
grep 查詢可以使用多種 正則 方式: 基礎(chǔ), 擴(kuò)展, perl. 支持的正則功能一次增多, 部分細(xì)節(jié)有些許差異.
-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
一句話概括這種方式: 簡單直接. 當(dāng)然有時(shí)直接 vim 打開, 然后再查看的. 不過數(shù)據(jù)量一大, vim 的速度就不樂觀了. 所以通常會(huì)對(duì)日志文件進(jìn)行 切分, 這樣也便于以后 歸檔:
- 按照業(yè)務(wù)切分: 服務(wù)器各項(xiàng)日志, 不同業(yè)務(wù)模塊的日志, 第三方接口的日志
- 按照時(shí)間維度: 日切
xxx_20180421.log; 月切xxx_201804.log - 按照文件大小: 比如到 1G 了, 從
xxx.log.1到xxx.log.2, 一次遞增 - 多種方式組合使用
文件一多, 查詢就變得困難起來了.
數(shù)據(jù)量大, 還要考慮日志的 寫入性能, 通常的做法是 加緩存: 這里稱之為 刷新(flash):
- 一定時(shí)間間隔寫入一次
- 日志達(dá)到多少條寫入一次
- 日志超過多大寫入一次
開放服務(wù)器 ssh 權(quán)限出來, 會(huì)帶來 安全隱患, 有開發(fā)上去誤操作就不好了. 所以有了新的替代方案:
- 運(yùn)維開日志的 ftp, 需要看日志自己去下載
- 存儲(chǔ)到數(shù)據(jù)庫中, 比如 MongoDB, 走數(shù)據(jù)庫查詢
- 自建日志中心
當(dāng)然 自建日志中心 是最高級(jí)的玩法, 之前鵝廠的分享提到過, 會(huì)走 UDP 進(jìn)行日志的上傳與統(tǒng)一分析.
關(guān)于日志的其他細(xì)節(jié):
- 全鏈路跟蹤: 去年很火的方案, 請(qǐng)求進(jìn)來時(shí)生成一個(gè)
trace_id, 之后的所有調(diào)用都會(huì)帶上這個(gè)trace_id, 這樣就可以在日志中通過trace_id查詢到整個(gè)調(diào)用鏈路 - 安全問題: 日志中可能有用戶未經(jīng)處理的敏感信息, 比如手機(jī)號(hào), 甚至沒有經(jīng)過處理的密碼
- 日志歸檔的問題: 打包歸檔歷史數(shù)據(jù)來降低日志存儲(chǔ)的成本
最后, 對(duì)大部分使用日志的人(通常是開發(fā), 定位 犯罪現(xiàn)場)而言, 好查 好用 才是重中之重, 日志的存儲(chǔ)/歸檔都不用自己操心, 由日志系統(tǒng)來解決.
上手阿里云日志服務(wù)
阿里云的日志服務(wù)上手比較容易, 在控制臺(tái)點(diǎn)點(diǎn)點(diǎn)即可, 大致的分層設(shè)計(jì)如下:
- 開通日志服務(wù): 總的入口
- project: 項(xiàng)目, 第一級(jí)分層,
project + region構(gòu)成 api 的訪問地址 - logStore: 日志存儲(chǔ), 每個(gè) project 下可以建立多個(gè) logstore, logStore 可以配置多個(gè)
shared和 過期時(shí)間 - log data source: 需要為每個(gè) logStore 配置數(shù)據(jù)源
- 日志投遞: 日志數(shù)據(jù)除了供日志服務(wù)消費(fèi)外, 還可以投遞給其他云產(chǎn)品, 比如 OSS 進(jìn)行歸檔處理
- 日志查詢: 重點(diǎn)功能, 包含
searchanalysischart3個(gè)主要部分
配置數(shù)據(jù)源常用的方式:
- nginx access log: 下面還會(huì)詳細(xì)提到
- 文本 + logtail 工具收集 + 自定義日志分割
- sdk 接入
關(guān)于 logtail: 阿里云提供的日志收集工具, 安裝到 ecs 上就可以按照 logStore 配置的日志路徑進(jìn)行搜集
PS: 如果 ecs 和 日志服務(wù)是不同的賬號(hào)下的, 需要配置授權(quán)
日志查詢快捷指南:
- search: 支持部分正則的查詢語法; 直接點(diǎn)擊日志即可查詢
- analysis: 使用
|管道對(duì)查詢結(jié)果進(jìn)行分析; 類 sql 的語法 - chart: 將 analysis 得到的結(jié)果轉(zhuǎn)化為圖表, 更直觀
- 其他小技巧: 保存常用查詢

實(shí)踐一: nginx access log
nginx access log 的接入提供了很好的支持:
- 配置好 logtail 收集 access log
- logStore 中配置
log_format就可以自動(dòng)分割日志建立索引 - 配置常用 查詢/分析/圖表
推薦下面的 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: 細(xì)節(jié)出魔鬼, 之前沒有采用 || 的方式, 導(dǎo)致部分日志解析出現(xiàn)問題, 字段沒有對(duì)上
日志記錄:

配置好圖表:

關(guān)于 request_body:
- 推薦加到
log_format中 - 當(dāng)前 nginx 版本(我的是 1.13) 直接配置上就可以收集
form-data x-www-form-urlencoded application/json等格式的 post 數(shù)據(jù)
如何解析 form-data 格式的數(shù)據(jù):
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 無法記錄, 網(wǎng)上提供了 2 種方案(當(dāng)前版本并不需要):
-
將
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';
實(shí)踐二: 接入日志服務(wù)sdk(以 yii 框架為例)
使用 logtail 來作為數(shù)據(jù)源實(shí)在是 簡單, 搜集的數(shù)據(jù)通過 分隔符 或者 正則 進(jìn)行分割, 有時(shí)候會(huì)很麻煩. 寫過 正則 的人都知道, 正則這東西并不難, 它就是 煩, 稍微有一點(diǎn)點(diǎn)變動(dòng), 正則可能就需要調(diào)整了. 而且用 logtail 來收集日志, 看似和業(yè)務(wù) 比較隔離, 實(shí)際感覺確實(shí) 偏離業(yè)務(wù) 更多一點(diǎn). 接入 日志服務(wù)sdk 會(huì)是一個(gè)不錯(cuò)的選擇.
在之前 yii| 最佳實(shí)踐之黑箱思維 提到過 yii 的日志服務(wù), 這里再簡要復(fù)述一下:
- 分層設(shè)計(jì):
logger - dispatch - target3層,logger專注于日志功能,dispatch來調(diào)度,target來適配不同日志存儲(chǔ) - 日志標(biāo)記: 包括 level / category / tag / perfix 等多種日志標(biāo)記方式, 方便對(duì)日志更細(xì)粒度的控制
- flush, 刷新, 比如 1000 條后再輸出(落地). 緩沖(buffer) 的思想可以說在系統(tǒng)設(shè)計(jì)中比比皆是.
- 切片, 比如說日志按照時(shí)間日切, 或者按照大小 10m 一切
PS: 這就是成熟框架的威力, 常用功能近乎全面無死角的解決掉
具體 yii 中接入, 其實(shí)就是新增一個(gè) target, 通過阿里云日志服務(wù)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';
- 封裝日志服務(wù)sdk到 component: https://gitee.com/daydaygo/yii/blob/master/common/components/AliyunLog.php
?php
namespace common\components;
use yii\base\Component;
/**
* https://github.com/aliyun/aliyun-log-php-sdk
*/
class AliyunLog extends Component
{
/**
* 服務(wù)入口: 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);
}
}
- 添加 AliyunlogTarget: https://gitee.com/daydaygo/yii/blob/master/common/components/AliyunLogTarget.php
<?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);
}
}
- 最后, 把日志服務(wù)配置到想要的地方: https://gitee.com/daydaygo/yii/blob/master/console/config/main.php
...
'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',
],
],
],
],
...
來張效果圖:

題外: 什么是好的SDK
在 yii| 最佳實(shí)踐之黑箱思維 里我還提到如何判斷 好的sdk:
好用的 SDK, 只用看一下 sample 或者 quick start 就能分辨出來.
不過這次實(shí)踐下來, 我要收回這句話, 從 「進(jìn)化論」 的角度來看才更趨于真理:
好用的 sdk, 應(yīng)該是能跟上社區(qū)最佳標(biāo)準(zhǔn)與實(shí)踐, 不斷進(jìn)化的.
說一下項(xiàng)目實(shí)踐中遇到的問題: 同時(shí)使用 阿里云日志服務(wù)和OSS服務(wù)的sdk, 而 2 這的 sdk 中都定義了 RequestCore 來作為 http 請(qǐng)求基類, 導(dǎo)致類沖突
- 日志服務(wù)的 sdk 是自己使用
spl_autoload_register()來做類的自動(dòng)加載: https://github.com/aliyun/aliyun-log-php-sdk/blob/master/Log_Autoload.php
<?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');
- 只能按照 demo 中 require 此 autoload 文件才能使用: https://github.com/aliyun/aliyun-log-php-sdk/blob/master/sample/sample.php#L7
require_once realpath(dirname(__FILE__) . '/../Log_Autoload.php');
本來只是想對(duì)現(xiàn)有日志功能進(jìn)行改造, 要是導(dǎo)致原有的 OSS 功能不能用了, 那就不好了. 基于此, 就動(dòng)了直接接入日志服務(wù) api 的念頭:
public function actionAliyunlog2()
{
$ak = 'bq2sjzesjmo86kq35behupbq';
$sk = '4fdO2fTDDnZPU/L7CHNdemB2Nsk=';
// 服務(wù)入口: https://help.aliyun.com/document_detail/29008.html
$project = 'test-project';
$endpoint = 'cn-hangzhou-devcommon-intranet.sls.aliyuncs.com';
// 請(qǐng)求簽名: 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));
// 公共請(qǐng)求頭: https://help.aliyun.com/document_detail/29010.html
$headers = [
"Date: $gmDate",
"Host: {$project}.{$endpoint}",
"Authorization:LOG {$ak}:{$sign}",
];
$headers = array_merge($headers, $logHeaders);
// post
// 數(shù)據(jù)編碼方式 - 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);
}
事實(shí)證明 我還是太年輕了, 日志傳輸用的 protobuf. 這東西說實(shí)話并不難, 之前的服務(wù)器系列有protobuf 的入門使用(blog - 服務(wù)器開發(fā)系列 1), 無非是安裝一個(gè) protobuf 的編譯器(protoc), 然后安裝一個(gè)protobuf的解析器(對(duì)應(yīng) php 中的 ext-protobuf 擴(kuò)展)
- 但是阿里云官方是 proto2 syntax 語法: https://gitee.com/daydaygo/yii/blob/master/common/protobuf/aliyunlog.proto2
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; // 內(nèi)部字段,不需要填寫
optional string topic = 3;
optional string source = 4;
}
message LogGroupList
{
repeated LogGroup logGroupList = 1;
}
- 而 protoc 用來生成編譯生成PHP文件的命令
protoc -php_out=./ aliyunlog.protoc只支持 proto3 syntax: https://gitee.com/daydaygo/yii/blob/master/common/protobuf/aliyunlog.proto
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; // 內(nèi)部字段,不需要填寫
string topic = 3;
string source = 4;
}
message LogGroupList
{
repeated LogGroup logGroupList = 1;
}
導(dǎo)致的結(jié)果就是, protobuf序列化的數(shù)據(jù)大小, 和 demo 對(duì)上, api 自然就不通了. 而官方 SDK 中, 是用 pack() 自己一點(diǎn)點(diǎn)實(shí)現(xiàn)的. 這事我在剛接觸服務(wù)器開發(fā)的時(shí)候也干過...
不過好在, OSS的SDK按照 psr-4 標(biāo)準(zhǔn)進(jìn)行組織了, 引入命名空間后就不會(huì)有現(xiàn)在類沖突的尷尬了.
寫在最后
日志服務(wù)是一個(gè)深究起來還頗為復(fù)雜的話題, 重要實(shí)踐, 讓日志真正起到 系統(tǒng)保駕護(hù)航 和 異常時(shí)還原犯罪現(xiàn)場的作用
推薦資源: