大规模管理S3服务器访问日志的技术实践

本文详细介绍了Yelp如何解决大规模Amazon S3服务器访问日志的管理挑战,包括通过Parquet格式压缩、使用Athena查询、构建自动化架构来优化存储成本、提升查询性能,并实现基于访问模式的数据生命周期管理。

S3 server access logs at scale

Nurdan Almazbekov, Infrastructure Security

Sep 26, 2025

引言

Yelp 严重依赖 Amazon S3(简单存储服务)来存储各种数据,如图片、日志、数据库备份等。由于数据存储在云端,我们需要仔细管理这些数据的访问、安全和最终删除方式——既要控制成本,又要维护高水平的安全性和合规性。管理 S3 存储桶的核心挑战之一在于了解谁在访问您的数据(即 S3 对象)、访问频率以及访问目的。如果没有健全的日志记录,就很难排查访问问题、响应安全事件,并确保仅保留实际必要的数据。这是许多使用 S3 的公司面临的挑战。

从历史上看,启用 S3 服务器访问日志(SAL)并非易事。原始日志的存储成本高昂,读取速度慢,并且某些 Amazon Web Services(AWS)功能,如对 S3 服务器访问日志进行基于日期的分区(这对 Athena 查询至关重要),直到 2023 年 11 月才添加。因此,出于成本和复杂性的考虑,过去很难为对象级日志记录(即跟踪对每个 S3 对象的访问)提供充分理由。然而,随着我们的运营需求和行业期望的发展,对更好日志记录的需求变得明确。对象级日志记录现在帮助我们排查权限问题、识别未使用的数据以进行清理,并向第三方证明我们正在负责任地管理敏感数据。

在这篇文章中,我们将介绍我们如何克服存储和数据管理挑战、哪些方法有效(哪些无效),以及我们在大规模运营 S3 日志记录方面学到的东西。随着我们继续收集更多历史数据,我们很高兴看到这些功能将如何启用新的工作流程并改善我们的数据安全状况。

什么是 S3 服务器访问日志?

S3 服务器访问日志包含对存储桶及其对象执行的 API 操作。通过提供存储目标位置,可以为每个 S3 存储桶启用日志记录;由于循环日志记录,建议使用另一个 S3 存储桶作为目标。一旦资源策略允许放置对象,日志就会开始到达配置的目标位置。SAL 日志行可能如下所示:

1
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 requestid operation key
yelp-bucket 10.10.10.10 - 09D73HFNSX17KY60 WEBSITE.GET.OBJECT foo.xml.gz

访问日志的交付是尽力而为的,这意味着偶尔可能会丢失日志、日志延迟到达或出现重复。根据我们的证据,少于 0.001% 的 SAL 日志会在几天后到达。例如,我们观察到有些日志在 9 天后才到达。

我们为何需要它们(如何使用它们)

对象级日志记录改善了调试和事件响应流程,并允许识别未使用的文件以便删除。以下是您可能的使用方式。

调试 我们有时会使用具有许多抽象层的系统,使用这些系统的人通常很难调试访问问题。这些服务器端日志非常有助于加快调试过程。通常的场景是根据特定时间戳和对象前缀进行切片,因为这是调试此类问题时已知的两个事实。根据结果,您可以确认系统是否确实使用了正确的身份、是否访问了预期的对象,以及是否被拒绝:

1
2
3
4
5
SELECT *
FROM "catalog"."database"."table"
WHERE bucket_name = 'yelp-bucket'
AND timestamp = '2025/02/05'
AND key = 'object-i-cant-read.txt'

成本归因 假设您有一个成本高昂的 S3 存储桶,但无法确定是哪个服务生成了所有调用,您可以使用类似以下查询来找出主要责任方:

1
2
3
4
5
6
7
8
SELECT
    regexp_extract(requester, '.*assumed-role/([^/]+)', 1) as iam_role, operation, COUNT(*) as api_call_count
FROM "catalog"."database"."table"
WHERE bucket_name = 'yelp-bucket'
    AND timestamp = '2025/02/05'
    AND requester LIKE 'arn:aws:sts::%:assumed-role/%'
