GEE资产删除与复制实战:权限配额、批量清理与dry-run

发布时间:2026/11/5 2:51:59
GEE资产删除与复制实战:权限配额、批量清理与dry-run

1. 先搞清楚 Asset 管理的边界在哪

1.1 为什么"删除"和"复制"比上传更容易翻车

在 Earth Engine 里做遥感分析,大家习惯把注意力放在影像合成、分类算法、精度验证上,资产(Asset)管理往往被当成"上传完就完事"的边角料。但只要你在一个项目里连续跑上几周,尤其是做批量时序产品、反复试参数的中间结果、给不同团队分发数据副本,就会遇到一个很现实的问题:projects/xxx/assets下面的东西越堆越多,命名越来越乱,存储配额一天天逼近上限,最后不得不做一次彻底的清理和归档。而清理这件事,恰恰是整个流程里最容易出事的环节——上传失败了可以重传,计算错了可以重跑,但一次误删是补不回来的。

我用 Earth Engine 处理过不少批量化任务,中间结果的体积往往比最终成果大十倍不止,一个 30 景影像的中间集合可能就吃掉几十 GB。删除和复制这两件事,本质上是在跟"不可逆操作"和"权限/配额边界"打交道,而不是跟算法打交道。这也是为什么我更愿意把这两件事写进脚本里而不是在界面上一顿点:脚本可以加确认、可以加日志、可以先 dry-run 打印路径再执行,而手点没有这些保险。

这篇文章面向三类人:一是刚接触 Earth Engine、想在 Code Editor 里手动删掉几个测试资产的初学者;二是已经跑了一段时间、需要批量清理项目空间的实践者;三是用 Python 做流水线、希望把资产运维也纳入自动化的人。JavaScript 和 Python 两套调用方式我会分别写清楚,包括参数差异、报错位置和几个我踩过的坑。

1.2 JavaScript 与 Python 两套调用方式到底差在哪

很多人第一次从 Code Editor 转到 Python 环境时,会误以为只要把deleteAsset改成delete_asset就行。大体上确实如此,但细节差异会直接决定你的脚本能不能跑通。核心区别有三个:第一是鉴权初始化,Code Editor 里是登录态直接可用,Python 里必须显式ee.Initialize()并指定云项目;第二是命名风格,JS 是驼峰(listAssets、copyAsset、getAsset),Python 是下划线(list_assets、copy_asset、get_info),写错了直接AttributeError;第三是返回值和异常处理,JS 侧习惯回调解构,Python 侧是返回值加异常抛出,批量脚本里必须自己包 try/except。

功能JavaScript(Code Editor)Python(ee 包)说明
删除资产ee.data.deleteAsset(id, callback)ee.data.delete_asset(id)删除文件夹会连带子资产
复制资产ee.data.copyAsset(src, dst, overwrite)ee.data.copy_asset(src, dst, overwrite=False)overwrite 仅对单幅 image 有效
重命名/移动ee.data.renameAsset(src, dst, overwrite)ee.data.rename_asset(src, dst, overwrite=False)同项目内重命名,跨项目等同移动
列出子资产ee.data.listAssets(parent)ee.data.list_assets(parent, page_size, page_token)返回结果分页,必须循环取
获取元信息ee.data.getAsset(id)ee.data.get_info(id)判断资产是否存在、看类型
命令行earthengine rm / cp / ls同左适合一次性手动操作

注意:Python 侧ee.data的多数接口是同步阻塞的,没有做重试封装。批量脚本里循环调用几十次以上时,网络抖动或配额限制会直接抛异常,务必自己加 try/except 和退避等待。

2. 核心机制拆解:Asset 路径、权限与配额

2.1 Asset ID 的完整层级结构

Earth Engine 的资产标识是一套清晰的树形路径,标准写法是projects/{cloud-project}/assets/{自定路径}。这里的第一个坑就是很多人写成了users/xxx/assets/xxx或者省略了projects/前缀。老的 legacy 项目确实支持users/形式的路径,但新的云项目一律要求projects/开头,两者混用会导致"资产明明存在却提示找不到"的情况。我在给同事排查问题时,十次里有三次是路径前缀写错了。

树形结构意味着资产有"文件夹"和"叶子"两种形态。文件夹(folder)只负责组织层级,本身不存数据;叶子资产可能是 image、image_collection、table 或 classifier。listAssets只返回直接子级,不递归。这一点非常关键:如果你想清理projects/p1/assets/tmp/下面所有东西,先对其listAssets拿到一批 ID,再对每个 ID 判断类型,如果是 folder 还要再进去一层。写批量清理脚本时,我通常实现一个深度优先的递归函数,先收集所有叶子节点,再统一删除,这样能避免删除顺序问题。

