LLM学習データの準備:FineWebの100GBスライスをS3に置いてみた
はじめに
LLMのトレーニングを学び始めると、最初に「学習データをどう用意して、どこに置くか」という話にぶつかります。今回はその第一歩として、LLMの学習データセットとして有名なFineWeb(Common Crawl由来、約4TB)の一部を、自分のS3に置いて使えるようにしてみました。
ネットのチュートリアルは load_dataset(...) や snapshot_download(...) といったHuggingFace側の話が中心です。でも自分がやりたかったのは「決まったスライスをS3に、再現可能な形で置く」というAWS側の話で、こちらはあまり情報が見つかりませんでした。
今回のゴールはこの2つです。
- FineWebから決まった約100GBのスライスをS3バケットに置く
- 半年後に他人(や自分)が同じバイト列かどうかを、manifestで検証できるようにする
転送のコア自体は短いPythonで済みます。ただ、それをAWS上で実際に完走させるまでに何度か詰まったので、そこも含めて残しておきます。
検証環境
- 検証用のAWSアカウント(Tokyoリージョン
ap-northeast-1、admin権限あり) - AWSの認証は一時credential(SSO / assume-role のようなもの。静的なaccess keyファイルは持たない)
- SkyPilot(EC2をワンショットで立てる用。導入は次のセクション)
- Python 3.12
- Terraform v1.13(S3バケットの管理用)
- ローカルはmacOS
セットアップ:SkyPilotを入れてAWSにつなぐ
最初の関門はここです。SkyPilotを入れて、AWSにアクセスできる状態を作ります。
インストールは uv を使いました(SkyPilotはPython 3.9〜3.13対応)。プロジェクトごとに入れるパターンはこう。
uv venv --seed --python 3.12
uv pip install 'skypilot[aws]'
マシン全体で使うなら uv tool install 'skypilot[aws]' でも入ります(この場合はvenv不要)。[aws] はAWS用の依存だけを入れる指定です。
次にAWSのcredential。SkyPilotは独自の認証ファイルを持たず、AWS CLIと同じcredentialチェーンを使います。なので、AWS CLIが通る状態にしておけばOKです(access keyなら aws configure、SSOなら aws sso login)。
つながっているかは sky check で確認します。ここが最初の関門で、通らないと後の sky launch は全部こけます。
uv run sky check aws
成功するとこう出ます。
🎉 Enabled infra 🎉
AWS [compute, storage]

この表示が出れば、SkyPilotからAWS(compute = EC2、storage = S3)を使える状態です。ここを確認してから先に進みます。
設計の全体像
パイプラインはこれだけです。

