大规模处理S3服务器访问日志
引言
Yelp严重依赖亚马逊简单存储服务来存储各种数据,如图像、日志、数据库备份等。由于数据存储在云端,我们需要仔细管理这些数据的访问、安全和最终删除,以控制成本并维持高水平的安全与合规性。管理S3存储桶的核心挑战之一是了解谁在访问您的数据、访问频率以及目的。没有强大的日志记录,就很难排除访问故障、响应安全事件并确保只保留实际需要的数据。这是许多使用S3的公司面临的挑战。
历史上,启用S3服务器访问日志并不简单。原始日志存储成本高昂,读取速度慢,并且某些亚马逊云科技功能(如S3服务器访问日志的基于日期的分区)直到2023年11月才添加,而这对于Athena查询至关重要。因此,由于成本和复杂性,追踪每个S3对象访问的对象级日志一直难以证明其合理性。然而,随着我们的运营需求和行业期望的发展,对更好日志记录的需求变得清晰。对象级日志现在帮助我们排除权限问题、识别未使用的数据以进行清理,并向第三方证明我们正在负责任地管理敏感数据。
在这篇文章中,我们介绍了如何克服存储和数据管理挑战、哪些方法有效(哪些无效),以及我们在规模化运营S3日志记录方面学到的经验。随着我们继续收集更多历史数据,我们很高兴看到这些功能将如何实现新的工作流程并改善我们的数据安全状况。
什么是S3服务器访问日志?
S3服务器访问日志包含对存储桶及其对象执行的API操作。通过提供存储目标(建议使用另一个S3存储桶,以避免循环日志记录),可以为每个S3存储桶启用日志记录。一旦资源策略允许放置对象,日志就会开始到达配置的目标。SAL日志行可能如下所示,
4aaf3ac1c03c23b4 yelp-bucket [20/Nov/2024:00:00:00 +0000] 10.10.10.10 - 09D73HFNSX17KY60 WEBSITE.GET.OBJECT foo.xml.gz "GET /foo.xml.gz HTTP/1.0" 200 - 587613 587613 83 79 "-" "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) Chrome/116.0.1938.76 Safari/537.36" - <host-id> - - - yelp-bucket.s3-website-us-west-2.amazonaws.com - - -
以下是部分字段更易读的表格视图:
| file_bucket_name | remoteip | requester | requiestid | operation | key |
|---|---|---|---|---|---|
| yelp-bucket | 10.10.10.10 | - | 09D73HFNSX17KY60 | WEBSITE.GET.OBJECT | foo.xml.gz |
访问日志的交付是最佳效果,意味着偶尔可能会丢失日志、延迟到达或出现重复。根据我们的证据,只有不到0.001%的SAL日志会在几天后才到达。例如,我们曾观察到一些日志在9天后才到达。
为什么我们需要它们(如何使用它们)
对象级日志记录改善了调试和事件响应流程,并允许识别未使用的文件以便删除。以下是您可能的使用方式。
调试
我们的系统有时有很多抽象层,用户经常难以调试访问问题。这些服务器端日志对于加速调试过程非常有帮助。通常的调试场景是查询特定时间戳和对象前缀,因为这是调试此类问题时已知的两个事实。通过查询结果,您可以确认您的系统是否确实使用了正确的身份、是否访问了预期的对象、以及是否被拒绝:
|
|
成本归属
假设您有一个成本巨大的S3存储桶,但无法确定哪个服务生成了所有调用,您可以使用类似这样的查询来找出主要原因:
|
|
这将按IAM角色请求者对所有API调用进行分组。您距离计算每个请求者的成本又近了一步!如果您使用IAM用户,也可以进行类似的操作。
事件响应
如果您发现有关S3访问的入侵迹象,您可以使用任何与入侵相关的指纹来筛选SAL。无论是IP地址、用户代理字段还是请求者字段,您都可以查看哪些数据被访问并评估攻击规模。以下是一个初步探索的示例,了解自首次已知入侵迹象以来,受入侵角色访问了哪些存储桶。
|
|
数据保留
通过将S3清单与S3服务器访问日志结合,您可以找出存储桶中未使用的数据,并根据其最后访问时间删除。这对于多租户存储桶或与复杂系统相关的存储桶非常有用,在这些系统中,过期的生命周期策略可能过于宽泛而无法安全使用。由于我们的保留期远长于最大日志延迟,我们在删除未使用对象时放心依赖S3服务器访问日志的尽力而为交付。此外,删除是基于前缀的,因此特定前缀的所有日志丢失只会发生在真正不活动的数据上。
Parquet格式来救场
Parquet是一种列式数据格式,将列存储在行组中,每个列由页面组成并按顺序排列,非常适合压缩。它包含元数据,允许根据过滤条件跳过行组或页面,从而减少扫描的数据量。
由于S3服务器访问日志会持续写入目标存储桶,从而产生许多小的S3对象。这些小对象带来了重大挑战。它们不仅使我们难以轻松压缩,还严重影响了针对原始日志的Athena查询时间,并使向其他存储类别的过渡成本超出必要。因此,合并成更少的对象可以显著减少存储量,只需将所有日志放在更少的对象中即可。
替代方案
作为替代方案,AWS提供了将对象级事件记录为CloudTrail数据事件的现成解决方案,但您需要为每个数据事件付费,因此成本要高得多:每百万个数据事件1美元,这可能高出几个数量级!
日志量
我们每天生成数TiB的S3服务器访问日志。通过将原始对象批量转换为parquet格式对象(我们称之为“压缩”),我们能够将存储大小减少85%,对象数量减少99.99%。压缩后的日志对于查询小时间窗口很有用,但对于其他用例,日志会使用聚合进一步减少。例如,我们为长时间范围内的可查询性创建了额外的基于访问的表。
架构
我们受到一篇AWS博客文章的启发,该文章描述了一种可能的架构,用于确定存储桶上未使用的对象。其思想是在S3清单和S3服务器访问日志之间执行差集操作,以确定一段时间内未使用的对象。我们的工作侧重于流程的压缩部分,因为它涉及设置Glue表、运行插入查询以及通过已插入的标签使对象过期,这基本上触及了所有主要问题。
每个受监控的存储桶都有一个目标存储桶,该存储桶必须存在于同一账户和区域,以消除跨区域数据费用。Tron是Yelp内部的批处理系统,每天运行,通过Athena插入查询将前一天的日志转换为parquet格式。我们使用Lambda来枚举所有可能的存储桶名称,因为我们选择使用基于枚举类型的投影分区。因此,压缩后,parquet格式的日志被整合到一个账户中。大部分精力都花在了使流程健壮化上,以便不需要人工干预。
删除压缩日志的机制
成功插入后,底层SAL日志的S3对象将通过生命周期策略标记为过期。存储桶需要有一个基于标签删除对象的S3生命周期策略,并允许实体标记对象。这是在不需每次修改生命周期策略或发出删除API调用的情况下,按对象删除的唯一可扩展方式。
至于插入后到达的日志,我们决定忽略滞后的日志,以便及时交付业务价值。滞后的日志可以在标记的对象过期后稍后插入。
基础设施设置
Terraform管理
我们使用单个AWS账户在不同账户之间进行查询。这样做的好处是,您可以使用单一角色访问其他账户的资源,而无需在AWS控制台之间切换角色或账户。这就是Terraform等资源管理工具派上用场的地方,尽管配置访问仍然需要操作负载。我们为每个账户每个区域创建Glue表,以便在Athena中查询SAL日志,特别注意input.regex中的日志格式。然后,SAL日志被转换为parquet格式的对象。
TargetObjectKeyFormat的格式
第一步是通过为访问日志创建目标存储桶来启用访问日志记录。但首先,我们必须处理已经启用了SAL的存储桶。这些存储桶使用了SimplePrefix的默认格式,产生扁平的前缀结构。
[TargetPrefix][YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString]
不可避免地,对于这些使用SimplePrefix的存储桶,累积的日志量变得无法在Athena中查询,因为会遇到S3 API速率限制。由于无法减慢Athena的读取速度,数据分区是解决问题的方法之一。
PartitionedPrefix提供了更多信息用于数据分区,特别是账户ID、区域和存储桶名称。
[TargetPrefix][SourceAccountId]/[SourceRegion]/[SourceBucket]/[YYYY]/[MM]/[DD]/[YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString]
一旦我们在Terraform模块中将PartitionedPrefix格式设为默认,我们就迁移了现有的目标前缀,以便新日志使用正确的格式。对于交付选项,我们选择了EventTime交付,因为它具有将日志归属于事件时间的优点。
读取S3服务器访问日志
在以下部分中,我们介绍了读取SAL日志的模式属性。
分区
我们选择了Glue的投影分区而不是托管分区,因为它需要刷新分区(使用MSCK REPAIR或ALTER TABLE等命令),随着分区数量的增长,这会变得很繁琐。这可能导致查询计划时间增加(由于元存储查找),并且需要通过分区索引来解决。
通过分区投影,我们利用已知的日志前缀格式避免了这种开销。
'storage.location.template'='s3://<destination-bucket>/0123456789012/us-east-1/${bucket_name}/${timestamp}'
其中,bucket_name分区键使用enum,以便将所有可能的存储桶名称写入其中。而timestamp分区键涵盖一整天,即yyyy/MM/dd,这加速了查询修剪时间,因为我们通常查询一天的日志。使用更细粒度的分区会导致过度分区。
主要风险是意外地在查询系统中遗漏新存储桶的日志。由于我们选择了enum分区类型以便于跨所有存储桶查询,因此Glue表中bucket和account的分区值由一个Lambda作业保持最新。Lambda从SQS队列读取数据,该队列由定期的EventBridge规则填充——队列是为了避免并发读写。
替代方案injected类型可以缓解枚举所有可能值的需要,但需要在where子句中指定一个值。我们决定对基于访问的表使用injected类型,因为它们有特定的用例。另一个考虑是,如果enum类型分区在where子句中不受约束,查询可能在长时间窗口内遇到超过100万个分区的上限。
input.regex中的日志格式
一旦您有了具有分区前缀的日志,就可以提供一个正则表达式,让Glue表读取日志。
|
|
虽然该模式可能难以阅读,但很快就可以清楚地看到,如果双引号内有双引号或空格(对于([^ ]*)),像\"[^\"]*\"这样的模式将无法匹配。我们意识到有些字段是用户控制的,这意味着用户可以为request_uri、referrer和user_agent等字段提供任何字符,这些字符在写入时没有任何编码。
这是一个使用referrer HTTP头破坏上述正则表达式的简单方法,
|
|
生成的日志中,双引号(“)出现在字段内,没有任何编码或转义。
e383838383838 foo-bucket [28/Nov/2024:04:19:03 +0000] 1.1.1.1 - 404040404040 REST.GET.OBJECT foo.txt "GET /foo-bucket/foo.txt HTTP/1.1" 403 AccessDenied 243 - 10 - ""a b" "c" ""d e" "f" - <host-id> - TLS_AES_128_GCM_SHA256 - s3-us-west-2.amazonaws.com TLSv1.3 - -
毫不奇怪,我们在request_uri和user_agent字段中也遇到了其他包含恶意文本的日志,这些日志也破坏了正则表达式匹配。
e383838383838 foo-bucket [20/Nov/2024:22:57:55 +0000] 1.1.1.1 - 404040404040 WEBSITE.GET.OBJECT public/wp-admin/admin-ajax.php "GET /public/wp-admin/admin-ajax.php?action=ajax_get&route_name=get_doctor_details&clinic_id=%7B"id":"1"%7D&props_doctor_id=1,2)+AND+(SELECT+42+FROM+(SELECT(SLEEP(6)))b HTTP/1.1" 404 NoSuchKey 558 - 26 - "-" "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0" - <host-id> - - - foo-bucket.s3-website-us-west-2.amazonaws.com - - -
e383838383838 foo-bucket [01/Jan/2024:01:01:01 +0000] 1.1.1.1 - 404040404040 REST.GET.OBJECT debug.cgi "GET /debug.cgi HTTP/1.1" 403 AccessDenied 243 - 13 - "() { ignored; }; echo Content-Type: text/html; echo ; /bin/cat /etc/passwd" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4867.123 Safari/537.36" - <host-id> - - - foo-bucket.s3.amazonaws.com - - -
本质上,任何用于解析S3服务器访问日志的正则表达式模式都可以被包含分隔符的反例破坏。
幸运的是,前七个字段不是用户控制的;对于大多数操作,key是双重URL编码的——参见密钥编码的特殊性。由于其余字段对我们来说不如requestid和key重要,非编码字段被包装在一个可选的非捕获组中,即(?:<rest-of-fields>)?。因此,即使其余字段的正则表达式匹配失败,我们也能确保前七个字段存在。一个重要的启示是,如果行为空,则说明正则表达式未能解析该行——所以不要忽略这些行!
对于任何想知道为什么正则表达式以.*$结尾的人:这是为了解释任何时候都可能添加额外列的可能性。
SAL日志的压缩
Athena的限制
我们利用了并行发出Athena查询的优势,但Athena是共享资源,因此查询可能因集群过载或偶尔触及S3 API限制而被终止。鉴于我们的规模,我们会经常遇到此类错误。因此,我们需要一种方法来重试那些受到活动Athena查询数量配额限制的查询。
在初始运行期间,我们遇到了TooManyRequestsException Athena异常,我们通过减少并发查询和为每个受影响的账户和区域请求增加活动数据操作语言(DML)查询来解决。
队列处理
为了利用每个存储桶插入的隔离性,我们使用异步函数建模了一个队列处理流程。
位置查询
首先,位置查询获取保存SAL日志的对象,这些对象在成功插入后被标记为过期。位置可以使用$path变量和一些字符串操作来获取,以供S3批处理操作使用。
|
|
我们首先在队列过程中获取位置,因为它避免了标记未插入的日志。
幂等插入
然后,SAL日志被插入到parquet格式的表中。一个简单的插入查询被修改为在同一分区上使用左连接,因此如果有匹配项,则不会第二次插入行。
|
|
现在,我们可以重新运行插入,而不必担心再次插入相同的数据。拥有requestid列非常方便。对于没有唯一列的表,我们使用了行值的校验和。复制bucket_name和timestamp的过滤器是明智的,因为每次插入后查询的运行时间都会变长。关于我们如何将存储桶映射到Glue数据目录,请参见将存储桶映射到Glue数据目录。
得益于幂等的Athena查询,我们的系统是重试安全的,任何故障都可以通过重新运行一天的数据作业来修复。
S3批处理操作
最后,为S3批处理操作创建清单(要标记的对象列表),以便在验证了源分区和目标分区的计数后使SAL对象过期。
由于S3批处理操作不支持删除,并且即使使用批处理删除API,每个对象发出一个删除API请求也无法很好地扩展,我们对SAL日志量高的分区使用对象标记。
S3作业对每个存储桶产生0.25美元的固定费用,这成为最大的成本贡献者,因此我们直接为生成低阶访问日志的大多数存储桶标记对象。
另一个挑战是,Athena查询结果包含标题行,S3批处理操作会将其解释为存储桶名称——导致作业失败。为了解决这个问题,我们在内存中重新创建没有标题的清单文件。我们还确保在将对象键传递给S3批处理操作之前,它们是经过正确URL编码的(相当于Python中的quote_plus(key, safe="/"))。
将S3清单与S3服务器访问日志结合
基于访问的表每周收集一次数据,通过S3清单和一周的访问日志之间的差集来确定“前缀”访问。这确保了长期历史数据的可查询性,并且存储占用空间小。
前缀仅覆盖其下的直接键,由斜杠(/)分段,并删除尾随斜杠,因为我们想避免混淆,即前缀“/foo”将确定键“/foo/”是否被访问。
|
|
能够从密钥中确定前缀对于连接操作至关重要。由于Athena分布式节点计算的性质,我们发现基于等式的连接可以更有效地跨节点分配工作,而基于LIKE的连接则会采用跨所有节点的交叉连接。例如,在一个查询约70,000行的查询中,仅将LIKE操作符切换为相等(=)操作符,执行时间就从超过5分钟减少到仅2秒——这是一个巨大的改进。
一旦我们收集了历史数据,来自基于访问的表的前缀会与S3清单结合,以获取S3批处理操作所需的完整S3对象名称。
未来工作
该项目旨在帮助缩小当前未对所有对象应用生命周期过期策略的S3存储桶的差距。我们现在使用基于访问的保留来删除未使用的对象。但也有一些有效的例外情况,例如存储表增量更改的备份存储桶可能包含看似未访问的对象,而实际上这表示该表的数据没有更改。
作为未来的潜在增强,我们希望通过将S3服务器访问日志转发到我们的数据可观测性平台Splunk,使其易于工程师使用。这将需要减少数据量以满足每日摄取限制,并应用较短的保留期,这对于调试目的仍然足够。一旦摄取,日志可以比通过Athena更有效地查询,提供更快速、更具成本效益的故障排除体验。
致谢
通过Athena将纯文本日志转换为parquet格式的S3对象的想法来自Nick Del Nano。整个工作始于与Thomas Robinson、Quentin Long和Vincent Thibault一起的“unhack”工作,该工作表明存储成本可以降低到可管理的水平。
非常感谢Vincent Thibault为所有存储桶启用日志记录、处理动态分区并提出发出查询的异步方法,从而设置了初始基础设施。此外,Daniel Popescu帮助进行了故障排除,并为未来的值班人员提供了减少操作负载的想法。Quentin Long是许多设计决策背后的指导力量,并界定了问题空间的范围。最后但同样重要的是,Brian Minard使我们按计划实现了里程碑目标,并为项目完成交付了业务所需的价值。
附录
密钥编码的特殊性
最初,我们根据遇到的数据认为密钥总是双重URL编码的。因此,使用url_decode(url_decode(key))是有效的,直到我们看到对于某些操作,如BATCH.DELETE.OBJECT和S3.EXPIRE.OBJECT,密钥是单次URL编码的。因此,对包含百分号(%)字符的密钥进行两次解码会失败。进一步观察后,我们得出结论,生命周期操作很可能是单次URL编码的,因为它们来自内部服务。您可能认为回退到单次URL解码就足够了,但请考虑访问日志中的密钥是foo-%2525Y的情况——解码两次成功,即使其实际对象名称可能不是它,即foo-%Y与foo-%25Y。
将存储桶映射到Glue数据目录
我们利用Glue数据目录跨不同AWS账户读写表。这当然需要跨账户访问Glue目录以及数据库和表,这意味着两个账户都需要允许访问Glue资源,以及访问底层存储访问日志的S3对象的S3权限。从其他账户查询数据涉及注册一个AWS Glue数据目录,用作from子句中的from-item。
FROM "catalog"."database"."table_region"
我们需要从存储桶名称到账户ID,再到目录名称的映射。幸运的是,从存储桶名称获取账户ID不需要额外的工作,因为我们有来自先前项目的数据可用。账户ID和数据目录名称之间的映射通过ListDataCatalogs API调用生成。
表和数据库的名称本可以在所有账户和区域中保持不变,但我们选择将信息嵌入名称中以使其明确;这有时会适得其反,当查询产生0结果时,您会意识到该存储桶存在于不同的账户或区域中!
验证表计数
Athena API调用GetQueryRuntimeStatistics可以帮助避免查询表计数,因为它提供了带有扫描行数的Rows统计信息;它仅对空白插入准确,因为使用JOIN将包含所有连接表的行数。对于初始插入,我们将从API获得的行扫描数(从而跳过对SAL对象的昂贵查询)与压缩表的计数查询进行比较,后者由于parquet格式而很快。API统计信息是异步提供的,但有时Rows键可能根本没有填充。在这种情况下,我们会回退到计数查询。
对于重新插入,我们使用不同的计数查询,因为重复的日志不会插入到压缩表中,这会使简单计数查询的计数不匹配。在极少数情况下,日志可能在插入和计数查询之间到达,那么插入查询需要再次运行。