判断类型我用的方法是ee.data.getAsset(id)返回对象里的type字段,取值形如IMAGE、IMAGE_COLLECTION、TABLE、FOLDER、CLASSIFIER。Python 侧ee.data.get_info(id)返回字典,字段名是type。这条信息在删除前一定要打出来看一眼,因为deleteAsset对不同类型的处理并不完全一样。

2.2 权限和配额才是真正的隐形天花板

删除和复制失败,九成以上不是语法问题,而是权限或配额问题。先说权限:Earth Engine 的资产权限分得比较细,读取、写入、管理是三档。你要删除一个资产,需要对该资产本身以及它的父路径都有管理权限;只对文件有权限但父目录没有,同样会报权限错误。跨项目复制更严格,源项目需要读权限,目标项目需要写权限,而且目标云项目必须已经完成 Earth Engine 的注册和启用——空有一个云项目 ID 是没用的。

再说配额,这是最容易被忽略的一块。每个账号/项目在资产数量、总存储量上都有上限,删除操作本身也受 API 调用频率约束。批量删除几百个资产时,如果循环里没有任何间隔,很容易在中途开始连续失败,而且错误信息往往只写"请求失败",不会明确告诉你是限流。我的做法是每处理 20 到 30 个资产暂停一两秒,并且统计成功/失败数量,失败项单独落盘重试。至于具体的配额数值,官方文档会调整,不要把它硬编码进脚本,用"分批 + 重试"的策略就能适配变化。

还有一条容易被忽略的规则:正在被导出任务写入的资产不能删除。如果你提交了一个 Export.image.toAsset 任务,任务还没结束就去删目标资产,删除请求会被拒绝或者删完后任务又把资产写回来。正确顺序是先ee.data.cancelTask(taskId)取消任务,等状态确认终止后再删。

2.3 删除与复制在语义上的根本差别

deleteAsset是原子性的破坏操作,没有事务回滚。据我的实际体验,删除后官方并没有提供可自助恢复的长期回收站,删错了基本只能重新上传或重新计算。所以我在所有删除脚本里都强制加了一道 dry-run 开关:默认只打印将要删除的 ID 列表和总大小,确认无误后再把开关打开执行。

copyAsset则是"新建副本",不是移动,也不是引用。它会把源资产的元数据、属性、几何/影像数据完整复制一份到目标路径,占用额外存储空间。跨项目复制时,源资产和目标资产属于两个不同的所有者,权限不会自动继承,复制完成后通常需要重新授权给协作者。另外,overwrite这个参数只对单幅 image 类型的资产有效,对 image_collection、table、folder 传True仍然会报"目标已存在"。这一点在文档里写得很含蓄,我在批量复制影像集合时被它卡过一次,后来改成"先判断目标是否存在,存在就先删再复制"的笨办法,反而最稳。

顺便说一个便利点:同项目内的路径调整,用renameAsset比"复制+删除"快得多,也更省存储,因为它本质上只是改了路径引用。只有跨项目时才必须走复制。

3. JavaScript 版实操:删除与复制

3.1 单个资产的删除与结果确认

在 Code Editor 里删一个资产,最直接的做法是用回调形态。下面这段我常用来做单点清理,逻辑很简单:先查资产是否存在、是什么类型,再执行删除,最后统一打印结果。好处是每一步都有输出,出问题能立刻定位在哪一环。

// 单个资产删除:先确认存在性,再执行 var assetId = 'projects/your-project/assets/tmp/ndvi_2020_test'; ee.data.getAsset(assetId, function(info, err) { if (err) { print('资产不存在或无权访问:', err); return; } print('类型:', info.type); print('大小(字节):', info.sizeBytes); ee.data.deleteAsset(assetId, function(result, e) { if (e) { print('删除失败:', e); } else { print('已删除:', assetId); } }); });

这段代码里有两个细节值得说。第一,getAsset的错误回调在资产不存在时也会触发,所以"报错"不一定是真的出错,可能只是路径写错了,打印出来对照一下就能分辨。第二,sizeBytes字段对文件夹通常不返回有效值,只有叶子资产才有意义,别指望用它统计整个文件夹的体积。

>提示:Code Editor 里的print输出不会持久保存,页面刷新就没了。批量操作前把要删的 ID 列表print出来,复制到文本文件里留档,这是最低成本的保险。

3.2 递归删除文件夹里的全部内容