- ローカルにファイルを一切落とさない。
hf://の読み込みストリームとs3://の書き込みストリームをfsspecでつないでバイトを流すだけ。 - 転送はラップトップではなく、SkyPilotで立てたEC2上で走らせる(帯域とスリープ問題を回避)。
- manifestは転送とは別パスで、アップロード後にS3から生成する。こうすると「バケットさえあればHuggingFaceに再アクセスせず再生成できる」独立したartifactになる。
スライスは「1つのdumpから、ファイル名ソートで先頭N件」と決めます。同じdump・同じ件数を指定すれば、(データセットのrevisionが同じなら)誰がやっても同じファイル集合が落ちてきます。ここで注意なのは、この選び方だけでは「取ってくるファイル」は揃うものの、将来 main の中身が更新されると同じにならない点です。厳密な再現のために、後述のmanifestにrevisionとハッシュを記録して、後から同一性を検証できるようにします。
フォルダ構成
自動生成ファイル(.venv/、.terraform/、*.tfstate)は省いています。
test/
├── .sky.yaml # SkyPilot project config (auth method)
├── fineweb.yaml # SkyPilot task (instance + run steps)
├── stream_fineweb_slice.py # hf:// -> s3:// streaming
├── build_manifest.py # build the manifest after upload
└── infra/
└── main.tf # S3 bucket (Terraform)
S3バケットはTerraformで作る
バケットはこのプロジェクトで唯一の永続リソースです(EC2は使い終わったら消す)。後で綺麗に消せるようにTerraformで管理します。ポイントは2つ。
- S3のバケット名は全世界でユニークでないといけない。なので
fineweb-mirrorのようなprefixにランダムなsuffixを付ける。こうするとコミットするコードにアカウント固有の情報が残らない。 force_destroy = trueにすると、100GB入ったバケットでもterraform destroyで消せる。検証環境なので割り切り。
# infra/main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
random = { source = "hashicorp/random", version = "~> 3.0" }
}
}
provider "aws" {
region = var.region
}
variable "region" {
default = "ap-northeast-1"
}
variable "bucket_prefix" {
default = "fineweb-mirror"
}
# S3 bucket names are globally unique; a random suffix avoids collisions
resource "random_id" "suffix" {
byte_length = 4 # -> 8 hex chars, e.g. "a3f9c1d0"
}
resource "aws_s3_bucket" "fineweb" {
bucket = "${var.bucket_prefix}-${random_id.suffix.hex}"
# allow destroy even when the bucket still holds ~100GB (test env)
force_destroy = true
}
output "bucket_name" {
value = aws_s3_bucket.fineweb.bucket
}
cd test/infra
terraform init
terraform apply # create the bucket
terraform output bucket_name # pass this name to sky launch
SkyPilotのタスクと2つのスクリプト
転送本体が stream_fineweb_slice.py。hf:// を読んで s3:// に流すだけです。
# stream_fineweb_slice.py
import argparse
import concurrent.futures
import os
import shutil
import sys
import fsspec
from huggingface_hub import HfFileSystem
HF_REPO = "datasets/HuggingFaceFW/fineweb"
def list_slice(dump: str, count: int) -> list[str]:
"""Return the first `count` parquet paths in `dump`, name-sorted (deterministic)."""
hf = HfFileSystem()
all_files = hf.glob(f"{HF_REPO}/data/{dump}/*.parquet")
if not all_files:
sys.exit(f"No parquet files found for dump {dump!r}")
chosen = sorted(all_files)[:count]
print(f"Selected {len(chosen)} / {len(all_files)} files from {dump}")
return chosen
def stream_one(hf_path: str, bucket: str, dump: str, region: str) -> str:
"""Stream one file hf:// -> s3:// without touching local disk."""
filename = hf_path.split("/")[-1]
s3_key = f"fineweb/{dump}/{filename}"
src = f"hf://{hf_path}"
dst = f"s3://{bucket}/{s3_key}"
# region in client_kwargs is REQUIRED (see below). Without it s3fs writes to
# the wrong regional endpoint and PutObject fails: "No AWSAccessKey was presented".
with fsspec.open(src, "rb") as fin, \
fsspec.open(dst, "wb", client_kwargs={"region_name": region}) as fout:
shutil.copyfileobj(fin, fout, length=32 * 1024 * 1024) # 32MB chunks
print(f" uploaded {s3_key}")
return s3_key
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--dump", required=True)
p.add_argument("--count", type=int, default=50)
p.add_argument("--bucket", required=True)
p.add_argument("--workers", type=int, default=8)
p.add_argument("--region", default=os.environ.get("AWS_DEFAULT_REGION", "ap-northeast-1"))
args = p.parse_args()
files = list_slice(args.dump, args.count)
# network I/O -> threads (not processes) overlap the waiting
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(stream_one, f, args.bucket, args.dump, args.region) for f in files]
for fut in concurrent.futures.as_completed(futures):
fut.result()
print(f"Done: {len(files)} files -> s3://{args.bucket}/fineweb/{args.dump}/")
if __name__ == "__main__":
main()
manifestは転送とは別で、アップロード後にS3側だけを見て作ります。各行が1ファイルをsize + ETag + 行数でピン留めするので、読者が「自分は本当に同じバイト列を持っているか」をチェックできます。これがこの記事の実質的な成果物です。
# build_manifest.py
import argparse
import json
import os
import boto3
import pyarrow.parquet as pq
import fsspec
def build(bucket, dump, revision, pulled_at, region):
s3 = boto3.client("s3", region_name=region)
paginator = s3.get_paginator("list_objects_v2")
entries = []
for page in paginator.paginate(Bucket=bucket, Prefix=f"fineweb/{dump}/"):
for obj in page.get("Contents", []):
key = obj["Key"]
if not key.endswith(".parquet"):
continue
# read only the parquet footer for the row count (not the whole file)
with fsspec.open(f"s3://{bucket}/{key}", "rb",
client_kwargs={"region_name": region}) as f:
rows = pq.read_metadata(f).num_rows
entries.append({
"path": key,
"size_bytes": obj["Size"],
"etag": obj["ETag"].strip('"'), # multipart ETags carry a "-N" suffix
"rows": rows,
"hf_revision": revision,
"pulled_at": pulled_at,
})
print(f" manifested {key} ({rows} rows)")
entries.sort(key=lambda e: e["path"])
return entries
def main():
p = argparse.ArgumentParser()
p.add_argument("--dump", required=True)
p.add_argument("--bucket", required=True)
p.add_argument("--revision", default="main")
p.add_argument("--pulled-at", default="unknown")
p.add_argument("--region", default=os.environ.get("AWS_DEFAULT_REGION", "ap-northeast-1"))
args = p.parse_args()
entries = build(args.bucket, args.dump, args.revision, args.pulled_at, args.region)
if not entries:
raise SystemExit(f"No parquet objects under fineweb/{args.dump}/")
body = "\n".join(json.dumps(e) for e in entries) + "\n"
manifest_key = f"manifests/{args.dump}-{len(entries)}files-v1.jsonl"
boto3.client("s3", region_name=args.region).put_object(
Bucket=args.bucket, Key=manifest_key,
Body=body.encode("utf-8"), ContentType="application/x-ndjson",
)
print(f"Wrote manifest: s3://{args.bucket}/{manifest_key} ({len(entries)} entries)")
if __name__ == "__main__":
main()
生成されるmanifestは1行1ファイルのjsonlです。
{"path": "fineweb/CC-MAIN-2024-10/000_00000.parquet", "size_bytes": 2147483648, "etag": "a4f3...-21", "rows": 973991, "hf_revision": "main", "pulled_at": "2026-07-25T09:12:33Z"}
これを束ねるSkyPilotのタスク定義が fineweb.yaml。バケット名とHFトークンは起動時に --env で渡すので、ファイル自体はそのまま公開しても大丈夫です。
# fineweb.yaml
resources:
cloud: aws
region: ap-northeast-1
instance_type: c7g.2xlarge # 16GB RAM (see the memory-shortage section below)
use_spot: false # on-demand (see the spot section below)
disk_size: 30 # boot disk only; nothing lands on local disk
envs:
HF_TOKEN: null # supplied via --env at launch
S3_BUCKET: null # supplied via --env at launch
CC_DUMP: CC-MAIN-2024-10
FILE_COUNT: 50 # ~2.0 GB/file measured, so 50 x 2.0 = ~100 GB
AWS_DEFAULT_REGION: ap-northeast-1 # must match the bucket region (see below)
workdir: .
setup: |
pip install 'huggingface_hub[hf_xet]>=1.5' 's3fs' 'pyarrow' 'boto3'
run: |
set -e
python stream_fineweb_slice.py --dump "$CC_DUMP" --count "$FILE_COUNT" --bucket "$S3_BUCKET" --workers 4
python build_manifest.py --dump "$CC_DUMP" --bucket "$S3_BUCKET" \
--revision main --pulled-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
起動はこれだけ。-i 5 --down は「アイドル5分でインスタンスを自動で落とす」設定です。
cd test
uv run sky launch -c fw-pull -i 5 --down fineweb.yaml \
--env HF_TOKEN=none \
--env S3_BUCKET=$(terraform -chdir=infra output -raw bucket_name) \
--env FILE_COUNT=50
(FineWebはpublicなのでトークンなしで読めます。スクリプトも HF_TOKEN を使っていません。上の HF_TOKEN=none は、YAMLで必須envにしている都合でダミーを渡しているだけです。トークンが要るgatedなデータセットを扱うときだけ本物を渡してください。)
まず1ファイルで試す
上のコマンドをいきなり50ファイルで回す前に、FILE_COUNT=1 にして1ファイルだけ流しました。1ファイルなら短時間・数円で終わるので、パイプラインが通るかを安く確認できます。
この1ファイル段階で分かったのは主に2つ。
- 読み込み → S3書き込み → manifest生成まで、一通り動く
- 1ファイルは約2.0GB(この数字を見て本番のファイル数を決めた)
ちなみに、次に挙げる詰まりどころのうち認証まわり(1〜3)は、ほとんどこの1ファイルのテストで出し切りました。並列数や実行時間に効くもの(4・5)は、50ファイルに増やして初めて出てきます。小さく回して問題を早めに潰してから本番に進む、という流れです。

