阿里云函数计算 FC 3.0 实战部署 Flask OSS 私有文件分享服务我踩过的 8 个坑把一套「Flask 阿里云 OSS」的文件上传与私密分享服务部署到函数计算前后踩了不少坑。这篇文章只讲实测结论——凡是官方文档没写清楚、或者文档写错了的地方我都附上了报错原文和验证方法。文中所有 Bucket 名、账号 ID、域名、密钥均已脱敏。一、先说架构需求很简单文件存 OSS通过网页上传/浏览/下载能生成「有有效期限制」的私密分享链接永久、单次、限次、限时后台能看访问记录并暂停/延期/关闭分享。选型上有个约束函数计算是无状态的实例随时会被销毁。所以不能依赖本地 SQLite 存分享记录。最终架构浏览器 ──► 函数计算 FC 3.0 (Flask) ──► 阿里云 OSS私有 Bucket │ └─ 分享记录 / 访问日志 / 分布式锁 也以 JSON 对象形式存在 OSS 里关键设计把 OSS 当数据库用。这个是后话第八节展开。二、坑 1Windows 上打的包传上去一定跑不起来这是最容易被忽略、也最容易浪费一整天的坑。现象本地开发机是 Windows直接pip install -t vendor -r requirements.txt把依赖装进代码目录打包上传。函数启动直接报依赖导入失败。原因依赖里有 5 个带 C 扩展的包cryptography、cffi、pycryptodome、MarkupSafe、charset-normalizer。在 Windows 上装出来的是win_amd64的二进制库.pyd而函数计算跑在 Linux 上需要的是.so。阿里云官方文档其实明确警告过这点并推荐用 WebIDE 或 Serverless Devs Docker 来装依赖。但如果你本地没有 Docker比如 Windows 开发机这两条路都不好走。解法用 pip 的跨平台下载能力自己交叉打包pip download\--only-binary:all:\--python-version3.12\--implementationcp\--abicp312\--platformmanylinux2014_x86_64\--platformmanylinux_2_17_x86_64\--platformmanylinux_2_28_x86_64\--no-deps\-dwheels/\MarkupSafe cryptography cffi pycryptodome charset-normalizer...要点--platform可以重复传多次我传了 3 个 manylinux 标签提高命中率--only-binary:all:强制只拉 wheel不碰 sdist否则会尝试本地编译纯 Python 的包py3-none-any在这一步也会一起下下来不用分开处理子坑 Apip install --target会拒绝安装下载完想安装到vendor/直接报错ERROR: cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.whl is not a supported wheel on this platform.因为pip install会拿当前正在运行的解释器去校验 wheel 的平台标签交叉下载来的 manylinux wheel 当然不匹配。解法wheel 本身就是 zip直接解包就行PEP 427 规定 wheel 就是按目录结构组织的 zipimportzipfileforwheelinwheels:withzipfile.ZipFile(wheel)asz:z.extractall(vendor_dir)只要启动时用python -m gunicorn而不是依赖 console_scripts 生成的gunicorn可执行文件就不会有任何问题。子坑 B有些包只发源码包oss2和crcmod在 PyPI 上只有 sdist没有 wheel。但它们本身是纯 Python所以本地构建出来的产物是py3-none-any跨平台可用pip wheel --no-deps-wwheels/ oss2 crcmod怎么验证打对了打包脚本里加一道自检比事后在云端看日志快得多# 1. 绝对不能出现 Windows 原生扩展assertnotlist(vendor.rglob(*.pyd)),vendor 里混进了 Windows 二进制# 2. Linux 原生扩展必须真的是 ELFforsoinvendor.rglob(*.so):assertso.read_bytes()[:4]b\x7fELF,f{so}不是 Linux 二进制7f 45 4c 46就是\x7fELF一眼就能分辨。三、坑 2自定义运行时的 Python 版本因镜像而异而且反直觉FC 3.0 没有 Python 3.13 的内置运行时内置最高到 3.12所以走自定义运行时。但三个 Debian 版本内置的 Python 版本是这样的运行时内置 Python路径需要设 PATH 吗custom.debian103.10.9/var/fc/lang/python3.10/bin需要custom.debian113.12.4/var/fc/lang/python3.12/bin需要custom.debian123.11.2/usr/bin/python3不需要注意 Debian11 比 Debian12 的 Python 还新——这个很容易看反直接决定你该交叉打包 cp311 还是 cp312 的依赖。实践建议让启动脚本自己探测解释器不要依赖镜像帮你设好 PATH在bootstrap里按优先级探测并把解释器路径写死传给 gunicornPYTHONforcandidatein\/var/fc/lang/python3.12/bin/python3\/var/fc/lang/python3.11/bin/python3\/usr/bin/python3\python3doifcommand-v$candidate/dev/null21;thenPYTHON$candidate;breakfidone# 依赖自检版本不匹配时给出可读的报错而不是让 Flask 抛一堆 ImportError$PYTHON-PYEOF import sys missing [m for m in (flask, oss2, gunicorn, cryptography) if not __import__(importlib).util.find_spec(m)] if missing: print(f[FATAL] 依赖与当前解释器不匹配{missing}当前 {sys.version}, filesys.stderr) sys.exit(1) PYEOFexec$PYTHON-mgunicorn--bind0.0.0.0:${FC_CUSTOM_LISTEN_PORT:-9000}\--workers1--threads8--worker-class gthread--timeout0wsgi:app几个细节监听端口读FC_CUSTOM_LISTEN_PORT这是平台注入的官方变量名不是FC_SERVER_PORT建议在bootstrap里加一次应用装配自检import wsgi这样配置缺参、凭据缺失会在启动阶段就报出可读错误而不是等到线上只看到 502gunicorn 用--workers 1 --threads Ngthread单实例并发度可 1 时多线程共享进程内缓存和连接池--timeout 0关闭 gunicorn 自身超时把超时控制权交给函数配置顺便一提官方文档写「Debian12 只支持杭州/青岛/北京等部分地域」但实测在文档未列出的地域也能正常部署。文档滞后以实际为准。四、坑 3实例规格有两条硬约束报错原文如下想省钱调小规格连着被拒两次报错信息倒是给得很清楚InvalidArgument: 400 the ratio of Memory(in GB) to CPU(in core) must be between 1 and 4, actual: 0.50/0.05(10.00)InvalidArgument: 400 ContainerCPU is set to an invalid value. The value must be a multiple of 0.05 vCPU. (actual: 0.125000)两条合起来内存(GB) ÷ CPU(核) ∈ [1, 4]且CPU 必须是 0.05 的整数倍。据此可以推出各内存档位的最小合法 CPU内存合法 CPU 范围最小 CPU128 MB0.050.05256 MB0.10 ~ 0.250.10512 MB0.15 ~ 0.500.151024 MB0.25 ~ 1.000.252048 MB0.50 ~ 2.000.50注意 0.125 这种看起来很合理的值是不合法的——必须是 0.05 的整数倍。五、坑 4成本优化的方向很多人搞反了接着说省钱。有几个反直觉的点timeout 不参与计费按量付费算的是实际执行时长不是你设的上限。把 timeout 从 600 秒改成 60 秒账单一分不变唯一的变化是超过 60 秒的请求会被强杀。如果你的服务需要服务端代理下载比如为了精确统计下载次数那么「用户下载多久 函数执行多久」timeout 必须留足。改小它纯粹是自伤。并发度越低越贵函数按实例计费。单实例并发度决定一台实例能同时服务几个请求并发度 81 台实例扛 8 个并发请求并发度 2同样负载要开 4 台实例 → 实例秒数 ×4 →账单约 ×4「降低并发 省钱」是常见误解尤其对长耗时请求下载、流式响应来说完全相反。真正的大头是流量费不是计算费我们的架构一开始是函数在香港、Bucket 在上海。用户下载一个文件数据要走两段公网上海 OSS ──公网──► 香港函数 ──公网──► 用户 (OSS 外网流出) (函数公网出流量)1 GB 下载 ≈ 两份流量费。而同样 1 GB 的计算费内存 GB × 秒只是几厘到几分钱的量级。结论把函数和 OSS 放同地域函数访问 OSS 走内网完全免费只剩用户下载那一份流量。这是这个架构里省得最多的一笔比抠内存规格有效得多。六、坑 5最大的坑函数计算默认域名不能当网页用这是本次踩得最深的坑也是我建议每个用 FC 跑 Web 应用的人先验证的事。现象用默认域名*.fcapp.run访问本该渲染页面结果浏览器直接开始下载文件。实测结论重点我写了一个探针蓝图覆盖各种响应形态在大陆和香港两个地域各测了一遍探针返回内容网关最终行为普通 HTML无Content-Disposition被强加attachment显式声明Content-Disposition: inlineinline仍被覆盖成attachmentinline; filename...同上仍被覆盖JSON 响应application/json也被加attachment相对地址 302Location: /files/400ExternalRedirectForbidden绝对地址 302Location: http://同域/...400 同上303 See Other—400 同上HTML JS 跳转200200 但带attachment页面被下载JS 不执行香港地域结果完全一致→ 说明这是*.fcapp.run域名的平台统一策略与地域、与 ICP 备案无关。一个被证伪的官方方案阿里云 FAQ 里给了三条解法其中一条是在响应头里显式返回content-type: text/html浏览器就会按 HTML 渲染。实测无效。我们的 HTML 页面本来就是text/html; charsetutf-8依然被强加attachment。另一个显式声明inline的绕法也被覆盖了。实际影响能力默认域名下是否可用JSON 接口✅文件下载本来就要attachment✅HTML 页面渲染❌任何 3xx 跳转包括登录成功后的跳转❌也就是说默认域名只能当下载接口用。想让网页界面能用必须绑自定义域名。绑定自定义域名后完全正常用 Serverless Devs 的fc3-domain组件绑上自定义域名后实测Content-Disposition : 完全没有 ✅ HTML 渲染 : 正常 ✅ 302 跳转 : 正常 ✅ POST /login → 302 → /files/ → 200界面完整渲染 ✅所以那条「自定义域名不受强制下载限制」的官方说明是对的。七、坑 6绑自定义域名的两个前置条件条件一CNAME 必须已经解析生效服务端强校验不是配了就能绑。函数计算在创建自定义域名时会校验解析不通过直接报错DomainNameNotResolved: 400 domain name xxx.example.com has not been resolved to your FC endpoint, the expected endpoint is account-id.region.fc.aliyuncs.com.CNAME 目标是账号ID.地域.fc.aliyuncs.com这个格式是确定性的可以提前配好。坑中坑如果你配置的 CNAME 目标写错了不会有任何提示——必须先让解析正确再执行绑定。条件二大陆地域需要备案海外地域不需要大陆地域官方要求「准备一个已在阿里云接入网站备案的自定义域名」中国香港 / 海外地域官方明确说明不需要备案如果想跳过备案快速上线香港地域是最直接的路径。坑中坑新注册域名有 serverHold 状态国内注册的新域名即使控制台显示「实名认证成功」whois 里也可能是Domain Status: serverHold这个状态下域名完全不解析查询返回 NXDOMAINCNAME 配了也没用。阿里云官方说明是域名完成实名认证后域名状态不会立即更新会存在1 个工作日左右的延迟……将在1~2 个工作日左右解锁。排查方法查询 whois 状态importsocket ssocket.create_connection((whois.nic.xyz,43),timeout20)# 换成对应后缀的 whois 服务器s.sendall(bexample.com\r\n)# 读返回内容看 Domain Status 里有没有 serverHold八、架构设计没有数据库怎么保证「单次下载不超发」这是这个项目技术上最有意思的部分。问题分享链接要支持「单次下载」也就是被下载 1 次后自动失效。这需要原子计数。但函数计算无状态实例随时销毁本地 SQLite 无法持久化多个实例并发时内存计数完全不共享。方案把元数据也放进 OSS_meta/shares/share_id.json 分享主体含限制、状态、计数 _meta/tokens/token.json token → share_id 索引 _meta/events/date/Y-M-D/... 按日期归档的访问事件全局日志 _meta/events/share/share_id/... 按分享归档的访问事件详情页 _meta/locks/name.lock 跨实例互斥锁并发一致性靠两层锁进程内锁函数计算的单实例并发度可 1同一进程会被多线程共享先用threading.Lock在进程内串行化跨实例的分布式锁利用对象存储的「不存在才写入」语义做互斥OSS 侧的实现headersoss2.CaseInsensitiveDict()headers[x-oss-forbid-overwrite]true# 目标已存在时返回 409 FileAlreadyExiststry:bucket.put_object(lock_key,payload,headersheaders)returnTrue# 抢锁成功exceptoss2.exceptions.ServerErrorasexc:ifexc.status409orexc.codeFileAlreadyExists:returnFalse# 已被别人持有raise锁里带过期时间持锁者崩溃后可以被抢占不然死锁会一直卡住。关键判定逻辑读计数 → 判定 → 计数 1 → 写回全部放在锁内并且锁内重新读取记录避免读到陈旧状态。拿不到锁时失败关闭返回 503 让用户重试宁可拒绝也不放行。实测验证部署后用真实请求下载一个「限 2 次」的分享链接第 1、2 次 200第 3 次 410。跨实例计数是准的。访问事件为什么双写事件同时写到events/date/和events/share/两个前缀下全局日志页按日期列举一次前缀列举搞定分享详情页按分享列举也是一次列举代价是每次访问多一次 PUT对象很小换来两侧查询都不用扫描全量。事件只追加不修改所以双写不会产生一致性问题。九、Serverless 下的「隐藏后台入口」怎么做才有效先明确隐藏路径不是安全边界路径隐藏能减少自动化扫描的噪音但挡不住定向攻击。尤其在 HTTP未启用 HTTPS下路径会以明文出现在网络上等于没隐藏。真正的防线永远是强口令 传输加密 可靠的登录限流。但是有几个「零成本、真有用」的收敛动作1. 未登录一律返回 404不要跳转登录页如果你的登录路径是隐藏的比如配成随机串那么未登录访问后台时绝对不能 302 跳登录页——Location头会直接把隐藏路径交出去。ifis_logged_in():returnview(*args,**kwargs)ifwants_json():# 只给本站 JSreturnjsonify({ok:False,code:unauthorized}),401abort(404)# 其余一律 4042. 判断「是否要 JSON」不能用路径前缀这是我自己写出来又修掉的一个坑。最早的实现是「路径以/api/开头就返回 JSON」结果扫描器和 curl 默认发Accept: */*打/api/xxx会拿到 401 —— 等于告诉对方「这个接口存在且需要登录」。改成只看明确信号defwants_json():ifrequest.headers.get(X-Requested-With)XMLHttpRequest:returnTrueifrequest.is_json:returnTrueraw(request.headers.get(Accept)or).lower()iftext/htmlinraw:returnFalsereturnapplication/jsoninraw# */* 不满足3. 注意 CSRF 校验的执行顺序全局 CSRF 校验通常是before_request会跑在视图的login_required之前。如果它在匿名请求上返回带中文提示的 HTML 页所有写操作端点都在泄漏应用身份。要按身份分流ifnotis_logged_in():returnanonymous_error(404)# 匿名探测极简ifwants_json():returnjsonify({...,code:bad_csrf}),400# 本站 JS可读提示returnh1400 请求校验失败/h1,400# 已登录用户友好页面4. 健康检查端点匿名可达但别吐内部信息/healthz要给负载均衡/平台探活用不能加登录。但它可能正在泄漏{version:1.0.0,storage_backend:oss,storage:{bucket:xxx,endpoint:oss-cn-xxx.aliyuncs.com},shares:{total:12,downloads:340}}Bucket 名、Endpoint、运营数据全给了。正确做法ifnotis_logged_in():returnjsonify({ok:True})# 匿名只说 ok# 已登录才返回完整信息5. 静态资源对匿名公开别在里面留品牌/static/js/app.js、/static/css/app.css是任何人都能下载的。里面出现项目名、注释里的「样式表」标题、甚至一行window.YourProject {...}都是指纹。打包/提交前搜一遍grep-riEyourproject|yourbrand|aliyun|oss|bucketapp/static/6. 登录页文案也可以中性化登录页只有知道路径的人会访问但万一路径被猜到也没必要让对方知道「这是什么站点、用的什么存储」。把站名、副标题比如「XX 文件分享 · 阿里云 OSS 存储」、文件类图标都去掉只留账号/密码表单。顺手加一条测试不变式这类「匿名面收敛」的改动很容易在后续迭代中破坏。我们加了几条测试把行为钉死deftest_anonymous_errors_are_minimal_plain_text(path):responseclient.get(path)assertresponse.status_code404assertresponse.mimetypetext/plainassertresponse.get_data(as_textTrue)404 Not Found\ndeftest_anonymous_errors_leak_nothing(path):bodyclient.get(path).get_data(as_textTrue)forleakin(YourProject,文件分享,app.css,OSS,bucket,html):assertleaknotinbody十、工程化一条命令打包 部署 自检踩完坑之后把这些都固化进脚本避免下次重来。打包脚本要做的事用pip download --platform manylinux...交叉拉依赖对只发 sdist 的包oss2/crcmod本地构建纯 Python wheel解包到vendor/并校验不能有.pyd、.so必须是 ELF用ast.parse(feature_version(3, 11))预检源码语法是否兼容目标 Python 版本打 zip 时显式给bootstrap写0755权限位Windows 打的 zip 默认不保留 Unix 权限而云函数默认就是执行/code/bootstrap启动命令建议用bash /code/bootstrap这样即使权限位丢了也能起来。加一道密钥泄漏兜底如果打包时把部署配置可能含 AK/SK一起打进了 zip而 zip 又被发到群里或传上网盘后果很严重。在打包脚本末尾加一道扫描defassert_no_secrets(zip_path):secrets[vfork,vinconfig.items()ifkin(akId,akSecret)]withzipfile.ZipFile(zip_path)asarchive:fornameinarchive.namelist():bodyarchive.read(name)ifany(s.encode()inbodyforsinsecrets):zip_path.unlink()# 直接删包raiseSystemExit(f安全检查失败{name}含明文密钥已删除压缩包)这个兜底真的救过一次早期的 zip 里就带着明文 AK是它扫出来的。部署脚本绑定域名前先查解析# 用国内 DNS 查询8.8.8.8 在大陆经常被阻断会误导判断Resolve-DnsName-Name$Domain-TypeCNAME-Server 223.5.5.5解析没生效就明确提示原因是serverHold还是记录没配而不是直接去绑然后拿到一个看不懂的 400。端到端自检脚本部署完跑一遍覆盖登录 → 建目录 → 上传 → 分层浏览 → 四种分享模式 → 匿名下载 → 限次边界 → 暂停/恢复/关闭/延期 → 管理页 → 访问日志。比人工点一遍靠谱也比只看健康检查有意义。十一、关于密钥能用 RAM 角色就别用 AK判断一组 AK 的权限范围如果拿不准某个 AccessKey 被授了多大权限可以做个对照实验。注意有个看起来很聪明但实际无效的方法我踩过访问一个不存在的 Bucket如果返回NoSuchBucket404说明鉴权通过、策略是*如果返回AccessDenied403说明限定到了具体 Bucket。这个方法是错的。用故意写错的 SK去访问同一个不存在的 Bucket依然返回NoSuchBucket—— 说明OSS 对不存在的 Bucket 根本不鉴权这个探针测不出任何东西。对比实验才是关键# 对照组故意用错误的 SKbogusoss2.Bucket(oss2.Auth(AK,ThisIsADeliberatelyWrongSecretKey),endpoint,ghost_bucket)try:bogus.get_bucket_info()exceptoss2.exceptions.ServerErroraserr:print(err.code)# 若返回 NoSuchBucket → 该探针无效对不存在的 Bucket 不鉴权# 若返回签名错误 → 鉴权先发生探针才有效教训任何探针结论都要用对照组验证探针本身是否成立。更可靠的判断ListBuckets这个操作在 OSS 里只能配Resource: *它不针对具体 Bucket。所以如果某个 AK能ListBuckets→ 策略里必然有Resource: *的语句如果不能→ 说明策略收得比较紧最小权限策略OSS 完全支持只授权单个 BucketResource全填具体 Bucket 名一个*都不需要{Version:1,Statement:[{Effect:Allow,Action:[oss:ListObjects,oss:GetBucketInfo],Resource:acs:oss:*:*:your-bucket},{Effect:Allow,Action:[oss:GetObject,oss:PutObject,oss:DeleteObject,oss:AbortMultipartUpload,oss:ListParts],Resource:acs:oss:*:*:your-bucket/*}]}几个要点ListObjects必须以整个 Bucket 作为 Resource官方要求不能带/*控制台里搜索 “OSS” 只会跳出两个系统策略AliyunOSSFullAccess/AliyunOSSReadOnlyAccess都是全账号范围。要用最小权限得点「创建权限策略 → 脚本编辑」手写 JSON——这是很多一给就全给的来源更好的做法是用 RAM 角色函数绑定角色后平台自动注入ALIBABA_CLOUD_ACCESS_KEY_ID/_SECRET/_SECURITY_TOKEN临时凭据过期自动轮换不用在环境变量里存长期密钥一个容易踩的细节oss2自带的EnvironmentVariableCredentialsProvider只认OSS_ACCESS_KEY_ID这类变量名不认ALIBABA_CLOUD_*。如果你的代码要用 RAM 角色需要自己做一层名字桥接。十二、复盘这次的方法论1. 凡是文档说可以都要实测这次有三处是实测推翻文档的FAQ 说「设置content-type: text/html就能让浏览器渲染」 →无效HTML 被强加attachment文档说「Debian12 只支持部分地域」 →实测在未列出的地域也能部署文档说domainName: auto临时域名 30 天后回收 → CLI 实际提示1 天2. 任何探针结论都要验证探针本身上面那个「用不存在的 Bucket 判断权限范围」就是典型反例。加一组对照实验的成本极低但能避免把错误结论写进文档。3. 把踩过的坑固化成脚本和测试交叉打包 → 写进打包脚本附.pyd/ELF 自检密钥泄漏 → 写进打包兜底扫描匿名面收敛 → 写成测试不变式防止后续迭代破坏域名绑定 → 写进部署脚本先查解析再绑一次踩坑是意外两次踩同一个坑就是流程问题。4. 先验证最大的不确定性这个项目里最大的不确定性是「默认域名到底能不能当网页用」。如果一开始就先花 5 分钟验证它能省下后面一堆围绕默认域名的无用功。新平台上的第一个动作应该是用最小代价验证最关键的假设。附完整踩坑速查表#现象根因解法1上传后依赖导入失败Windows 装的库是.pyd云端要.sopip download --platform manylinux...交叉打包2not a supported wheel on this platformpip install --target用本机解释器校验标签直接解包 wheelwheel 就是 zip3oss2/crcmod找不到 wheel只发 sdistpip wheel --no-deps本地构建产物跨平台4依赖版本对不上Debian113.12、Debian123.11反直觉bootstrap 探测解释器 依赖自检5ContainerCPU is set to an invalid valueCPU 必须是 0.05 的整数倍用 0.15 而非 0.1256ratio of Memory to CPU must be between 1 and 4内存/CPU 比例约束512MB 最小配 0.15 核7网页变成下载默认域名强加attachment绑自定义域名8ExternalRedirectForbidden默认域名禁止 3xx同上9DomainNameNotResolved绑定前 CNAME 必须已生效先配解析再绑10域名完全不解析新域名serverHold等实名认证核验1~2 工作日11匿名探测能拿到中文报错CSRF 校验跑在鉴权之前按登录状态分流响应12扫描器能发现后台/api/前缀被当成要 JSON只认明确信号不看路径前缀13健康检查泄漏 Bucket 名匿名端点返回了完整信息匿名只返回{ok: true}14上传超 32MB 失败HTTP 触发器同步请求体上限 32MB改浏览器直传 OSS签名 URL如果这篇文章帮你少踩一个坑欢迎点赞收藏。有不同结论或补充评论区聊聊。