GEE 的deleteAsset作用在 folder 上时,会连同其下的子资产一起删除。这个行为很方便,但也危险——因为它没有二次确认。我一般不用它直接删大文件夹,而是自己写递归,先收集、再确认、后删除,过程可控。下面的函数会用显式栈遍历整棵子树,避免深度过大时栈溢出,同时把所有路径先收集成列表。

// 递归收集某个文件夹下的所有叶子资产 ID function collectLeaves(rootId) { var leaves = []; var stack = [rootId]; while (stack.length > 0) { var current = stack.pop(); var resp = ee.data.listAssets({ parent: current, pageSize: 1000 }); var assets = (resp && resp.assets) ? resp.assets : []; assets.forEach(function(a) { if (a.type === 'FOLDER') { stack.push(a.id); } else { leaves.push(a.id); } }); } return leaves; } var target = 'projects/your-project/assets/tmp'; var toDelete = collectLeaves(target); print('待删除数量:', toDelete.length); print('清单:', toDelete); // 确认无误后取消下面两行的注释 // toDelete.forEach(function(id) { // ee.data.deleteAsset(id, function(r, e) { if (e) print('失败', id, e); }); // });

这里要注意listAssets的分页问题。虽然我传了pageSize: 1000,但一次调用返回的结果仍可能被服务端截断,返回对象里会有nextPageToken。上面的简化版适合子资产数量在千级以内的情况;如果你的目录动辄上万条,就必须在循环里判断nextPageToken并继续请求,否则会漏删。漏删比多删好,但盘点时发现"明明删过怎么还有"同样让人头疼。

3.3 复制资产与跨路径分发的写法

复制在 JS 里就是一行copyAsset,但要考虑三件事:目标是否已存在、是否需要覆盖、复制后权限怎么处理。我通常把"存在就删"和"复制"串起来,做成一个幂等操作,这样脚本重复执行不会报错。

var src = 'projects/proj-a/assets/products/lulc_2022'; var dst = 'projects/proj-b/assets/incoming/lulc_2022'; ee.data.getAsset(dst, function(info, err) { if (!err && info) { print('目标已存在,先删除'); ee.data.deleteAsset(dst, function(r, e) { if (e) { print('删除旧目标失败:', e); return; } doCopy(); }); } else { doCopy(); } }); function doCopy() { ee.data.copyAsset(src, dst, true, function(result, e) { if (e) { print('复制失败:', e); } else { print('复制完成:', dst); } }); }

跨项目复制的耗时和资产体积强相关,单幅 image 一般几秒到几十秒,影像集合可能要几分钟。Code Editor 里发起后不要立刻刷新页面,否则回调可能丢失,你会误以为失败了然后又发一次,导致目标路径冲突。我的经验是:大资产复制前先print一句"开始",等到成功回调出现再操作别的。

>提示:复制不会带走源资产上基于角色的共享设置。如果这个资产要给外部协作者用,复制完记得在资产详情里重新添加权限,别假设它"跟着过来了"。

4. Python 版实操:把清理写进流水线

4.1 初始化与环境准备的几个关键点

Python 侧第一件事是初始化,而且必须显式带项目。不传project参数的写法在旧版本还能跑,现在基本会直接报错或者落到默认项目上,导致你在错误的项目里删东西——这是我最不想看到的场景。初始化有三种常见方式:本机交互式授权、服务账号密钥、以及复用已有的凭据文件。批处理脚本我强烈推荐服务账号,因为无人值守时交互式授权一定会卡住。

import ee # 方式一:本机一次性授权(首次会打开浏览器) ee.Initialize(project='your-project') # 方式二:服务账号(适合定时任务、批量脚本) # import json # from ee import ServiceAccountCredentials # creds = ServiceAccountCredentials( # 'sa-key.json', # scopes=['https://www.googleapis.com/auth/earthengine'] # ) # ee.Initialize(credentials=creds, project='your-project')

服务账号要提前在云项目的权限里加上相应角色,否则能初始化成功但删不动东西。我遇到过一个很典型的场景:脚本能list_assets却delete_asset报错,排查半天发现是服务账号只有读权限。分辨方法很简单,先跑只读接口验证连通性,再跑写接口验证权限,两步分开测,比一次性跑全流程再猜错误位置高效得多。

4.2 分页遍历与批量删除的完整脚本

Python 侧的分页必须手动处理,list_assets返回的是字典,assets是当前页,nextPageToken是下一页的令牌。下面这个函数是我实际项目里在用的版本,做了三件事:递归展开文件夹、跳过指定白名单、统计体积。删除部分用 try/except 包住,失败项写入文件方便二次处理。