動かすまでに詰まった5つのポイント
ここまでが設計です。ただ、上のYAMLの use_spot: false・c7g.2xlarge・AWS_DEFAULT_REGION は最初から知っていた値ではなく、全部ハマった結果たどり着いた値です。ぶつかった順に並べます。
| # | 症状 | 原因 | 対処 |
|---|---|---|---|
| 1 | awsコマンドが InvalidClientTokenId |
aws が別の認証ツールにaliasされていて、こちらのセッションを上書きしていた |
alias側を無効化(unalias aws 等) |
| 2 | EC2上のジョブがS3書き込みで No AWSAccessKey was presented |
SkyPilotのデフォルト(LOCAL_CREDENTIALS)はローカルの ~/.aws をアップロードする方式。一時credentialは静的ファイルが無いのでインスタンスに何も渡らなかった |
.sky.yaml で remote_identity: SERVICE_ACCOUNT にしてインスタンス自身のIAM roleを使わせる |
| 3 | roleを付けても書き込みだけ No AWSAccessKey のまま |
s3fsにregionが無く、書き込みが誤ったendpointに行って署名されなかった | AWS_DEFAULT_REGION + コードで region_name を明示 |
| 4 | 実行中に UP → INIT に戻って停止 |
spotインスタンスがpreemption(AWSに回収)された。sky launch はpreemptされたジョブを自動再開しない |
use_spot: false(on-demand)に切り替え |
| 5 | Rayがドライバをメモリ不足でkill (FAILED_DRIVER) |
c7g.largeは4GB RAM。8並列で各ストリームが約2GB/fileを両端でバッファ → 4GB超過 | インスタンスを16GBに、workersを4に |
個別の対処はテーブルの通りです。ここでは、特に理解しておくと応用が効く3つだけ深掘りします。
(2) IAM roleは「権限を持つ」んじゃなくて「一時credentialを配る」
エラー No AWSAccessKey was presented を最初に見たとき、「roleにS3権限が付いてるのに何を言ってるんだ」と思いました。でもこれは誤解でした。
AWSのAPIリクエストは必ず署名(SigV4)が要ります。「このインスタンスは権限があるから通す」という暗黙の許可はありません。IAM roleがやっているのは、EC2のメタデータサービス(IMDS, 169.254.169.254)経由で一時的なcredentialを配ること。SDK(boto3やs3fs)はそれを取ってきてリクエストに署名します。
- roleをアタッチ → AWSが一時credentialをIMDSに載せる
- SDKがIMDSからcredentialを取得する
- SDKがそのcredentialでリクエストに署名する(ここで「access key」が使われる)
- S3が署名を検証 → その後で初めてroleの権限をチェックする
No AWSAccessKey was presented は「リクエストが署名されていない」=ステップ3が起きていない、という意味でした。S3は認証(署名)を認可(権限)より先に見ます。だから、roleにS3FullAccessが付いていても、署名されていないリクエストは権限を見る前に弾かれます。
(2)の原因は、SkyPilotのデフォルトがローカルの ~/.aws ファイルをアップロードする方式だったこと。SSOやassume-roleのような一時credentialは環境変数で渡されることが多く、アップロードすべき静的な認証ファイルがありません。結果、インスタンスには何も渡らず、IMDSのroleもデフォルトではアタッチされない。SERVICE_ACCOUNT に切り替えると、SkyPilotが自前のIAM role(skypilot-v1)をインスタンスにアタッチしてくれて解決しました。
その設定を入れた .sky.yaml がこれです。プロジェクト(test/)に置くと、そのディレクトリで sky を叩いたときに自動で読まれます(グローバルの ~/.sky/config.yaml より優先)。「何をやったか」がリポジトリだけで完結するのが利点です。なお、プロジェクトの .sky.yaml を自動で読むかはSkyPilotのバージョンに依存します。反映されない場合は同じ内容を ~/.sky/config.yaml に置くか、SKYPILOT_CONFIG で明示してください。
# .sky.yaml
aws:
remote_identity: SERVICE_ACCOUNT
(3) s3fsは「読み込み」は通っても「書き込み」でregionが要る
(3)が一番てこずりました。boto3のsts確認は通る。s3fsの ls(バケット一覧)も通る。なのにs3fsの書き込みだけが No AWSAccessKey で落ちる。同じプロセス、同じcredentialなのに、です。
原因はregionでした。ListBuckets はglobalなendpointを使うので通ります。一方 PutObject はバケットのリージョン固有のendpointが要ります。regionを指定しないと、s3fsが違うendpointに書きに行き、署名がずれてあのエラーになっていました。
対処はregionを明示するだけ。ただしスクリプトの複数箇所でS3にアクセスするので、環境変数 AWS_DEFAULT_REGION をYAMLで1回渡しつつ、コードでも region_name を明示する二段構えにしました。
この切り分けのハシゴは他でも使えるので残しておきます。
# 1. Is the credential chain (IMDS/role) working at all?
python -c "import boto3; print(boto3.client('sts').get_caller_identity())"
# 2. Is an empty AWS_* env var breaking the chain?
env | grep -i aws
# 3. s3fs READ auth test
python -c "import s3fs; print(s3fs.S3FileSystem().ls(''))"
# 4. test WRITE separately (read working != write working)
(5) 「ディスクを使わないストリーミング」でもRAMは食う
(5)は思い込みでハマりました。「ローカルディスクに落とさないから軽い」と考えて c7g.large(4GB RAM)で8並列を回したら、Rayがメモリ不足でジョブを殺しました。
ストリーミングでもメモリは要ります。hf_xet(HuggingFaceの転送層)が読み込み側でチャンクをバッファし、s3fsが書き込み側でmultipartのパートをバッファします。実測では1ファイルあたり両端で最大2GB程度を抱えました。8並列だとその数倍になり、4GBのマシンでは足りません。1ファイルだけのテストが通っていたのは、並列度が1で収まっていただけでした。
対処はメモリを増やす(c7g.2xlarge, 16GB)のと並列度を下げる(--workers 4)の両方。4並列 × 約2GB = 約8GBなら16GBに余裕を持って収まります。「streaming, no disk」でもインスタンスは並列度に合わせて選ぶ、という話でした。
結果



