qruiq-cos-storage
Mount a Tencent Cloud COS bucket into a TKE pod as a ReadWriteMany PVC via the cosfs CSI driver, so the app reads/writes user uploads (or any persisted blobs) through a normal filesystem path. Covers
Files1
View on GitHubSKILL.mdskills/qruiq-cos-storage/SKILL.md
GitHubqruiq-cos-storage
Mount a Tencent COS bucket as a ReadWriteMany PVC in a TKE pod via the cosfs CSI driver. The app then reads and writes through a regular filesystem path (e.g. /app/public/uploads), and the bytes land in COS.
This is the right answer when:
- pods are ephemeral and you can't keep user uploads on local disk;
- multiple pod replicas need to see the same files (CBS doesn't do RWX);
- the data is blobs written once and read many — images, exports, generated PDFs, snapshots.
This is the wrong answer when:
- the app needs POSIX guarantees (database files, lock files, append-only logs, build caches) — use CBS
cloud-premium(RWO) instead; - the cluster is outside Tencent Cloud — use S3 / R2 / MinIO.
Prerequisites
- TKE cluster with the COS CSI add-on installed (see Step 1).
- A COS bucket in the same region as the cluster (cross-region traffic is billed and slow).
tccliconfigured (used for inspection only; the bucket itself is usually created via console).- Tencent AppId (10-digit number, same across all your buckets — find in console → 账号信息).
Bucket naming — the -AppId suffix is mandatory
Tencent COS bucket names are <name>-<AppId>, e.g. x-invoory-1252401919. The "name" you pick in the console is what you choose; Tencent appends -<AppId> automatically. Use the full name everywhere — bucket: in the PV, IAM ARNs, anywhere.
Step 1 — Confirm the CSI driver is installed
cosfs is not built into TKE — it's an add-on. Check:
kubectl get pods -n kube-system -l 'app in (csi-cosplugin,csi-coslauncher)'
Expect csi-coslauncher-* and csi-cosplugin-* running on every node (DaemonSets). If missing: TKE console → 集群 → 组件管理 → 安装 cos CSI 组件. Without this, your PVC will hang at Pending forever with no useful error.
Step 2 — Create cos-secret in the workload's namespace
The CSI driver authenticates to COS using a Secret referenced by the PV. The Secret must live in the same namespace as the pod that mounts the PVC — namespace cross-references aren't supported.
Mint a dedicated CAM sub-user with bucket-scoped policy (see Step 6). Don't use the master key. Then:
kubectl -n <ns> create secret generic cos-secret \
--from-literal=SecretId="$COS_SECRET_ID" \
--from-literal=SecretKey="$COS_SECRET_KEY"
Reuse one Secret per (namespace, bucket) pair.
Step 3 — PV + PVC manifest
Drop into k8s/cosfs-storage.yaml:
apiVersion: v1
kind: PersistentVolume
metadata:
name: <project>-cosfs-pv
spec:
capacity:
storage: 10Gi # cosfs ignores this; k8s requires a value
accessModes:
- ReadWriteMany # the whole point of using cosfs
persistentVolumeReclaimPolicy: Retain
storageClassName: cosfs
volumeMode: Filesystem
mountOptions:
- allow_other
csi:
driver: com.tencent.cloud.csi.cosfs
volumeHandle: <project>-cosfs-pv # MUST be unique cluster-wide
nodePublishSecretRef:
name: cos-secret
namespace: <ns>
volumeAttributes:
bucket: x-invoory-1252401919 # full name with -AppId
path: /uploads # subdirectory inside the bucket
url: http://cos-internal.ap-singapore.tencentcos.cn # see Step 4
additional_args: -oallow_other
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: <project>-cosfs-pvc
namespace: <ns>
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
storageClassName: cosfs
volumeName: <project>-cosfs-pv # bind directly to the PV above
Critical fields:
volumeHandlemust be unique cluster-wide. Reusing the same handle across two PVs makes one of them silently unmountable. Prefix with the project name.path:lets multiple workloads share one bucket via different prefixes. Always set it — don't mount/, you'll see every other project's files.volumeNameon the PVC pins the binding. Without it the PVC may bind to some other RWX PV that happens to match.storage: 10Giis a lie cosfs has to tell to satisfy the k8s API — COS is unlimited, this number doesn't gate anything.
Step 4 — Internal vs public endpoint (billing trap)
| URL | When | Cost |
|---|---|---|
http://cos.<region>.myqcloud.com |
Pod is outside the bucket's Tencent region (cross-region cluster, local dev) | Public egress + request fees |
http://cos-internal.<region>.tencentcos.cn |
Pod is inside same Tencent region as the bucket | No traffic charge, only request fees |
For a TKE cluster in ap-singapore reading a bucket in ap-singapore, always use the internal endpoint. The public endpoint works but you pay outbound traffic on every read — easy to miss and expensive at scale.
If you find an existing manifest with cos.<region>.myqcloud.com while the cluster is in the same region, it's a misconfiguration — flag it.
Step 5 — Mount it in the Deployment
spec:
template:
spec:
containers:
- name: web
volumeMounts:
- name: uploads
mountPath: /app/public/uploads # whatever path the app expects
volumes:
- name: uploads
persistentVolumeClaim:
claimName: <project>-cosfs-pvc
Apply: kubectl apply -f k8s/cosfs-storage.yaml -f k8s/deployment.yaml. Watch: kubectl -n <ns> get pvc -w until Bound, then kubectl -n <ns> get pods -w.
If pods stay ContainerCreating with events like MountVolume.SetUp failed for volume "..." : rpc error:
cos-secretexists in the right namespace? (kubectl -n <ns> get secret cos-secret)- bucket name has the
-<AppId>suffix? - region in
url:matches the bucket's region? (mismatch produces auth-looking errors) - CSI plugin pods running on the scheduled node?
Step 6 — IAM: bucket-scoped sub-user
The CAM key in cos-secret should be a sub-user with bucket-scoped policy, not the master key. Minimum policy for an app that reads & writes /uploads/*:
{
"version": "2.0",
"statement": [{
"effect": "allow",
"action": ["cos:GetObject","cos:PutObject","cos:DeleteObject","cos:HeadObject","cos:GetBucket"],
"resource": ["qcs::cos:ap-singapore:uid/1252401919:x-invoory-1252401919/uploads/*",
"qcs::cos:ap-singapore:uid/1252401919:x-invoory-1252401919"]
}]
}
uid/<AppId> and the full bucket name (with -<AppId> suffix) are both required in the ARN. cos:GetBucket on the bare bucket is needed for ListObjects, which cosfs uses for readdir.
cosfs is not POSIX — know the lies
cosfs presents COS as a filesystem, but COS is object storage. The mismatch leaks through. Don't promise the developer these will work like a real disk:
- No atomic rename across "directories". A
mvis copy + delete; readers can see neither, both, or partial. Don't rely on rename for atomic publish. - No append. Apps that open files
O_APPEND(some loggers, SQLite WAL) will misbehave or rewrite the whole object. Don't put a database here. fsyncdoesn't durably flush the way you think. cosfs buffers and uploads in chunks.- Listing a "directory" is
ListObjects. Slow once you have ≥10k objects in one prefix. Don'tls /mnt/cosfs/uploads/on a million-file tree — it pages forever. - No hard links, no symlinks, weak permission semantics. Anything depending on chmod / chown / inode numbers will break or no-op.
- Concurrent writers to the same object will overwrite each other — last writer wins, no locking. cosfs is RWX in the k8s sense (multi-pod read-write) but the consistency contract is per-object eventual.
- Don't use cosfs for a database, a queue, or a build cache. Use it for blobs that are written once and read many: user uploads, exports, image renders, snapshots.
If the app needs POSIX guarantees, use CBS (cloud-premium) and accept RWO instead.
Public vs private bucket access
- Private bucket + signed URL for user-uploaded files (default; safer). The app generates a presigned URL via
cos-nodejs-sdk-v5for client downloads. - Public-read bucket only for static assets you'd put on a CDN anyway. Don't make a user-upload bucket public — privacy incident waiting to happen.
- Public-read is set in console: bucket → 权限管理 → 访问权限 → 公有读私有写. Public endpoint is required for browser access, internal endpoint is private-network-only.
Cleanup / dev hygiene
- When dropping a workload, don't also delete the PV (it has
persistentVolumeReclaimPolicy: Retain). Leaving the PV without the bucket is fine; deleting the bucket while a PV references it makes future mounts fail until the PV is deleted. - Full teardown: delete PVC → delete PV → optionally clear bucket contents.
Gotchas (quick reference)
- Bucket name without
-<AppId>→ mount errors that look like auth failures. - Wrong region in
url:→ same auth-looking error. cos.<region>.myqcloud.cominstead ofcos-internal.<region>.tencentcos.cnin same-region cluster → quietly bills public traffic on every read.- CSI plugin not installed → PVC stuck
Pendingwith no obvious error in pod events. cos-secretin wrong namespace → mount fails. Each namespace mounting needs its own copy.volumeHandlecollision across two PVs → silent breakage.- Treating cosfs as POSIX → see "cosfs is not POSIX". No SQLite / BoltDB / lock files / mmap.
- Master CAM key in
cos-secret→ over-privileged; use a bucket-scoped sub-user. - Public endpoint is correct for static-public-read assets — internal endpoint isn't reachable from browsers. Internal-vs-public is per-traffic-direction, not per-bucket.
What NOT to do
- Don't put a database (SQLite, LMDB, BoltDB) on a cosfs mount.
- Don't enable public-read on a user-upload bucket.
- Don't reuse the master CAM key for runtime workloads.
- Don't omit
path:— never mount the bucket root if other workloads share it. - Don't delete the PV before the PVC; it'll hang in
Terminating. - Don't paste
SecretId/SecretKeyinto chat.kubectl create secretreads from env or files directly. - Don't pick a bucket region different from the cluster region "to save on storage" — you'll lose the savings on traffic many times over.
Appendix — coscli for one-off uploads
For batch uploads from a dev box or CI (publishing static assets, one-shot migrations, backups), use coscli instead of writing to the mounted PVC. Both can target the same bucket — runtime app writes via cosfs, dev box pushes via coscli.
brew install coscli # mac, or grab a release from github.com/tencentyun/coscli
coscli config add \
--bucket x-invoory-1252401919 \
--region ap-singapore \
--alias invoory \
--secret-id "$COS_SECRET_ID" \
--secret-key "$COS_SECRET_KEY"
coscli ls cos://invoory/
coscli cp public/uploads/ cos://invoory/uploads/ -r
coscli sync ./dist/ cos://invoory/static/ -r
Notes:
- The
cosclialias (invoory) is just a local nickname — it has no relationship to the actual bucket name. Don't confuse the two when reading scripts. coscliwrites./coscli.logand./coscli_output/<timestamp>/process.log. Useful for CI artifacts; gitignore them in repos.- For CI, use a bucket-scoped sub-user key in repo secrets — same IAM principle as Step 6.
- Same
-<AppId>suffix rule applies in--bucket.