ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Azure Blob Storage实战指南:从上传下载到权限与成本优化

Azure Blob Storage实战指南:从上传下载到权限与成本优化

第一次接触 Azure Blob Storage 的时候,我其实挺不屑的:一个对象存储服务,官方文档翻来覆去就是“创建账户、创建容器、上传文件、下载文件”这四板斧,感觉比操作本地文件夹还简单,能有什么坑?直到后来在某个在线教育项目里,要把几十万份课程视频、课件PDF和学员头像全部迁到 Blob 上,才发现所谓“基本用法”背后还藏着一堆文档没写透的东西——连接字符串放哪最容易泄露、容器访问级别设错了会裸奔、SAS 令牌过期时间算不对导致生产故障、并发上传同一路径会发生什么,这些都是实际跑起来才撞上的问题。

这篇文章就围绕 Azure Blob Storage 的日常使用场景,把从建账户到写代码上传下载、再到权限取舍的完整链路捋一遍。内容偏实战,适合刚接手 Azure 项目、想快速把 Blob 用起来的开发者,也适合那些已经用了一阵子但总感觉某些行为“怪怪的”的朋友。放心,我不会粘贴大段官方文档,只讲我试过、踩过、验证过的东西。

1. 先从一张“档案柜”图看懂 Blob 的三层结构

Azure Blob Storage 的宣传语总爱讲“无限扩展、高可用、对象存储”,听起来很玄。但说白了,它的数据组织方式和线下实体档案柜几乎没有区别:档案室里有多个柜子,每个柜子里有多个文件夹,文件夹里装着一份份文件。对应到 Blob 上,就是“存储账户 / 容器 / Blob”这三层。

存储账户(Storage Account)是顶层单位,相当于整个档案室。你在里面设置地域、冗余策略、访问层这些全局属性,所有 Blob 数据都住在这个账户下。一个账户可以开很多个容器(Container),容器相当于档案柜,主要作用是给 Blob 划定命名空间和权限边界。最底层的 Blob 才是真正存储的数据对象,它本身就是一个二进制文件,可以是一个 JPEG 图片、一个 MP4 视频、一段日志文本,甚至是一个数据库备份文件,Azure 不关心内容是什么,只当它是字节流。

这里有个初学者最容易忽略的细节:Blob 的“目录”其实是不存在的。你看到container/videos/2024/01/course.mp4这种路径,看起来有层级,实际只是 Blob 的名称里包含了斜杠字符而已。容器里面是一个扁平的命名空间,所有 Blob 名称都是平铺存储的。“虚拟目录”这个概念是 SDK 和 Azure Portal 为了人眼友好做出来的假象。这意味着你在代码里没法直接“进入某个目录”,要么列出所有名称带指定前缀的 Blob,要么直接按完整名称定位。

Blob 本身又分三种类型:Block Blob(块 Blob)、Append Blob(追加 Blob)、Page Blob(页 Blob)。日常 95% 的场景用块 Blob 就对了,它适合存储静态文件和媒体文件,上传时把大文件拆成小块分别传输,失败后只需要重传失败的小块;追加 Blob 适合写日志这类“只往后加”的数据流;页 Blob 是给虚拟硬盘文件用的,优化了随机读写能力。做文件存储的朋友直接用块 Blob,别多看另外两个,等真需要了再学也不迟。

存储账户(Storage Account) └── 容器(Container)——相当于逻辑分组 └── Blob——例如 videos/2024/01/course.mp4 实际名称(Name): videos/2024/01/course.mp4 元数据(Metadata): 键值对,例如 标题=课程介绍 属性(Properties): ContentType、ContentLength 等

我第一次建容器的时候很迷惑:容器名称居然要求小写字母、数字和连字符,而且不能以连字符开头。这跟 Blob 名称的命名规则不一样,Blob 名称几乎可以放任何字符,包括中文和空格,只要你做好 URL 编码。建议项目里统一用英文小写 + 连字符给容器起名,Blob 名称则保持原有文件名,查询方便,也避免了一堆转义问题。

三层结构整明白了,接下来就是怎么把这个档案柜搬进代码里。

2. 创建存储账户和容器时,别急着点“创建”:先想清楚冗余和访问层