GROUP BY 1, operation
ORDER BY api_call_count DESC;

这将按 IAM 角色请求者对所有 API 调用进行分组。您现在离按请求者计算成本仅一步之遥!如果您使用 IAM 用户,也可以进行类似操作。

事件响应 如果您发现与 S3 访问相关的危害指标,可以使用您掌握的关于危害的任何指纹对 SAL 进行切片。无论是 IP 地址、User-Agent 字段还是请求者字段,您都能看到哪些数据被访问并评估攻击规模。以下是一个初始探索示例,用于查看自第一个已知危害指标以来,受危害角色访问了哪些存储桶:

1
2
3
4
SELECT DISTINCT bucket_name, remoteip
FROM "catalog"."database"."table"
WHERE requester LIKE '%assumed-role/compromised-role%'
AND timestamp > '2024/11/05'

数据保留 通过将 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 格式的对象(如“SAL 日志的压缩”中所述)。

TargetObjectKeyFormat 格式 第一步是通过为访问日志创建目标存储桶来启用访问日志记录。但首先,我们必须处理那些已经启用了 SAL 的存储桶。这些存储桶使用了默认的 SimplePrefix 格式,该格式产生扁平的前缀结构:

1
[TargetPrefix][YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString]

不可避免地,这些使用 SimplePrefix 的存储桶积累的日志量变得无法在 Athena 中查询,因为达到了 S3 API 速率限制。由于无法减慢从 Athena 的读取速度,数据分区是解决此问题的方法之一。

PartitionedPrefix 提供了更多信息用于数据分区,特别是账户 ID、区域和存储桶名称:

1
[TargetPrefix][SourceAccountId]/[SourceRegion]/[SourceBucket]/[YYYY]/[MM]/[DD]/[YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString]

一旦我们在 Terraform 模块中将 PartitionedPrefix 格式设为默认,我们就迁移了现有的目标前缀,以便新日志使用正确的格式。对于交付选项,我们选择了 EventTime 交付,因为它具有将日志归因于事件时间的好处。

读取 S3 服务器访问日志

在以下部分中,我们将介绍读取 SAL 日志的模式属性。

分区 我们选择了 Glue 的投影分区而非托管分区,因为托管分区需要刷新分区(使用 MSCK REPAIRALTER TABLE 等命令),随着分区数量增长,这会变得繁琐。这可能导致由于元存储查找而增加查询规划时间,并且需要通过分区索引来解决。

通过分区投影,我们利用已知的日志前缀格式避免了这种开销。

1
'storage.location.template'='s3://<destination-bucket>/0123456789012/us-east-1/${bucket_name}/${timestamp}'

其中,bucket_name 分区键使用枚举类型,因此所有可能的存储桶名称都会被写入。而 timestamp 分区键涵盖整个一天,即 yyyy/MM/dd,这加速了查询修剪时间,因为我们通常查询一天的日志。使用更细粒度的分区会导致过度分区。

主要风险是在查询系统中意外遗漏新存储桶的日志。由于我们选择了枚举分区类型以便跨所有存储桶轻松查询,因此 Glue 表中 bucketaccount 的分区值由一个 Lambda 作业保持最新。该 Lambda 从一个 SQS 队列读取数据,该队列由周期性的 EventBridge 规则填充——使用队列是为了避免并发读写。

injected 类型的替代方案可以避免枚举所有可能的值,但需要在 where 子句中指定一个值。我们决定对基于访问的表使用 injected 类型,因为它们有特定的用例。另一个考虑是,如果枚举类型分区在 where 子句中未受约束,查询可能会在长时间窗口内遇到超过 100 万分区的上限。

input.regex 中的日志格式 一旦您拥有分区前缀的日志,就需要提供一个允许 Glue 表读取日志的正则表达式。

1
2
WITH SERDEPROPERTIES (
  'input.regex'='([^ ]*) ([^ ]*) \\[(.*?)\\] ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) (-|[0-9]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) ([^ ]*)(?: ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*))?.*$')