import ee import time import json ee.Initialize(project='your-project') def list_children(parent, page_size=1000): """拿一个目录下的全部直接子资产,自动翻页""" result = [] token = None while True: kwargs = {'page_size': page_size} if token: kwargs['page_token'] = token resp = ee.data.list_assets(parent, **kwargs) result.extend(resp.get('assets', [])) token = resp.get('nextPageToken') if not token: break return result def walk(root, skip_keywords=()): """递归展开,返回所有叶子资产""" leaves = [] stack = [root] while stack: cur = stack.pop() for a in list_children(cur): aid = a['id'] if any(k in aid for k in skip_keywords): continue if a.get('type') == 'FOLDER': stack.append(aid) else: leaves.append({'id': aid, 'type': a.get('type'), 'size': a.get('sizeBytes', 0)}) return leaves def delete_batch(items, dry_run=True, interval=0.2): ok, failed = [], [] for it in items: aid = it['id'] if dry_run: print('[DRY-RUN] 将删除:', aid, it['size'], '字节') continue try: ee.data.delete_asset(aid) ok.append(aid) print('已删除:', aid) except Exception as e: failed.append({'id': aid, 'err': str(e)}) print('失败:', aid, e) time.sleep(interval) return ok, failed root = 'projects/your-project/assets/tmp' targets = walk(root, skip_keywords=('keep_', 'golden_')) print('共发现', len(targets), '个待处理资产') print('总体积约', round(sum(t['size'] for t in targets) / 1e9, 2), 'GB') ok, failed = delete_batch(targets, dry_run=True) # 先空跑 # ok, failed = delete_batch(targets, dry_run=False) # 确认后再执行 with open('delete_failed.json', 'w') as f: json.dump(failed, f, ensure_ascii=False, indent=2)

这段脚本里有几个我反复优化的点。skip_keywords白名单是硬需求,因为清理脚本最常见的悲剧是"顺手把基准数据集也带走了",加一个命名约定(比如重要资产统一以keep_开头)就能避免。interval参数看起来不起眼,但批量删除上千个资产时,没有间隔的话中途必然开始连续报错。写完ok/failed落盘也是必要的,失败了不用从头再找。

4.3 复制与移动:什么时候该用哪个

Python 侧的copy_asset与rename_asset分工很明确。同一项目内调整目录结构、改命名规范,用rename_asset,几乎瞬间完成,不额外占用空间。跨项目交付数据,才用copy_asset。下面是一个带前置检查的复制封装,能直接放进流水线里当工具函数。

def asset_exists(asset_id): try: ee.data.get_info(asset_id) return True except Exception: return False def safe_copy(src, dst, overwrite_image=True): """ 幂等复制:目标存在则按类型决定是否覆盖 overwrite 只对单幅 image 有效,集合/表必须先删 """ if asset_exists(dst): info = ee.data.get_info(dst) if info.get('type') == 'IMAGE' and overwrite_image: print('目标为 image,直接覆盖') else: print('目标已存在且不支持覆盖,先删除') ee.data.delete_asset(dst) time.sleep(1) ee.data.copy_asset(src, dst) print('复制完成:', src, '->', dst)

调用时注意,copy_asset是同步阻塞的。复制一个大影像集合可能几分钟到十几分钟,中间没有任何进度输出,看上去像卡死。我的处理办法是把它放到独立线程或者干脆拆成多个小集合分批复制,每批完成打印一行,至少能判断是不是还在动。如果是几十 GB 级别的集合,更好的思路是不复制,直接让使用方在你原来的项目里获得读权限,省时省空间。

另外,Python 侧还有一条命令行通道值得记住:earthengine cp、earthengine rm、earthengine ls。做一次性手工操作时,命令行比写脚本快得多,而且在 CI 环境里也方便串进 shell 脚本。只是命令行同样没有 dry-run,敲之前最好先用ls看清楚。

5. 常见报错与排查速查表

5.1 高频错误对照与处置建议

下面这张表是我这几年遇到过、也帮别人排查过的典型问题汇总。看报错信息本身往往不够,要结合"哪一步失败"来定位:是列目录就失败(权限/路径问题),还是删除时失败(配额/父子权限问题),还是复制时失败(目标冲突/跨项目权限问题)。