很多时候我们用 Azure 是直接抄文档写的命令,账户能建出来、代码能跑通,就觉得完事了。但存储账户的创建环节有两项配置,会在后续几个月里持续影响你的账单和可用性,创建后虽然部分可改,改起来却伤筋动骨。

第一个是冗余策略。默认的 LRS(本地冗余存储)会把数据复制到同一个数据中心内的三个副本,成本最低,但整个数据中心挂了数据也就没了。如果在做生产项目、尤其是数据不能丢的业务,建议至少用 ZRS(区域冗余存储),它会把副本分布到同一个区域内的三个可用区,可用性比 LRS 高一个量级。至于 GRS 和 GZRS,会把数据复制到配对区域,适合做跨地域灾难恢复,但读取故障区域的副本是只读的,别指望当双活。我的习惯是:生产环境选 ZRS,开发测试环境用 LRS,省下的钱拿去买奶茶不香吗?冗余策略创建之后不能直接更改,只能迁移,所以想清楚再下手。

第二个是访问层(Access Tier)。每个存储账户都有默认访问层:热(Hot)或冷(Cool)。热访问层适合频繁读写的数据,存储单价高但访问单价低;冷访问层存储单价便宜,但每次读写都要额外付访问费。这条对应到 Blob 层面还有更细粒度:单个 Blob 也能单独设置访问层(热/冷/归档)。归档层最便宜,但把归档文件重新变为可读状态,需要等待数小时到几十小时。

放到真实场景里,有个朋友做过一个图片托管系统,客户上传的图片未来 30 天会被频繁访问,之后访问量骤降。他没做任何分层策略,全部数据躺在热层,月末账单出来直接傻眼。正确做法是用生命周期管理策略,比如“创建后 30 天转为冷层,90 天后转为归档层”,这在 Azure Portal 的“生命周期管理”里配置,一条规则就能搞定,不需要写代码。

{ "rules": [ { "name": "coolAfter30Days", "enabled": true, "type": "Lifecycle", "definition": { "actions": { "baseBlob": { "tierToCool": { "daysAfterModificationGreaterThan": 30 }, "tierToArchive": { "daysAfterModificationGreaterThan": 90 } } }, "filters": { "blobTypes": [ "blockBlob" ], "prefixMatch": [ "images/" ] } } } ] }

不过这个配置有几个容易忽略的坑:生命周期规则不会对 30 天内有过访问修改的 Blob 生效,这里判定的不是“读取次数”,而是最后修改时间;另外规则不是实时的,Azure 一般每天跑一次,所以账单变化会迟几天。设成归档层之后,如果用户想要下载这个文件,得先操作“解除冻结”,这个等待时间必须提前告诉业务方,不然线上就会出现文件点不动、用户疯狂提工单的尴尬场景。

容器本身创建就简单了,Azure CLI 一条命令的事,但别忘了同时设置容器访问级别:

az storage container create \ --name course-files \ --account-name myteststorage \ --account-key <your-key> \ --public-access off

这段命令里我故意把--public-access写成 off,这是最安全的选择。很多教程为了演示方便会写container或blob,让容器内所有 Blob 都能匿名访问。我的建议是:默认永远选 off,永不公开。确实需要对外分享文件时,用 SAS 令牌去控制有效期和权限,后面章节会细讲为什么。

3. 代码实操:用 Python SDK 走一遍上传、下载、列出和删除

账户建好了,容器有了,接下来就是写代码。Azure 官方提供了很多语言的 SDK,Java、Python、Node、Go 都有。我平时 Python 用得多,下面的例子基于azure-storage-blob这个包,版本用 12.x 起步(老版 2.x 已经废弃,网上搜到的旧教程会让你踩坑,认准新版 SDK 的 API 风格)。

3.1 安装和连接到存储账户

pip install azure-storage-blob

连接方式有两种主流姿势。一种是直接拿连接字符串(Connection String),在 Azure Portal 的“访问密钥”里能看到,格式大概是DefaultEndpointsProtocol=https;AccountName=xxx;AccountKey=xxx;EndpointSuffix=core.chinacloudapi.cn(国内版)或者core.windows.net(国际版)。我建议代码里不用连接字符串,因为里面含账户密钥,一旦泄露等于把整个存储账户的管理员权限交出去了。