虽然这个模式可能难以阅读,但很快就会发现,如果双引号内有双引号,或者 ([^ ]*) 中有空格,那么像 \"[^\"]*\" 这样的模式就不成立。我们意识到某些字段是用户可控的,这意味着用户可以向诸如 request_urireferreruser_agent 等字段提供任何字符,这些字段在写入时没有任何编码。

以下是使用 referrer HTTP 头拆解上述正则表达式的一个简单方法:

1
$ curl https://s3-us-west-2.amazonaws.com/foo-bucket/foo.txt -H "Referer: \"a b\" \"c" -H "User-Agent: \"d e\" \"f"

生成的日志在字段内出现了未经过编码或转义的双引号(")。

1
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_uriuser_agent 字段中包含恶意文本的日志,这些也破坏了正则表达式匹配。

1
2
3
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 编码——参见“Key 编码的特异性”。由于其余字段对我们来说不如 requestidkey 重要,非编码字段被包装在一个可选的、非捕获组中,即 (?:<rest-of-fields>)?。因此,即使其余字段的正则表达式匹配失败,我们也能确保前七个字段存在。一个重要的结论是:如果某行为空,那么正则表达式未能解析该行——所以不要忽略这些行!

至于为什么正则表达式以 .*$ 结尾:这是为了考虑可能在任何时间添加额外列的情况。

SAL 日志的压缩

Athena 的限制 我们利用了并行发出 Athena 查询的优势,但 Athena 是共享资源,因此查询可能因集群过载或偶尔触及 S3 API 限制而随时被终止。鉴于我们的规模,我们会定期遇到此类错误。因此,我们需要一种方法来重试那些受限于同时活跃 Athena 查询数量配额的查询。

在初始运行期间,我们遇到了 TooManyRequestsException Athena 异常,我们通过减少并发查询并为每个受影响的账户和区域请求增加活跃数据操作语言(DML)查询配额来解决。

队列处理 为了利用每个存储桶插入操作的隔离性,我们使用异步函数对队列处理流程进行了建模。

位置查询 首先,位置查询获取保存 SAL 日志的对象,这些对象在成功插入后将被标记为过期。可以使用 $path 变量并通过一些字符串操作来检索位置,以供 S3 批处理操作使用。

1
2
3
SELECT split_part(substr("$path", 6), '/', 1), substr("$path", 7 + length(split_part(substr("$path", 6), '/', 1)))
FROM ...
GROUP BY "$path"

我们在队列流程中首先获取位置,因为它可以避免我们使未插入的日志过期。

幂等插入 然后,SAL 日志被插入到 Parquet 格式的表中。一个简单的插入查询被修改为在同一分区上使用左连接,因此如果存在匹配项,则不会再次插入行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
INSERT INTO "catalog_target"."database"."table_region"
SELECT *
FROM "catalog_source"."database"."table_region" source
LEFT JOIN "catalog_target"."database"."table_region" target
ON target.requestid = source.requestid
AND bucket_name = 'foo-bucket'
AND timestamp = '2024/01/01'
WHERE bucket_name = 'foo-bucket'
AND timestamp = '2024/01/01'
AND target.requestid IS NULL

现在,我们可以重新运行插入操作,而无需担心再次插入相同的数据。拥有 requestid 列非常方便。对于没有唯一列的表,我们使用了行值的校验和。为 bucket_nametimestamp 重复过滤器是明智的,因为每次插入后查询运行时间会变长。关于我们如何将存储桶映射到 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/”是否被访问过,即:

1
SELECT array_join(slice(split(rtrim(key, '/'), '/'), 1, cardinality(split(rtrim(key, '/'), '/')) - 1), '/') as prefix

能够从键确定前缀对于连接操作至关重要。由于 Athena 分布式节点计算的性质,我们发现基于等式的连接可以更有效地跨节点分配工作,而基于 LIKE 的连接则会导致跨所有节点的交叉连接(广播)。例如,在一个约 70,000 行的查询中,仅仅将 LIKE 操作符切换为等式(=)操作符,就将执行时间从超过 5 分钟减少到仅 2 秒——这是一个显著的改进。

一旦我们收集了历史数据,基于访问的表中的前缀会与 S3 清单连接,以获取 S3 批处理操作所需的完整 S3 对象名称。

未来工作

该项目旨在帮助弥补当前未对所有对象应用生命周期过期策略的 S3 存储桶的空白。我们现在正在使用基于访问的保留策略来删除未使用的对象。但也有一些有效的例外情况,例如存储表增量更改(CDC)的备份存储桶,其中可能包含看似未被访问的对象,而实际上这表明表中的数据没有发生变化。

作为潜在的未来增强,我们希望通过将 S3 服务器访问日志转发到我们的数据可观测性平台 Splunk,使工程师能够轻松使用它们。这将需要减少数据量以满足每日摄取目标限制,并应用较短的保留期,但这对于调试目的仍然足够。一旦摄取,日志将比通过 Athena 查询更高效,提供更快、更具成本效益的故障排除体验。

致谢

通过 Athena 将纯文本日志转换为 Parquet 格式的 S3 对象的想法来自 Nick Del Nano。整个工作始于与 Thomas Robinson、Quentin Long 和 Vincent Thibault 一起进行的“unhack”工作,该项目表明存储成本可以降低到可管理的水平。

非常感谢 Vincent Thibault 为所有存储桶启用日志记录建立了初始基础设施,处理了动态分区,并提出了一种异步发出查询的方法。此外,Daniel Popescu 帮助进行故障排除,并为未来的待命人员减少操作负载提供了想法。Quentin Long 是许多设计决策和界定问题范围的指导力量。最后但同样重要的是,Brian Minard 让我们保持正轨,以达到里程碑目标,并交付项目完成所需的业务价值。

附录

Key 编码的特异性 最初,根据遇到的数据,我们以为 key 总是经过双重 URL 编码的。因此,使用 url_decode(url_decode(key)) 是有效的,直到我们发现对于某些操作,例如 BATCH.DELETE.OBJECTS3.EXPIRE.OBJECTkey 只被 URL 编码了一次。因此,对包含百分号(%)作为字符的键进行两次解码会失败。根据进一步的观察,我们得出结论:生命周期操作可能因为是来自内部服务而只被 URL 编码一次。您可能认为回退到单次 URL 解码就足够了,但请考虑访问日志中键是 foo-%2525Y 的情况——即使其实际对象名可能不是它,解码两次也能成功,即 foo-%Yfoo-%25Y

将存储桶映射到 Glue 数据目录 我们利用 Glue 数据目录跨不同 AWS 账户读取和写入表。这当然需要跨账户访问 Glue 目录以及数据库和表,这意味着两个账户都需要允许访问 Glue 资源,以及访问存储访问日志的底层 S3 对象的 S3 权限。从其他账户查询数据涉及注册一个 AWS Glue 数据目录,用作 FROM 子句中的源项。

1
FROM "catalog"."database"."table_region"

我们需要从存储桶名称到账户 ID,再到目录名称的映射。幸运的是,从存储桶名称获取账户 ID 不需要额外的工作,因为我们在之前的项目中已经有了可用的数据。通过 ListDataCatalogs API 调用生成了账户 ID 和数据目录名称之间的映射。

表和数据库的名称本可以跨账户和区域设为常量,但我们选择将信息嵌入名称中以使其更明确;这偶尔会适得其反,当查询产生 0 结果时,您才意识到存储桶存在于不同的账户或区域!

表计数的验证 Athena API 调用 GetQueryRuntimeStatistics 有助于避免为表计数而进行查询,因为它提供了带有扫描行数的“Rows”统计信息;这只对空白插入准确,因为使用 JOIN 将包括所有连接表的行数。对于初始插入,我们比较来自 API 的扫描行数——从而跳过对 SAL 对象的昂贵查询——与压缩表上的计数查询,由于 Parquet 格式,后者速度很快。API 统计信息是异步提供的,但有时“Rows”键可能根本未被填充。在这种情况下,我们会回退到计数查询。

对于重新插入,我们使用去重计数查询,因为重复日志不会插入到压缩表中,这会导致简单计数查询的计数不匹配。在极少数情况下,日志可能在插入和计数查询之间到达,这时需要再次运行插入查询。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计