前言
小明是个单纯的. NET 开发, 一天大哥叫住他, 安排了一项任务:
- "小明, 分析一下我们超牛逼网站上个月的所有 AWS ELB 流量日志, 这些日志保存在 AWS S3 上, 你分析下, 看哪个 API 的响应时间中位数最长."
- "对了, 别用 Excel, 哥给你写好了一段 Python 脚本, 可以自动解析统计一个 AWS ELB 文件的日志, 你可以利用一下."
- "好的, 大哥真厉害!".
小明看了一下, 然后傻眼了, 在管理控制台中, 九月份 AWS ELB 日志文件翻了好几页都没翻完, 大概算算, 大概有 1000 个文件不止. 想想自己又不懂 Python, 又不是搞数据分析专业出身的, 这个 "看似简单" 的工作完不成, 这周怕是陪不了女朋友, 搞不好还要 996.ICU, 小明几乎要流下了没有技术的泪水......
不怕! 会. NET 就行!
要完成这项工作, 光老老实实将文件从管理控制台下载到本地, 估计都够喝一壶. 若小明稍机灵点, 他可能会找到 AWS S3 的文件管理器, 然后...... 发现只有付费版才有批量下载功能.
其实要完成这项工作, 只需做好两项基本任务即可:
从 AWS S3 下载 9 月份的所有 ELB 日志
聚合并分析这 1000 多个日志文件, 然后按响应时间中位数倒排序
AWS 资源
能在管理控制台上看到的 AWS 资源, AWS 都提供了各语言的 SDK 可供操作 (可在 SDK 上操作的东西, 如批量下载, 反倒不一定能在界面上看到).SDK 支持多种语言, 其中(显然) 也包括. NET.
对于 AWS S3 的访问, Amazon 提供的 NuGet 包叫: AWSSDK.S3, 在 Visual Studio 中下载并安装, 即可运行本文的示例.
要使用 AWSSDK.S3, 首先需要实例化一个 AmazonS3Client, 并传入 AWS access key,AWS secret key,AWS 区域等参数:
- var credentials = new BasicAWSCredentials(
- Util.GetPassword("aws_live_access_key"),
- Util.GetPassword("aws_live_secret_key"));
- var s3 = new AmazonS3Client(credentials, RegionEndpoint.USEast1);
注意: 本文的所有代码全部共享这一个 s3 的实例. 因为根据文档, AmazonS3Client 实例是设计为线程安全的.
在下载 AWS S3 的文件 (对象) 之前, 首先需要知道有哪些对象可供下载, 可通过 ListObjectsV2Async 方法列出某个 bucket 的文件列表. 注意该方法是分页的, 经我的测试, 无论 MaxKeys 参数设置多大, 该接口最多一次性返回 1000 条数据, 但这显然不够, 因此需要循环分页去拿.
分页时该响应对象中包含了 NextContinuationToken 和 IsTruncated 属性, 如果 IsTruncated=true, 则 NextContinuationToken 必定有值, 此时下次调用 ListObjectsV2Async 时的请求参数传入 NextContinuationToken 即可实现分页获取 S3 文件列表的功能.
这个过程说起来有点绕, 但感谢 C# 提供了 yield 关键字来实现协程 - coroutine, 代码写起来非常简单:
- IEnumerable<List<S3Object>> Load201909SuperCoolData(AmazonS3Client s3)
- {
- ListObjectsV2Response response = null;
- do
- {
- response = s3.ListObjectsV2Async(new ListObjectsV2Request
- {
- BucketName = "supercool-website",
- Prefix = "AWSLogs/1383838438/elasticloadbalancing/us-east-1/2019/09",
- ContinuationToken = response?.NextContinuationToken,
- MaxKeys = 100,
- }).Result;
- yield return response.S3Objects;
- } while (response.IsTruncated);
- }
注意: Prefix 为前缀, AWS ELB 日志都会按时间会有一个前缀模式, 从文件列表中找到这一模式后填入该参数.
接下来就简单了, 通过 GetObjectAsync 方法即可下载某个对象, 要直接分析, 最好先转换为字符串, 拿到文件流 stream 后, 最简单的方式是使用 StreamReader 将其转换为字符串:
- IEnumerable<string> ReadS3Object(AmazonS3Client s3, S3Object x)
- {
- using GetObjectResponse obj = s3.GetObjectAsync(x.BucketName, x.Key).Result;
- using var reader = new StreamReader(obj.ResponseStream);
- while (!reader.EndOfStream)
- {
- yield return reader.ReadLine();
- }
- }
注意:
GetObjectAsync 方法返回的 GetObjectResponse 类实现了 IDisposable 接口, 因为它的 ResponseStream 实际上是非托管资源, 需要单独释放. 因此需要使用 using 关键字来实现资源的正确释放.
可以直接调用
StreamReader.ReadToEnd()
方法直接获取全部字符串, 然后再通过 Split 将字符串按行分隔, 但这样会浪费大量内存, 影响性能.
这时一般会将这个 stream 缓存到本地磁盘以供慢慢分析, 但也可以一鼓作气直接将该 stream 转换为字符串直接分析. 本文将采取后者做法.
分析 1000 多个文件
每个 ELB 日志文件的格式如下:
- 2019-08-31T23:08:36.637570Z SUPER-COOLELB 10.0.2.127:59737 10.0.3.142:86 0.000038 0.621249 0.000041 200 200 6359 291 "POST http://super-coolelb-10086.us-east-1.elb.amazonaws.com:80/api/Super/Cool HTTP/1.1" "-" - -
- 2019-08-31T23:28:36.264848Z SUPER-COOLELB 10.0.3.236:54141 10.0.3.249:86 0.00004 0.622208 0.000045 200 200 6359 291 "POST http://super-coolelb-10086.us-east-1.elb.amazonaws.com:80/api/Super/Cool HTTP/1.1" "-" - -
可见该日志有一定格式, Amazon 提供了该日志的详细文档中文说明: https://docs.aws.amazon.com/zh_cn/elasticloadbalancing/latest/application/load-balancer-access-logs.html#access-log-entry-format
根据文档, 这种日志可以通过按简单的空格分隔来解析, 但后面的 RequestInfo 和 UserAgent 字段稍微麻烦点, 这种可以使用正则表达式来实现比较精致的效果:
- public static LogEntry Parse(string line)
- {
- MatchCollection s = Regex.Matches(line, @"[\""].+?[\""]|[^ ]+");
- string[] requestInfo = s[11].Value.Replace("\"", "").Split(' ');
- return new
- {
- Timestamp = DateTime.Parse(s[0].Value),
- ElbName = s[1].Value,
- ClientEndpoint = s[2].Value,
- BackendEndpoint = s[3].Value,
- RequestTime = decimal.Parse(s[4].Value),
- BackendTime = decimal.Parse(s[5].Value),
- ResponseTime = decimal.Parse(s[6].Value),
- ElbStatusCode = int.Parse(s[7].Value),
- BackendStatusCode = int.Parse(s[8].Value),
- ReceivedBytes = long.Parse(s[9].Value),
- SentBytes = long.Parse(s[10].Value),
- Method = requestInfo[0],
- Url = requestInfo[1],
- Protocol = requestInfo[2],
- UserAgent = s[12].Value.Replace("\"", ""),
- SslCypher = s[13].Value,
- SslProtocol = s[14].Value,
- };
- }
- LINQ
数据下载好了, 解析也成功了, 这时即可通过强大的 LINQ 来进行分析. 这里将用到以下的操作符:
SelectMany 数据 "打平"(和 JS 数组的. flatMap 方法类似)
Select 数据转换(和 JS 数组的. map 方法类似)
GroupBy 数据分组
首先, 通过 AWSSDK 的 ListObjectsV2Async 方法, 获取的是文件列表, 可以通过. SelectMany 方法将多个下载批次 "打平":
- Load201909SuperCoolData(s3)
- .SelectMany(x => x)
然后通过 Select, 将单个文件 Key 下载并读为字符串:
- Load201909SuperCoolData(s3)
- .SelectMany(x => x)
- .SelectMany(x => ReadS3Object(s3, x))
然后再通过 Select, 将文件每一行日志转换为一条. NET 对象:
- Load201909SuperCoolData(s3)
- .SelectMany(x => x)
- .SelectMany(x => ReadS3Object(s3, x))
- .Select(LogEntry.Parse)
有了. NET 对象, 即可利用 LINQ 进行愉快地分析了, 如小明需要求, 只需加一个 GroupBy 和 Select, 即可求得根据 Url 分组的响应时间中位数, 然后再通过 OrderByDescending 即按该数字排序, 最后通过. Dump 显示出来:
- Load201909SuperCoolData(s3)
- .SelectMany(x => x)
- .SelectMany(x => ReadS3Object(s3, x))
- .Select(LogEntry.Parse)
- .GroupBy(x => x.Url)
- .Select(x => new
- {
- Url = x.Key,
- Median = x.OrderBy(x => x.BackendTime).ElementAt(x.Count() / 2)
- })
- .OrderByDescending(x => x.Median)
- .Dump();
运行效果如下:
多线程下载
解析和分析都在内存中进行, 因此本代码的瓶颈在于下载速度.
上文中的代码是串行, 单线程下载, 带宽利用率低, 下载速度慢. 可以改成并行, 多线程下载, 以提高带宽利用率.
传统的多线程需要非常大的功力, 需要很好的技巧才能完成. 但. NET 4.0 发布了 Parallel LINQ, 只需极少的代码改动, 即可享受到多线程的便利. 在这里, 只需将在第二个 SelectMany 后加上一个 AsParallel(), 即可瞬间获取多线程下载优势:
- Load201909SuperCoolData(s3)
- .SelectMany(x => x)
- .AsParallel() // 重点
- .SelectMany(x => ReadS3Object(s3, x))
- .Select(LogEntry.Parse)
- .GroupBy(x => x.Url)
- .Select(x => new
- {
- Url = x.Key,
- Median = x.OrderBy(x => x.BackendTime).ElementAt(x.Count() / 2)
- })
- .OrderByDescending(x => x.Median).Take(15)
- .Dump();
注意: 写 AsParallel()的位置有讲究, 这取决于你对性能瓶颈的把控. 总的来说:
太靠后了不行, 因为 AsParallel 之前的语句都是串行的;
靠前了也不行, 因为靠前的代码往往数据量还没扩大, 并行没意义;
扩展
到了这一步, 如果小明足够机灵, 其实还能再扩展扩展, 将平均值, 总响应时间一并求出来, 改动代码也不大, 只需将下方那个 Select 改成如下即可:
- .Select(x => new
- {
- Url = x.Key,
- Median = x.OrderBy(x => x.BackendTime).ElementAt(x.Count() / 2),
- Avg = x.Average(x => x.BackendTime),
- Sum = x.Sum(x => x.BackendTime),
- })
运行效果如下:
总结
看来并不需要 python, 有了. NET 和 LINQ 两大法宝, 看来小明周末又可以陪女朋友了
来源: https://www.cnblogs.com/sdflysha/p/20191016-avoid-996-using-dotnet-linq.html