另一种更推荐的姿势是用StorageAccountKeyCredential或者基于 Azure Identity 的托管身份认证。基础用法如下:

from azure.storage.blob import BlobServiceClient # 方式一:连接字符串,适合本地快速验证 conn_str = "DefaultEndpointsProtocol=https;AccountName=xxx;AccountKey=xxx;EndpointSuffix=core.windows.net" blob_service_client = BlobServiceClient.from_connection_string(conn_str) # 方式二:账户名 + 密钥,密钥从环境变量读,不要写死在代码里 from azure.storage.blob import BlobServiceClient from azure.core.credentials import AzureKeyCredential account_name = "myteststorage" account_key = os.environ["AZURE_STORAGE_ACCOUNT_KEY"] blob_service_client = BlobServiceClient( account_url=f"https://{account_name}.blob.core.windows.net", credential=AzureKeyCredential(account_key) )

我个人强烈建议:连接字符串和密钥只出现在本地调试阶段,一旦进了共享开发环境,马上换成环境变量或者 Azure 密钥保管库。公司里发生过不止一次因为代码里硬编码连接字符串,Git 仓库一泄露,整个存储账户被人清空的事故。别问我怎么知道的,人都是这么长大的。

3.2 上传文件:从文件路径、字节流和并发上传

上传是 Blob 使用最频繁的操作,先看最简单的完整文件上传:

from azure.storage.blob import BlobServiceClient blob_service_client = BlobServiceClient.from_connection_string(conn_str) # 拿到容器客户端 container_client = blob_service_client.get_container_client("course-files") # 把本地文件上传为 Blob with open("./intro.mp4", "rb") as data: container_client.upload_blob( name="videos/2024/01/intro.mp4", data=data, blob_type="BlockBlob", overwrite=True, # 小心这个参数,后面细说 content_settings=ContentSettings(content_type="video/mp4") )

这里有几个要点。

文件名里的目录前缀:videos/2024/01/intro.mp4就是 Blob 的完整名称,斜杠不是真的目录而是名称的一部分。这种命名方式的好处是方便用前缀查询,比如列出所有videos/2024/01/下的文件,SDK 只需要扫描前缀匹配的 Blob。

content_type 要显式设置:如果你不传content_settings,Blob 默认的 Content-Type 是application/octet-stream。这意味着浏览器直接访问这个图片或视频 URL 时,会弹出下载框而不是直接预览,或者前端<img>标签拿到的是一坨乱码。显式传一个正确的 Content-Type 是基本素质,上生产前一定要检查。

overwrite 参数:默认是 False,也就是说如果同名 Blob 已存在,上传会报错。这个设计很好,防止覆盖事故。我曾经在脚本里没设 overwrite,跑挂了一个半小时,所有重复上传全部抛异常,幸好没覆盖掉旧文件,虚惊一场。想覆盖时手动写 True 就好,但要明确意识到:这个操作没有后悔药,旧文件直接被新文件替代,所以覆盖前最好先备份或者加版本控制。

大文件上传:SDK 的upload_blob会自动把大文件拆成多个块并发上传,默认块大小 4MB,并发数 8。所以传几个 GB 的文件不会内存炸掉——它内部用流式读取,按块发送,最后提交块列表,服务端合并成完整 Blob。我这里不需要手动做分块,只有当你需要自定义块大小或精细控制并发时,才需要直接操作stage_block和commit_block_list。

提示:SDK 默认的重试机制是 4 次,每次重试间隔指数退避。网络不稳定环境下传大文件,默认配置大概率能撑住,但如果是跨国传输或丢包率高的网络,建议自定义RetryPolicy(retry_total=6),代价是失败时等待时间更长,换来更低的失败概率。

3.3 下载文件:按流下载而不是一次全装进内存

下载最常见的错误是用download_blob().readall()把整个 Blob 读进内存。对于一个 1GB 的备份文件,直接吃满 1GB 内存,服务并发一上来就 OOM。正确做法是按流处理,或下载到本地文件:

from azure.storage.blob import BlobServiceClient blob_service_client = BlobServiceClient.from_connection_string(conn_str) container_client = blob_service_client.get_container_client("course-files") # 下载到本地文件 with open("./downloaded.mp4", "wb") as f: stream = container_client.download_blob("videos/2024/01/intro.mp4") stream.readinto(f) # 或者按块读取,逐 chunksize 消费,避免一次性 load 全量 stream = container_client.download_blob("videos/2024/01/intro.mp4") for chunk in stream.chunks(): process(chunk)

第二种遍历chunks()的方式适合流式场景,比如你下载的同时要做格式转换或计算哈希值,一边拉一边算,内存占用恒定。下载时还可以指定offset和length做范围读取,比如视频截取头几秒预览、读取日志文件的最后几行,都能避免传全量数据。

3.4 列出 Blob:分页、前缀过滤和虚拟目录

列出容器里的 Blob 很容易写,但数据量大时需要注意分页。默认一次只返回 5000 条,results_per_page参数控制了每页条数,需要用by_page()迭代器翻页:

from azure.storage.blob import BlobServiceClient container_client = blob_service_client.get_container_client("course-files") # 列出全部 Blob,自动翻页 blob_iter = container_client.list_blobs(name_starts_with="videos/2024/", include=["metadata", "snapshots"]) for blob in blob_iter: print(blob.name, blob.last_modified)

name_starts_with对应之前说的前缀查询,这是 Blob 在扁平命名空间里模拟“目录遍历”的唯一方式。如果不指定前缀,容器里几百万个 Blob 会全部列出来,非常慢。建议业务代码里,必须配合前缀使用列表操作,让查询范围尽可能收窄。

还有一个细节:include=["snapshots"]会把快照也列出来。快照是 Blob 某个时刻的只读副本,做版本恢复时很有用,但列出来了容易混淆,默认不包含即可。

3.5 删除 Blob 和容器

删除单个 Blob 很简单:

container_client.delete_blob("videos/2024/01/intro.mp4")

但容器删除有要求:容器必须先清空所有 Blob,否则删除会失败。Azure 没有“强制删除非空容器”的选项,所以批量清理时要么先递归删 Blob,要么在存储账户层开启“软删除”(Soft Delete)保护。软删除一定要开,它能保留已删除的 Blob 一段时间(可配置天数,默认 7 天),防止手滑删库。有次我们清理测试环境的脚本路径写错,误删了生产容器的部分文件,就是靠软删除救回来的。这个开关默认是关闭的,别指望 Azure 主动保护你。

4. 权限管理实战:容器访问级别、共享密钥和 SAS 令牌的正确思路

Blob 的权限模型大概是新手最容易翻车的地方,一句话总结:能匿名就别共享密钥,能用 SAS 就别直接用账户密钥,密钥只留给后台服务用。

4.1 三种容器访问级别

  • private(off):只有授权用户能访问,默认值。
  • blob:容器内的 Blob 允许匿名读取(但无法列出容器内容)。
  • container:允许匿名读取容器内所有 Blob,并且能列出容器内容。

container级别最危险,意味着任何人知道一个 URL 就能下载文件;blob级别相当于只要你猜得到完整的 Blob URL 就能下载。做私有文件存储时,这两者都不要选。但有个典型例外:网站的静态资源(图片、CSS、JS)放在 Blob 上,需要全互联网都能访问,此时设成blob级别是合理的,配合 CDN 还能保证访问速度。

4.2 在代码里生成 SAS 令牌

SAS(共享访问签名)是一种“限时钥匙”,本质是一个附加在 URL 上的签名参数。你可以指定它允许的操作(读、写、删、列)、生效时间范围和来源 IP。最喜欢的场景是这些:

  • 给前端一个带 SAS 的临时上传 URL,让它直接传到 Blob,不经过应用服务器中转;
  • 给外部合作方一个带 SAS 的下载链接,有效期设 24 小时,过期就自动失效;
  • 防止恶意用户批量下载私有文件,因为 URL 暴露了也能限定到具体文件和期限。

构造 SAS 可以直接用 SDK 的generate_blob_sas:

from datetime import datetime, timedelta from azure.storage.blob import BlobServiceClient, BlobSasPermissions, generate_blob_sas blob_service_client = BlobServiceClient.from_connection_string(conn_str) blob_client = blob_service_client.get_blob_client("course-files", "videos/2024/01/intro.mp4") sas_token = generate_blob_sas( account_name=blob_client.account_name, container_name="course-files", blob_name="videos/2024/01/intro.mp4", account_key=account_key, permission=BlobSasPermissions(read=True), expiry=datetime.utcnow() + timedelta(hours=2) ) url_with_sas = f"{blob_client.url}?{sas_token}" print(url_with_sas)