最終的な設定(on-demand c7g.2xlarge, 4 workers, 50ファイル)で完走しました。完走まで約40分(HFのCDN速度次第で前後します)。
uploaded fineweb/CC-MAIN-2024-10/000_00049.parquet
Done: 50 files -> s3://<bucket>/fineweb/CC-MAIN-2024-10/
manifested fineweb/CC-MAIN-2024-10/000_00049.parquet (957982 rows)
Wrote manifest: s3://<bucket>/manifests/CC-MAIN-2024-10-50files-v1.jsonl (50 entries)
s3://<bucket>/fineweb/CC-MAIN-2024-10/に50個のparquet(約100GB)s3://<bucket>/manifests/CC-MAIN-2024-10-50files-v1.jsonlにmanifest(50件、各ファイル約95万〜97万行)- ジョブが終わって5分後にインスタンスは自動でterminate
ファイル数は、さきほどの1ファイル約2.0GBから逆算して50にしました。50ファイルでおよそ100GBという規模感です。なお、実際のLLM学習でデータ量を「ちょうど何GB」と決め打ちすることはまずないので、ここでの100GBはデモ用のざっくりした目安くらいに思ってください。
コストの目安はこんな感じです。
| 項目 | コスト |
|---|---|
| EC2 c7g.2xlarge on-demand 約40分 | 約$0.2 |
| HF egress(HuggingFaceからの転送) | $0(HF側が負担) |
| S3 PUTリクエスト | <$0.01 |
| S3ストレージ 100GB/月 | 約$2.30 |
| 初月合計 | 約$2.5 |
HuggingFaceからの転送(egress)はHF側が負担するので0です。ただしEC2とS3ストレージは普通に課金されます。AWSの無料枠に収まる規模ではないので、個人で試すなら、置きっぱなしにするとストレージ代(100GBで月$2.3前後)が継続でかかる点に注意してください。使い終わったら terraform destroy でバケットごと消せます。
読者が同じスライスを検証するときは、manifestと突き合わせるだけです。ハッシュ(ETag)が合わなければ、そのベンチマーク結果は比較可能ではない、というのがこの設計の狙いです。
head = s3.head_object(Bucket=bucket, Key=entry["path"])
assert head["ETag"].strip('"') == entry["etag"]
assert head["ContentLength"] == entry["size_bytes"]
おわりに
やる前は「FineWebをS3に置くだけ」と思っていました。実際、転送のコアは10行ちょっとのPythonで終わっています。
でも本当の作業は、その10行を実在のAWSアカウント上で完走させるところにありました。どれも転送コードそのものではなく、その周辺の認証・インフラ・リソース設計で起きた問題で、snapshot_download で止まっているチュートリアルには出てこない部分です。
同じように「HuggingFaceのデータを自分のAWSに再現可能な形で置きたい」人がいたら、この5つのポイントは先回りして踏んでおいたので、あとは自分の環境に合わせて調整してみてください。