现象或报错关键字最常见原因处置方式
Asset not found路径前缀错、少了projects/、拼写错先用getAsset/get_info验证,再复制确认过的路径
Permission denied对资产或父目录无管理权限;服务账号只读检查账号角色,确认父路径也有权限
Cannot delete non-empty folder类提示部分场景下目录未清空先递归列出子资产逐个删,再删目录本身
批量删到一半开始连续失败触发调用频率限制加间隔、分批、失败项单独重试
already exists目标路径已被占用,且类型不支持覆盖先查类型,非 image 时先删再复制
复制大集合长时间无响应同步阻塞,没有进度反馈分批复制或改为授权共享而非复制
删除后资产又出现有导出任务正在写同一路径先cancelTask并等任务终止
复制成功但协作者看不到权限不随复制继承复制后在目标资产上重新授权

>注意:报错信息里出现Invalid argument时别急着改代码,先检查资产 ID 是不是包含了首尾空格、中文标点或者多余斜杠。我在从表格里复制 ID 时栽过好几次,肉眼完全看不出来。

5.2 几个只有实际做过才会记住的细节

第一个细节关于删除顺序。删文件夹时如果你既删了父目录又删了子资产,第二次操作一定会报"不存在"。批量脚本里我一般只收集叶子节点,从不把目录本身放进删除列表,让目录留空或者最后单独处理,这样即使中途失败,重跑也不会因为"父目录没了"而整体崩掉。

第二个细节关于资产类型判断的时机。listAssets返回的type字段在部分情况下可能缺失或者与getAsset的结果不一致,尤其是较老的 legacy 资产。稳妥做法是对拿不准的资产单独调一次getAsset/get_info确认,别完全依赖列表里的类型字段。这个成本很低,但能避免把整个影像集合当成单幅影像去覆盖。

第三个细节关于时间。跨时区协作的项目里,资产名里带日期是很常见的约定,但如果你用日期做清理条件(比如"删除 90 天前的中间结果"),注意updateTime是服务端时间,别用本地时间去比。我见过一次因为时差导致本该保留的当天产物被算成过期,虽然只是中间结果,重跑也花了大半天。

第四个细节关于日志。所有删除操作我都要求脚本输出"ID + 类型 + 体积 + 时间戳"四要素,写进本地文件。这份日志在做成本核算时特别有用——你能清楚看到哪一类中间结果最占地方,下次就能从流程上少生成一些,而不是每次都靠删。

6. 工程化建议:让资产管理可回滚、可复现

6.1 用命名规范和 dry-run 把风险挡在执行之前

技术手段能解决的问题,最好别靠人的记性。我给所有项目的资产目录定了三条规矩:assets/incoming/只放原始上传,assets/tmp/放可随时清理的中间结果,assets/products/放正式成果且禁止脚本触碰。清理脚本的作用域永远只开在tmp/上,物理隔离比任何白名单都可靠。这条规矩推行之后,团队里再没出现过误删正式成果的情况。

dry-run 不是走过场。我的做法是让脚本在 dry-run 模式下除了打印清单,还输出聚合统计:总数、总体积、按类型分组的数量。这个统计本身就是决策依据——你可能会发现待删的 800 个资产里,有 700 个是几百 KB 的小表,真正占空间的只有几十个大影像集合。那就没必要全删,聚焦处理大头即可,既降低风险也节省调用次数。

再补一个实用技巧:延迟删除。正式执行前,先把待删清单写入一个 JSON 文件并打上时间戳,隔一天再跑实际删除。这一天里如果有人反馈"某个中间结果还要用",改清单就行。这个缓冲期在多人协作的项目里价值极高,成本几乎为零。

6.2 把资产运维纳入版本管理与可复现流程

脚本一旦稳定,就应该进代码仓库,而不是散落在各个人的本地目录里。我通常把资产运维脚本和业务脚本放在同一个仓库的不同目录下,共享同一份配置(项目 ID、路径前缀、白名单关键字),这样换人接手时不用重新摸索。配置文件里我习惯把"项目 ID"和"根路径"抽成变量,脚本里绝不出现硬编码的完整路径,迁移到新项目时只改一处。

复现性方面,我的经验是:任何一次批量删除或复制,都应该能从日志反推出"当时删了什么、为什么删、谁执行的"。这三个信息里,前两个靠脚本日志,第三个靠提交记录。做到了这一点,出问题时至少能快速定位影响范围,而不是一群人对着空目录互相猜测。

最后分享一个我在实际使用中形成的习惯:每次大清理之前,先跑一遍"清单生成 + 体积统计 + dry-run"三连,把结果贴到项目记录里,等确认后再执行删除。多花的这几分钟,换来的是不用在深夜因为一个误删的路径而重新排队跑几十个小时的计算任务。至于复制,我的原则更简单——能不复制就不复制,优先用权限共享解决分发问题,毕竟存储配额是有限的,而权限是几乎零成本的。