Daft 作业的业务代码与 Python 依赖时常变动,若全量打包进镜像,会需要频繁修改镜像,若把解压后的依赖直接放 TOS 上,冷启动会被大量小文件拖慢。本文为您介绍最佳实践方案:TOS 只存 project.zip 和 Wheel 归档,首次连接 Ray 时利用 Runtime Env 一次性下发,working_dir 分发代码,pip 在每个 Worker 本地建虚拟环境并按哈希缓存,同配置的作业再次启动即可使用缓存,提高启动效率。
用户使用统一的 Daft 基础镜像运行任务时,业务侧有两类内容变化频繁:
通常的处理方案都会引发明显问题:
TOS 只保存 ZIP 或 Wheel 制品;代码与依赖的分发全部交给 Ray Runtime Env,且只在首次连接 Ray 时执行一次。
各角色分工如下:
project.zip 和依赖 Wheel,不上传解压后的 site-packages。ray.init 时刻),一次性把代码和依赖下发到每一个 Worker:
runtime_env.working_dir 指向 Driver 解压后的目录,Ray 自动同步到每个 Worker 本地磁盘。runtime_env.pip 指定 Wheel 列表或 requirements 文件,Ray 在每个 Worker 本地执行 pip install,构建独立的 pip 环境。配置 | 能力 | 本方案中的用途 |
|---|---|---|
| 在 LAS Pod 创建时注入普通环境变量,不负责安装 Python 包 | 传递归档路径、本地目录和业务配置 |
| 支持 | 安装动态 Wheel、分发业务代码,并让 Daft 的远程 Runner、Task 和 Actor 继承 |
按变化频率、体积、是否被 Python import 三个维度,把作业运行时用到的资产分成 4 层,各自放到最合适的位置。
层级 | 内容 | 推荐位置 | 分层原因 |
|---|---|---|---|
1 | Daft、Ray、CUDA、PyTorch 等稳定大依赖 | 基础镜像 | 体积大、变化少,避免每次冷启动重新安装。 |
2 | 中小型、经常变化的 Wheel | 客户 Mirror + | 在各节点本地安装,并按依赖配置缓存。 注意
|
3 | 业务 Python 源码 | TOS 中的 | TOS 只传输一个压缩包,Worker 在本地解压和 注意 业务代码归档结构建议:
错误写法示例:
|
4 | 数据集、模型文件 | TOS、NAS 或 vePFS | 与 Python |
TOS 只放 ZIP 或 Wheel,代码与依赖的分发交给 Ray Runtime Env。
site-packages 小文件。working_dir 分发一次,缓存复用: Ray 把 working_dir 分发并解压到每个节点的本地 Runtime Env 缓存,同一连接期内多次任务复用。pip 环境节点本地隔离: 动态 Wheel 安装到每个节点本地的独立 pip 环境,按配置哈希缓存,同配置下次启动即命中。节点本地路径类似于:
/tmp/ray/session.../runtime_resources/ ├── pip/<hash>/virtualenv/lib/python3.10/site-packages/ └── working_dir_files/<package-hash>/
working_dir 只用于代码和小型配置,不应包含数据集、模型权重或虚拟环境。pip.conf、Mirror 网络和私有包认证必须对所有 Worker 生效。requirements.txt
# 动态依赖清单,交给 runtime_env.pip 装到每个 Worker humanize==4.13.0
operators.py
import json import os import time def enrich_record(value: int) -> str: # Daft UDF:每个 Worker 上远程执行,返回一条 JSON # 结果字段用于验证:依赖已装、代码来自本地缓存、任务跨多 Worker # import 放函数体内:等 runtime_env 生效后再解析 import humanize import operators import ray # 让分区执行时间足够长,便于观察多 Worker 并发 time.sleep(0.25) return json.dumps({ "humanized": humanize.intcomma(value * 1000), "humanize_file": humanize.__file__, # 应位于节点本地虚拟环境 "humanize_version": humanize.__version__, "node_id": ray.get_runtime_context().get_node_id(), "operators_file": operators.__file__, # 应位于 working_dir 本地缓存 "runtime_env_marker": os.environ.get("LAS_NODE_LOCAL_RUNTIME_ENV"), "value": value, }, sort_keys=True)
main.py
import json import os from collections import Counter import daft import ray from operators import enrich_record # Driver 入口命令已把 project.zip 解压到该目录 PROJECT_DIR = os.environ["LOCAL_PROJECT_DIR"] # 解析 requirements.txt,得到 pip 包列表 with open(os.path.join(PROJECT_DIR, "requirements.txt")) as file: packages = [ line.strip() for line in file if line.strip() and not line.lstrip().startswith("#") ] # 方案核心:working_dir 分发代码;pip 在每个 Worker 本地建虚拟环境并按哈希缓存;env_vars 随之下发 runtime_env = { "working_dir": PROJECT_DIR, "pip": { "packages": packages, # 本次镜像有历史冲突,关闭全环境检查;镜像干净时建议改回 True "pip_check": False, }, "env_vars": { "LAS_NODE_LOCAL_RUNTIME_ENV": "enabled", }, } # 若平台/SDK 已经先 init 过 Ray,runtime_env 不会生效,直接失败 if ray.is_initialized(): raise RuntimeError( "Ray was initialized before runtime_env was configured; " "configure runtime_env at the platform's first ray.init() instead." ) # 连接 LAS 已拉起的集群,此刻一次性把 runtime_env 下发到所有 Worker ray.init(address="auto", runtime_env=runtime_env) daft.set_runner_ray() # 24 条数据 / 24 分区,跑 UDF 得到 enriched 列 result = ( daft.from_pydict({"value": list(range(24))}) .repartition(24) .with_column( "enriched", daft.col("value").apply( enrich_record, return_dtype=daft.DataType.string(), ), ) .collect() .to_pydict() ) records = [json.loads(item) for item in result["enriched"]] node_counts = Counter(record["node_id"] for record in records) # 端到端断言:数据完整、≥3 Worker、依赖/代码/env_vars 均来自 runtime_env 且不再回访 TOS assert len(records) == 24 assert len(node_counts) >= 3 assert all(record["humanize_version"] == "4.13.0" for record in records) assert all(record["runtime_env_marker"] == "enabled" for record in records) assert all("/intelligent-data/" not in record["humanize_file"] for record in records) assert all("/intelligent-data/" not in record["operators_file"] for record in records) print("worker_node_counts:", dict(node_counts)) print("sample:", records[0]) print("LAS_NODE_LOCAL_RUNTIME_ENV_E2E_SUCCESS")
通过 LAS API 提交
运行前设置:
# 安装火山引擎 Python SDK(含 LAS 2025-08-15 API) python -m pip install volcengine-python-sdk # LAS API 与 TOS 挂载共用同一组 AK/SK;生产请走密钥托管 export ACCESS_KEY="<your_ak>" export SECRET_KEY="<your_sk>" # LAS 资源池与 TOS 归档前缀(前缀下只放 project.zip) export LAS_RESOURCE_ID="<LAS 队列或独占资源 ID>" export TOS_ARTIFACT_URI="tos://<your_bucket>/<path>/" # 本文验证使用的 CPU 镜像;账号需可拉取 export LAS_DAFT_IMAGE="las-ai-online-cn-beijing.cr.volces.com/las/ve-daft-pylance7:0.7.21.post1-py3.10-ubuntu24.04" # 先 CreateTask 建任务模板,再 RunTaskInstance 拉起实例 python submit_daft_job.py
说明
AK/SK 只能通过安全配置或环境变量注入,不要写进代码、镜像或日志。本文示例全程使用同一组 AK/SK,因此该账号需要同时具备调用 LAS API 和访问 TOS 的权限。
下面的提交脚本只把一个 project.zip 从 TOS 挂载到任务中。入口命令将 ZIP 复制到 Driver 本地盘并解压,不在 TOS 上安装依赖,也不向 TOS 回写项目小文件。
import os import uuid from volcenginesdkcore import ApiClient, Configuration from volcenginesdklas20250815 import LAS20250815Api from volcenginesdklas20250815.models import ( CreateTaskRequest, DaftResourceConfigForRunTaskInstanceInput, DaftTaskForCreateTaskInput, EnvForCreateTaskInput, RunEnvForRunTaskInstanceInput, RunTaskInstanceRequest, TosMountForCreateTaskInput, TosMountPathForCreateTaskInput, ) # AK/SK 同时用于 LAS API 认证和 TOS CSI 挂载 ACCESS_KEY = os.environ["ACCESS_KEY"] SECRET_KEY = os.environ["SECRET_KEY"] # 资源 ID、镜像、TOS 归档前缀 RESOURCE_ID = os.environ["LAS_RESOURCE_ID"] IMAGE = os.environ["LAS_DAFT_IMAGE"] TOS_SOURCE = os.environ["TOS_ARTIFACT_URI"] REGION = "cn-beijing" LAS_HOST = "open.volcengineapi.com" # ARTIFACT_ROOT:TOS 只读挂载点;LOCAL_ROOT:Driver 本地盘,用于解压和运行 ARTIFACT_ROOT = "/intelligent-data/artifacts/my_daft_job" LOCAL_ROOT = "/tmp/my_daft_job" LOCAL_PROJECT_DIR = f"{LOCAL_ROOT}/project" # 初始化 LAS OpenAPI 客户端 config = Configuration() config.ak = ACCESS_KEY config.sk = SECRET_KEY config.region = REGION config.host = LAS_HOST config.scheme = "https" api = LAS20250815Api(ApiClient(config)) # Driver 入口命令:复制 zip 到本地 → 本地解压 → 运行 main.py # 中间打三个时间戳,便于事后拆分冷启动耗时 entrypoint = ( "bash -lc 'set -euo pipefail; " "date \"+ARTIFACT_COPY_START=%F %T\"; " "mkdir -p \"$LOCAL_PROJECT_DIR\"; " "cp \"$ARTIFACT_ROOT/project.zip\" \"$LOCAL_ROOT/project.zip\"; " "date \"+ARTIFACT_COPY_DONE=%F %T\"; " "unzip -o -q \"$LOCAL_ROOT/project.zip\" -d \"$LOCAL_PROJECT_DIR\"; " "date \"+LOCAL_UNZIP_DONE=%F %T\"; " "python \"$LOCAL_PROJECT_DIR/main.py\"'" ) # 第一步:CreateTask 定义可复用的 Daft 任务模板,把 TOS 前缀挂到 ARTIFACT_ROOT created = api.create_task( CreateTaskRequest( client_token=str(uuid.uuid4()), # 幂等 token name="daft-node-local-runtime-env", description="Distribute code and wheels to Ray node-local environments", task_type="Daft", timeout_minutes=30, daft_task=DaftTaskForCreateTaskInput( image=IMAGE, image_type="BuiltIn", entrypoint=entrypoint, envs=[EnvForCreateTaskInput(key="PYTHONUNBUFFERED", value="1")], ), tos_mount=TosMountForCreateTaskInput( mount_type="Csi", access_key=ACCESS_KEY, secret_key=SECRET_KEY, tos_mount_paths=[ TosMountPathForCreateTaskInput( source=TOS_SOURCE, mount_path=f"{ARTIFACT_ROOT}/", ) ], ), ) ) # 第二步:RunTaskInstance 拉起实例 # run_envs 只传路径变量;真正的 pip/working_dir 由 main.py 里的 ray.init(runtime_env=...) 完成 # 1 Head + 3 Worker,每节点 1C4G;GPU 场景改 worker_gpu_num 并换 GPU 镜像 started = api.run_task_instance( RunTaskInstanceRequest( client_token=str(uuid.uuid4()), task_id=created.task_id, resource_id=RESOURCE_ID, resource_spec_group_id="0", run_envs=[ RunEnvForRunTaskInstanceInput(key="ARTIFACT_ROOT", value=ARTIFACT_ROOT), RunEnvForRunTaskInstanceInput(key="LOCAL_ROOT", value=LOCAL_ROOT), RunEnvForRunTaskInstanceInput(key="LOCAL_PROJECT_DIR", value=LOCAL_PROJECT_DIR), ], daft_resource_config=DaftResourceConfigForRunTaskInstanceInput( head_cpu_num=1, head_memory_size=4, worker_cpu_num=1, worker_memory_size=4, worker_gpu_num=0, replicas=3, ), ) ) print("task_id:", created.task_id) print("task_instance_id:", started.task_instance_id)
pip check 冲突。runtime_env 配置在第一次 ray.init() 时生效,不要只修改 Driver 的 sys.path。project.zip 的版本路径或内容哈希,避免复用旧缓存。--target 目录。