Comprendre et optimiser la tarification des CDN
Un taux de succès du cache plus élevé peut réduire votre facture d’origine tout en laissant les frais de requêtes et de diffusion du CDN presque inchangés. Pour optimiser les coûts du CDN, distinguez d’abord ces métriques de facturation. Vérifiez ensuite que votre politique de cache et vos URL d’images servent les bons octets avant d’augmenter leur durée de validité.

Qu’est-ce qu’un CDN ?
Un CDN reçoit les requêtes à proximité de vos utilisateurs et sert une réponse en cache ou la récupère auprès de votre origine. Un succès du cache évite une partie du traitement à l’origine, mais le serveur en périphérie traite toujours la requête et transmet des octets à l’utilisateur. Le cache du navigateur peut éviter entièrement cette requête réseau ; le cache en périphérie ne le peut pas.
Présentation des modèles de tarification des CDN
Les modèles de tarification peuvent se combiner : un forfait facturé à l’usage peut facturer à la fois les requêtes et le transfert, avec des frais distincts pour le calcul et la sécurité. Partez de votre charge de travail, puis appliquez les quotas inclus, les paliers, les régions et les engagements minimaux du forfait choisi.
| Modèle | Points à vérifier avant l’estimation |
|---|---|
| Forfait mensuel fixe | Quotas inclus de requêtes et de transfert, trafic admissible et conséquences d’un dépassement |
| Diffusion facturée à l’usage | Octets transmis par région de facturation, nombre de requêtes, quotas gratuits et limites des paliers |
| Contrat avec engagement | Dépense minimale, produits inclus, dépassements et expiration éventuelle de l’engagement inutilisé |
Au 8 octobre 2026, la tarification d’Amazon CloudFront comprend des forfaits à tarif fixe sans frais de dépassement, en parallèle de la tarification à l’usage. Les forfaits à tarif fixe comportent néanmoins des quotas d’utilisation ; lisez les conditions des forfaits avant de les considérer comme une diffusion illimitée. Consultez les forfaits Cloudflare, la tarification de Fastly et la tarification d’Akamai pour obtenir des offres ou des devis. Si le lien vers la tarification d’Akamai est indisponible, sa page Download Delivery propose un contact commercial. Le prix le plus bas mis en avant par un fournisseur ne constitue pas une estimation pour votre charge de travail.
Principaux facteurs influençant les coûts des CDN
Conservez quatre lignes dans votre feuille de calcul, même lorsqu’un forfait regroupe certains postes :
| Poste de coût | Données de la charge de travail | Méthode d’estimation |
|---|---|---|
| Requêtes | Requêtes mensuelles des utilisateurs par catégorie facturable | Divisez les requêtes facturables par l’unité du tarif, puis multipliez par ce tarif |
| Transfert | Octets transmis aux utilisateurs par région de facturation | Convertissez les octets selon l’unité GB du fournisseur, puis appliquez les quotas inclus et les paliers |
| Origine | Récupérations auprès de l’origine, octets transférés et traitement applicatif | Appliquez séparément les tarifs de stockage, de requêtes, de transfert sortant et de calcul de l’origine |
| Calcul et services supplémentaires | Invocations de fonctions, durée ou unités CPU, transformations, journaux et sécurité | Utilisez la métrique de facturation de chaque produit activé ; déduisez uniquement le quota qui lui est applicable |
Bande passante et transfert de données
Un mois hypothétique avec 10 millions de requêtes de 200 KB en moyenne représente 2 000 GB transmis, en utilisant des KB et GB décimaux. Avec des tarifs fictifs de 0,08 $/GB et de 1 $/million de requêtes, cela représente 160 $ pour le transfert et 10 $ pour les requêtes, avant les quotas inclus, les coûts d’origine, le calcul, les taxes ou les engagements. Ce sont des exemples arithmétiques, pas des devis de fournisseurs. Utilisez les octets transférés réellement facturables plutôt que la taille des fichiers non compressés, et vérifiez si le fournisseur définit le GB comme une unité décimale ou binaire.
Si 80 % de ces requêtes sont satisfaites par le cache en périphérie, un modèle simple avec une récupération par absence de résultat en cache laisse 2 millions de récupérations auprès de l’origine. Il ne réduit pas les 10 millions de requêtes des utilisateurs ni les 2 000 GB transmis par le CDN. La revalidation, le regroupement des requêtes, les requêtes de plage et les tailles inégales des objets peuvent modifier l’estimation pour l’origine ; confrontez-la aux journaux de l’origine plutôt que de multiplier tous les octets par un taux de succès du cache calculé sur les requêtes.
Répartition géographique
Regroupez la diffusion selon les régions de facturation du fournisseur, et non selon la localisation de l’origine. La tarification à l’usage de CloudFront et la tarification de Fastly comportent toutes deux des grilles régionales de transfert. Un tarif moyen pondéré par région nécessite de connaître la répartition de votre trafic. Déplacer une origine ne modifie pas à lui seul la métrique de facturation du transfert entre la périphérie et les utilisateurs.
Fonctionnalités et sécurité
Listez les produits activés à côté de l’estimation de diffusion. Les règles WAF, la journalisation, l’optimisation des images et TLS peuvent être inclus dans un forfait, facturés à l’usage ou faire l’objet de contrats distincts. Vérifiez aussi les frais côté origine : CloudFront exonère actuellement de frais le transfert depuis les origines AWS vers CloudFront, mais cela n’exonère pas de tous les frais de stockage ou de requêtes applicatives.
Calcul en périphérie et fonctionnalités modernes
Prégénérer les variantes d’images populaires déplace le travail d’encodage vers la génération de l’application ou une tâche de traitement. Cela ajoute des coûts de stockage et de génération ; mesurez-les donc en parallèle des économies de diffusion. Le calcul en périphérie utilise des métriques de facturation propres à chaque produit : Fastly indique les requêtes et les vCPU-millisecondes, tandis que d’autres produits utilisent les invocations ou la durée, et les forfaits peuvent inclure du calcul. Utilisez la métrique du produit choisi.
L’atelier local suivant vérifie la sélection des URL, les en-têtes de cache de l’origine, la
compression et la surveillance. Il ne déploie pas de CDN et ne mesure pas le taux de succès du
cache d’un fournisseur. Utilisez Bash,
Node.js 24.15.0 ou une version ultérieure de Node 24 encore
maintenue, ou Node 26.5.0 ou une version ultérieure de Node 26, ainsi que
Corepack installé séparément et cURL. L’exemple utilise la
suppression native des annotations de types TypeScript et des fichiers
.mts pour rendre explicite le comportement des modules. Les versions de
Yarn et des dépendances sont fixées ci-dessous ; Node 24.15.0, 26.5.0 et 26.8.1 sont les versions
testées, pas des versions minimales intrinsèquement requises par les API. Sharp génère des images
synthétiques, pas une chaîne de traitement d’images de production.
Collez cette configuration dans un répertoire où cdn-cost-lab n’existe pas.
Le sous-shell préserve le répertoire et les options du shell appelant, et les commandes s’arrêtent
en cas d’échec :
(
for tool in node corepack curl mkdir; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (!(major === 24 && minor >= 15 || major === 26 && minor >= 5)) { console.error("Use Node 24.15+ or 26.5+ within those majors"); process.exit(1) }' &&
mkdir cdn-cost-lab && cd cdn-cost-lab &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"compression":"1.8.2","express":"5.2.1","negotiator":"1.1.0","sharp":"0.35.3"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' > .yarnrc.yml &&
printf '' > yarn.lock &&
corepack yarn install
)
Si l’installation échoue après la création du répertoire, conservez-le et réessayez avec le bloc
ci-dessous. Ne relancez pas la création du projet dans ce répertoire. Enregistrez les fichiers
suivants dans cdn-cost-lab.
(cd cdn-cost-lab && corepack yarn install)
Enregistrez ceci sous prepare-demo.mts. Ce script crée un PNG vert transparent et un
JPEG rouge opaque avec le même nom de base, ainsi que leurs deux largeurs dans chaque format
déclaré. Conserver l’extension source dans le répertoire des variantes évite les collisions entre
ces deux identités. Le script refuse un répertoire dist existant ;
une génération échouée peut laisser des résultats partiels. Utilisez donc un nouvel atelier pour
une autre génération.
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
await mkdir('dist')
await mkdir('dist/assets', { recursive: true })
const script = 'console.log("cdn demo")\n'.repeat(1024)
const hash = createHash('sha256').update(script).digest('hex').slice(0, 8)
await writeFile(`dist/assets/app.${hash}.js`, script)
await writeFile('dist/assets/app.js', script)
for (const [source, formats, background] of [
['logo.png', ['png', 'avif', 'webp'], { r: 0, g: 180, b: 0, alpha: 0.5 }],
['logo.jpg', ['jpeg', 'avif', 'webp'], { r: 200, g: 0, b: 0, alpha: 1 }],
]) {
for (const width of [320, 1024]) {
const directory = `dist/images/w/${width}/${source}`
await mkdir(directory, { recursive: true })
for (const format of formats) {
const extension = format === 'jpeg' ? 'jpg' : format
await sharp({ create: { width, height: width / 2, channels: 4, background } })
.toFormat(format)
.toFile(`${directory}/image.${extension}`)
}
}
}
console.log(`/assets/app.${hash}.js`)
Enregistrez ceci sous variants.mts. Seules les deux sources publiques déclarées
ont des variantes. Un paramètre de requête inconnu renvoie null : il
pourrait sélectionner une autre version, un recadrage ou un objet signé. Seuls
w et le paramètre utm_source, dont l’absence d’effet
est connue dans cet atelier, peuvent être retirés. N’élargissez cette liste qu’après avoir vérifié
le contrat de l’origine. Les largeurs inconnues utilisent 1024 ; les paramètres de largeur en
double sont refusés.
import Negotiator from 'negotiator'
const WIDTHS = new Map([['small', '320'], ['large', '1024']])
const PUBLISHED = new Map([
['/images/logo.png', new Map([['image/png', 'png'], ['image/avif', 'avif'], ['image/webp', 'webp']])],
['/images/logo.jpg', new Map([['image/jpeg', 'jpg'], ['image/avif', 'avif'], ['image/webp', 'webp']])],
])
export function buildVariantUrl(requestUrl, accept) {
const url = new URL(requestUrl)
const formats = PUBLISHED.get(url.pathname)
if (!formats) return null
for (const key of url.searchParams.keys()) {
if (key !== 'w' && key !== 'utm_source') return null
}
if (url.searchParams.getAll('w').length > 1) return null
// The source format wins wildcard ties; q=0 excludes a format.
const accepted = new Negotiator({ headers: { accept: accept ?? '*/*' } })
.mediaType([...formats.keys()])
const format = formats.get(accepted)
if (format === undefined) return null
const width = WIDTHS.get(url.searchParams.get('w')) ?? WIDTHS.get('large')
const name = url.pathname.slice('/images/'.length)
url.pathname = `/images/w/${width}/${name}/image.${format}`
url.search = ''
url.hash = ''
return url.toString()
}
Ce mécanisme sélectionne une URL, pas une opération d’encodage. Votre modèle ou le code appelant
doit demander l’URL renvoyée. Un résultat null signifie que cet utilitaire
n’a aucune variante sûre ; il ne vous autorise pas à envoyer un format rejeté. Un repli sur le PNG
préserve la transparence. En production, ne publiez que des variantes existantes et vérifiées, et
versionnez leurs URL chaque fois que leurs octets changent.
Servir différents formats depuis une même URL nécessite une configuration de cache compatible.
La fonctionnalité Vary for Images de Cloudflare nécessite une
règle de variantes configurée et est disponible avec les forfaits Pro, Business et Enterprise,
pas avec Free. Vary: Accept ne l’active pas à lui seul. Des URL de format
explicites séparent les représentations, mais votre CDN doit tout de même autoriser la mise en
cache de ces réponses.
Mettre en œuvre une mise en cache efficace
Une durée de validité d’un an pour immutable n’est sûre que si l’URL change
chaque fois que les octets changent. Le nom du fichier JavaScript de l’atelier contient une
empreinte du contenu. Les images non versionnées bénéficient d’une heure de fraîcheur et peuvent
être servies périmées pendant jusqu’à un jour durant la revalidation. Il n’est donc pas garanti
qu’un remplacement parvienne aux clients dans l’heure. N’écrasez jamais le contenu d’une URL
portant une empreinte avec des octets différents.
Déterminez si une réponse peut être mise en cache à partir de la route, pas de l’extension.
/account/avatar.jpg et /api/private.css peuvent contenir des données propres
à chaque utilisateur. Enregistrez ceci sous server.mts ; seuls les points de
montage publics remplacent la politique no-store par défaut. Les routes du
compte sont des démonstrations sans authentification, pas une implémentation de l’authentification.
L’atelier écoute uniquement sur l’interface de boucle locale.
import express from 'express'
const FINGERPRINTED = /\.[0-9a-f]{8,}\.(?:css|js|jpe?g|png|gif|webp|avif|woff2?)$/
function cacheControlFor(filePath) {
return FINGERPRINTED.test(filePath)
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600, stale-while-revalidate=86400'
}
const app = express()
app.use((_req, res, next) => {
res.setHeader('Cache-Control', 'no-store')
next()
})
const publicFiles = {
setHeaders: (res, filePath) => res.setHeader('Cache-Control', cacheControlFor(filePath)),
}
app.use('/assets', express.static('dist/assets', publicFiles))
app.use('/images', express.static('dist/images', publicFiles))
app.get('/account/avatar.jpg', (_req, res) => res.type('jpg').send('per-user bytes'))
app.get('/api/private.css', (_req, res) => res.type('css').send(':root{--brand:rebeccapurple}'))
app.get('/api/failure.css', (_req, _res, next) => next(new Error('Demo failure')))
app.use((_req, res) => res.status(404).send('Not found'))
app.use((_error, _req, res, _next) => {
res.setHeader('Cache-Control', 'no-store')
res.status(500).send('Request failed')
})
const port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid PORT')
const server = app.listen(port, '127.0.0.1', (error) => {
if (error) {
console.error(`Listen failed: ${error.code}`)
process.exitCode = 1
return
}
console.log(`http://127.0.0.1:${server.address().port}`)
})
process.once('SIGINT', () => server.close())
process.once('SIGTERM', () => server.close())
Depuis cdn-cost-lab, générez les données de test et démarrez l’origine.
PORT=0 choisit un port inutilisé ; copiez l’URL d’origine affichée pour le
terminal suivant. Arrêtez le serveur au premier plan avec Ctrl+C.
node prepare-demo.mts && PORT=0 node server.mts
Dans un second terminal, depuis le répertoire de l’atelier, affectez cette URL affichée à
DEMO_ORIGIN. Exécutez ce complément pour sélectionner et récupérer une petite
variante WebP ; la source PNG et la largeur restent dans son chemin :
DEMO_ORIGIN='http://127.0.0.1:REPLACE_WITH_PRINTED_PORT'
node --input-type=module - "$DEMO_ORIGIN" <<'NODE'
import { buildVariantUrl } from './variants.mts'
const url = buildVariantUrl(`${process.argv[2]}/images/logo.png?w=small`, 'image/webp')
if (url === null) throw new Error('No acceptable variant')
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const bytes = new Uint8Array(await response.arrayBuffer())
console.log(new URL(url).pathname, response.headers.get('content-type'), bytes.length)
NODE
Vous devriez obtenir /images/w/320/logo.png/image.webp, image/webp et un nombre
d’octets positif. Cela prouve que l’objet sélectionné est accessible ; inspectez ses pixels lorsque
vous remplacez cette démonstration par votre propre génération. Demandez le chemin de la ressource
avec empreinte affiché lors de la génération pour inspecter immutable.
Ces commandes montrent les politiques des objets non versionnés, privés et absents ; utilisez la
même valeur de DEMO_ORIGIN :
curl -fsSI "$DEMO_ORIGIN/assets/app.js" &&
curl -fsSI "$DEMO_ORIGIN/account/avatar.jpg" &&
curl -sSI "$DEMO_ORIGIN/assets/absent.js"
La première réponse contient max-age=3600, stale-while-revalidate=86400 ; la deuxième et la réponse 404
contiennent no-store. Ces en-têtes d’origine ne prouvent pas qu’un CDN déployé
les respecte. Vérifiez ses règles de cache, ses cookies, son traitement de l’autorisation et ses
journaux d’état du cache avant d’activer la mise en cache partagée.
Surveillance et optimisation des performances
Une sonde utile doit rejeter les réponses hors 2xx, lire le corps entier et limiter la durée de
la requête complète. Enregistrez ceci sous edge-monitor.mts. Elle attend cinq minutes
entre les mesures successives et annule une récupération en cours ou la temporisation d’attente
sur Ctrl+C. Son délai maximal de cinq secondes inclut le transfert du corps ; une ressource
volumineuse sur une connexion lente peut donc échouer malgré un transfert qui progresse.
import { setTimeout as sleep } from 'node:timers/promises'
async function probeEdge(url, timeoutMs = 5000, signal) {
const start = performance.now()
const deadline = AbortSignal.timeout(timeoutMs)
const response = await fetch(url, {
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
})
try {
if (!response.ok) return { url, ok: false, status: response.status }
let bytes = 0
if (response.body) {
for await (const chunk of response.body) bytes += chunk.byteLength
}
return {
url, ok: true, status: response.status, bytes,
duration: performance.now() - start,
cache: response.headers.get('cf-cache-status') ?? 'unknown',
}
} finally {
if (!response.bodyUsed) await response.body?.cancel()
}
}
function formatProbe(result) {
const { origin, pathname } = new URL(result.url)
if (!result.ok) return `${origin}${pathname} FAIL ${result.error ?? `HTTP ${result.status}`}`
return `${origin}${pathname} ${result.status} ${result.duration.toFixed(0)}ms ${result.bytes}B cache:${result.cache}`
}
async function monitorEdge(url, signal, intervalMs = 300_000) {
while (!signal.aborted) {
const result = await probeEdge(url, 5000, signal)
.catch((error) => ({ url, ok: false, error: error.name }))
if (signal.aborted) break
console.log(formatProbe(result))
await sleep(intervalMs, undefined, { signal }).catch((error) => {
if (error.name !== 'AbortError') throw error
})
}
}
const url = process.argv[2]
if (!url) throw new Error('Usage: node edge-monitor.mts URL')
const controller = new AbortController()
process.once('SIGINT', () => controller.abort())
process.once('SIGTERM', () => controller.abort())
await monitorEdge(url, controller.signal)
Exécutez node edge-monitor.mts "$DEMO_ORIGIN/assets/app.js". Une ligne indiquant un succès comprend HTTP 200,
le temps écoulé en millisecondes, les octets et cache:unknown pour cette origine
locale. Les paramètres de requête sont omis des journaux, car les URL peuvent contenir des jetons
signés. La sonde mesure le trajet d’un seul processus, pas la latence des utilisateurs ni un taux
global de succès du cache. La fonction fetch de Node décode les corps compressés ; ce nombre
d’octets ne correspond donc pas aux octets compressés transmis sur le réseau ni à une métrique
de facturation. L’en-tête cf-cache-status est propre à Cloudflare ; adaptez-le à
votre fournisseur. Chaque mesure ajoute du trafic qui peut être comptabilisé dans les requêtes et
le transfert.
Stratégies pour optimiser les coûts des CDN
Utilisez la feuille de calcul pour choisir la modification : des représentations plus petites réduisent les octets transmis ; la mise en cache en périphérie réduit le traitement à l’origine ; le cache du navigateur réduit les requêtes réseau répétées. Vérifiez une modification selon la métrique de facturation correspondante plutôt que de supposer que les quatre postes de coût baissent ensemble.
Réduire les transferts de données inutiles
Pour tester la compression à l’origine, ajoutez ceci à server.mts immédiatement
après const app = express(), puis redémarrez le serveur avec
PORT=0 node server.mts. L’import reste au niveau du module. C’est une solution de repli
lorsque la compression n’est pas déjà assurée ailleurs sur votre trajet de diffusion.
import compression from 'compression'
app.use(compression({ level: 6, threshold: 0 }))
Demandez /assets/app.js avec Accept-Encoding: gzip, vérifiez
Content-Encoding et le nombre d’octets du corps compressé, puis comparez les octets
décodés à l’original. Enregistrez ceci sous check-compression.mts et exécutez
node check-compression.mts "$DEMO_ORIGIN" après avoir affecté à DEMO_ORIGIN l’URL affichée
par le serveur redémarré. Le script lit les octets HTTP bruts, car fetch les décoderait pour vous :
import { strict as assert } from 'node:assert'
import { readFile } from 'node:fs/promises'
import { get } from 'node:http'
import { gunzipSync } from 'node:zlib'
const url = new URL('/assets/app.js', process.argv[2])
const response = await new Promise((resolve, reject) => {
get(url, { headers: { 'Accept-Encoding': 'gzip' }, signal: AbortSignal.timeout(5000) }, resolve)
.on('error', reject)
})
const chunks = []
for await (const chunk of response) chunks.push(chunk)
assert.equal(response.statusCode, 200)
assert.equal(response.headers['content-encoding'], 'gzip')
const encoded = Buffer.concat(chunks)
const original = await readFile('dist/assets/app.js')
assert.equal(Buffer.compare(gunzipSync(encoded), original), 0, 'Decoded bytes differ from the source')
console.log(`${encoded.length}B gzip, ${original.length}B decoded`)
Ne recompressez pas les JPEG, WebP ou AVIF avec gzip simplement parce qu’ils sont volumineux.
Le middleware de compression filtre selon le type de contenu et
respecte no-transform ; la configuration de compression de votre CDN peut
différer. Cet atelier ne démontre pas les économies de compression pour vos ressources de
production.
Exploiter efficacement la mise en cache
Ne normalisez que les paramètres de requête dont l’absence d’effet sur l’objet est démontrée.
Ici, w=small devient un segment de chemin et
utm_source peut être supprimé ; version,
crop et les jetons signés ne peuvent pas l’être. L’utilitaire refuse ces
entrées inconnues plutôt que de les ramener à un autre objet. Suivez séparément le taux de succès
du cache en nombre de requêtes et en volume d’octets, et examinez quelles variantes ou valeurs de
paramètres de requête provoquent des absences de résultat en cache avant de modifier la clé de
cache.
Conclusion
Appliquez la grille tarifaire choisie à un mois de données sur les requêtes, les régions, le transfert et l’origine, puis comparez avec une nouvelle estimation après la modification mesurée. Si votre application nécessite une étape de traitement des fichiers lors de sa génération, Transloadit peut assurer cette partie du processus ; la politique de cache et la facturation du fournisseur nécessitent toujours leurs propres vérifications.