权限字符串要尽量最小:如果只是让用户看一眼视频,只给 read 权限,绝不给 write。即使 SAS 泄露,攻击者也只能读这一个文件,且有效期 2 小时。这是最小权限原则在对象存储上的实践。

4.3 SAS 令牌的两个大坑

坑一:时区问题。SAS 的生效和过期时间基于 UTC,你得用datetime.utcnow()生成,如果用本地时间的datetime.now(),国内用户(东八区)生成时差 8 小时,就会出现“看得到是 2 小时过期,实际 10 小时前就过期了”或者反过来的情况。排查生产事故时,第一件事就先确认时间有没有对齐。

坑二:多个 SAS 拼接时的“?”重复。如果 Blob 的 URL 本身已经带了一个 SAS 参数,再拼接时忘了把?换成&,解析就会出问题。SDK 生成的 URL 里一般不会带初始 SAS,但你自己拼 URL 时一定要检查。

4.4 托管身份是更优雅的解决方案

如果 Blob 的调用方是运行在 Azure 内部的应用(比如自己是 App Service 或某个云虚拟机上的服务),最优雅的认证方式其实是托管身份(Managed Identity)。它不需要任何密钥,Azure 会自动帮你管理身份凭据,代码里直接向某个地址要一个 token 就行。好处:日志里不会出现密钥,就算代码泄露也不会泄露敏感凭据。但托管身份配置比密钥多几步,建议先把前面的密钥方式跑通,再逐步升级。

5. 进阶玩法:属性、元数据、快照和生命周期管理,别让 Blob 只是个“文件筐”

很多人把 Blob 当作一个远程文件系统来用:上传文件、下载文件、删除文件,仅此而已。但 Blob 设计之初就不是传统文件系统,它是为对象存储服务的,所以一些文件系统没有的能力反而是它的杀手级特性。

5.1 元数据:给文件贴标签,替代一部分数据库

每个 Blob 可以挂一堆自定义键值对,叫 Metadata,比如上传时记录source=user-upload、category=course-intro、team=marketing。查询时可以通过include=["metadata"]把这些标签带出来,而不需要读取 Blob 内容。这在打标签、批量管理上能帮你省不少事:比如给所有team=marketing的图片批量套 CDN 刷新策略,就可以先列出元数据再筛选。

上传时设置元数据:

from azure.storage.blob import BlobServiceClient, ContentSettings container_client = blob_service_client.get_container_client("course-files") with open("./intro.mp4", "rb") as f: container_client.upload_blob( name="videos/2024/01/intro.mp4", data=f, metadata={"category": "course-intro", "team": "marketing", "uploadedBy": "system"} )

注意 Metadata 的键和值都必须是字符串,类型不能混。想用整数就转成字符串再存。

5.2 快照:文件的“后悔药”

快照是 Blob 在某个时间点的只读副本,创建快照不复制数据块,成本很低。修改或删除原 Blob 不会影响已创建的快照。所以做定期备份时,常见做法是:每天凌晨给关键 Blob 拍个快照,保留最近 N 天,超出自动删。

blob_client = blob_service_client.get_blob_client("course-files", "important_backup.bak") snapshot = blob_client.create_snapshot() print(snapshot["snapshot"]) # 这个时间戳就是快照标识 # 从快照恢复 blob_client_from_snapshot = blob_service_client.get_blob_client( "course-files", "important_backup.bak", snapshot=snapshot["snapshot"] ) restored_data = blob_client_from_snapshot.download_blob().readall()

快照的删除是独立的,恢复用的就是“从快照读取一遍,再重新上传一遍”的思路,看起来笨,但确实安全。

5.3 生命周期规则:自动瘦身,减少存储成本

刚才提过生命周期管理,我强烈建议每个生产容器都配一条。即便是开发环境,也经常遇到测试数据堆积几个月没人清理的情况,白白烧存储费。规则可以按“前缀”或“Blob 类型”过滤,比如:

{ "rules": [ { "name": "deleteOldLogs", "enabled": true, "type": "Lifecycle", "definition": { "actions": { "baseBlob": { "delete": { "daysAfterModificationGreaterThan": 30 } } }, "filters": { "blobTypes": [ "blockBlob" ], "prefixMatch": [ "logs/" ] } } } ] }

这个规则会在 Blob 修改后超过 30 天时把它删除。注意这里按“最后修改时间”,不是创建时间,所以你每次重新上传(覆盖)同一个名称的 Blob,生命周期都会重新计时。配好规则后,日常就不用天天盯着日志目录清理了。

6. 实际项目里发生的三个典型故障,以及排查思路

最后分享三个真实项目里遇到的故障。前两个是配置问题,第三个是设计问题,希望你看完能少走点弯路。

6.1 SAS 过期时间错位:视频播放突然 403

有一次测试反馈线上课程视频播放偶发打不开,浏览器控制台报 403 Forbidden。排查时发现,前端播放器使用的是带 SAS 的 URL,而 SAS 是后端用一个“过期时间+本地时钟”生成的。后端服务器的系统时间比真实时间快了约 20 分钟,导致生成的 SAS 从后端角度看还有效,但 Azure 服务端校验实际时间时已经过期,于是一部分请求 403,一部分请求还正常。解决方法是统一改用时区服务同步时间,并强制所有 SAS 的 start/expiry 用 UTC。从那以后我写 SAS 时都以 UTC 为唯一标准,绝不信任部署机器的本地时间。

6.2 上传超时:默认超时时间远不够大文件

项目里有个功能是让用户上传 100MB - 2GB 的教学视频。开发环境网络好,一切正常;上线一段时间后,陆续有较大文件上传失败,错误信息是ServiceRequestError或OperationTimeoutError。查了半天才发现,SDK 默认单个请求超时时间是 20 秒,而大文件上传要做分块,每块 4MB 也可能受网络带宽限制导致单块传输超过 20 秒。我们最后在客户端把connection_timeout和read_timeout调大,并开启 SDK 的智能分块逻辑。这个教训说明,预览连通性和生产环境大带宽之间差距很大,别用开发环境来验证超时配置。

from azure.storage.blob import BlobServiceClient client = BlobServiceClient( "https://myteststorage.blob.core.windows.net", credential=AzureKeyCredential(account_key), connection_timeout=60, read_timeout=60 )

6.3 并发写同一路径:后写覆盖先写,数据丢了都不知道

有一次排查线上文件缺失,发现并行上传用户头像时,两个用户同时上传到同一路径avatars/123.png,后完成的请求直接把先前的覆盖了。代码本身没报错,但业务逻辑却把 A 的头像换成了 B 的头像。问题的本质是没有用“条件写入”机制——Azure Blob 支持在写入时设置If-None-Match之类的条件,如果条件不满足就返回错误。我们的业务要求是“同一个路径只允许一个用户写,谁先写谁拥有”,改成先创建空 Blob 占位、有人抢占就检测到冲突,才彻底解决。如果你的场景是多用户并发上传、各自独立的路径(比如user-{id}/avatar.png),基本没问题;但如果路径由业务逻辑拼接而非用户唯一 ID 决定,一定要想清楚并发覆盖的可能性。

from azure.storage.blob import BlobServiceClient container_client = blob_service_client.get_container_client("course-files") # 带条件的上传:仅当目标不存在时才上传 with open("./avatar.png", "rb") as f: container_client.upload_blob( name="avatars/123.png", data=f, overwrite=False, # 关键:不允许覆盖 if_none_match="*" )

if_none_match="*"的含义是“只有当目标 Blob 不存在时写入”,否则触发错误。这个特性最适合实现“抢占写”或“唯一写”逻辑。

Blob Storage 从入门到能稳定跑生产,不算复杂,但“基本用法”四个字绝对不止上传下载这么简单。我现在的习惯是:每趟一个新项目,先把权限模型画出来,再定命名规范,最后才写代码。把前面这些细节都做到位,后续线上问题会少一大半。如果你刚开始接触,建议从创建一个测试存储账户开始,亲手传一个文件、生成一个 SAS 链接、看一次生命周期规则生效,比读十篇文档都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进