cloud
Content Delivery Network
Использование CDN для ускорения доставки контента пользователям
•
#cdn
#performance
#caching
#distributed-systems
Content Delivery Network
CDN (Content Delivery Network) — географически распределенная сеть серверов для быстрой доставки контента пользователям.
Как работает CDN
Основные компоненты
interface CDNConfig {
origin: string; // Исходный сервер
edges: EdgeLocation[]; // Edge серверы
cacheRules: CacheRule[]; // Правила кэширования
purgeStrategy: 'instant' | 'lazy';
}
interface EdgeLocation {
id: string;
region: string;
lat: number;
lon: number;
capacity: number;
}
interface CacheRule {
path: string;
ttl: number;
queryStringHandling: 'ignore' | 'include' | 'whitelist';
headers?: string[];
}
class CDNRouter {
constructor(private config: CDNConfig) {}
selectEdge(userIP: string): EdgeLocation {
const userLocation = this.geolocate(userIP);
// Находим ближайший edge сервер
return this.config.edges.reduce((nearest, edge) => {
const distance = this.calculateDistance(
userLocation,
{ lat: edge.lat, lon: edge.lon }
);
const nearestDistance = this.calculateDistance(
userLocation,
{ lat: nearest.lat, lon: nearest.lon }
);
return distance < nearestDistance ? edge : nearest;
});
}
private geolocate(ip: string): { lat: number; lon: number } {
// Геолокация по IP
return { lat: 0, lon: 0 };
}
private calculateDistance(
point1: { lat: number; lon: number },
point2: { lat: number; lon: number }
): number {
// Формула гаверсинуса для расчета расстояния
const R = 6371; // Радиус Земли в км
const dLat = this.toRad(point2.lat - point1.lat);
const dLon = this.toRad(point2.lon - point1.lon);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(point1.lat)) * Math.cos(this.toRad(point2.lat)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRad(degrees: number): number {
return degrees * Math.PI / 180;
}
}
Интеграция с CDN
CloudFlare
class CloudFlareCDN {
private apiKey: string;
private zoneId: string;
constructor(apiKey: string, zoneId: string) {
this.apiKey = apiKey;
this.zoneId = zoneId;
}
async purgeCache(urls: string[]): Promise<void> {
await fetch(`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ files: urls })
});
}
async purgeAll(): Promise<void> {
await fetch(`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ purge_everything: true })
});
}
async getCacheStats(): Promise<CacheStats> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/analytics/dashboard`,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
}
);
return response.json();
}
}
interface CacheStats {
requests: {
all: number;
cached: number;
uncached: number;
};
bandwidth: {
all: number;
cached: number;
uncached: number;
};
}
AWS CloudFront
import { CloudFront } from 'aws-sdk';
class CloudFrontCDN {
private cloudfront: CloudFront;
private distributionId: string;
constructor(distributionId: string) {
this.cloudfront = new CloudFront();
this.distributionId = distributionId;
}
async createInvalidation(paths: string[]): Promise<string> {
const params = {
DistributionId: this.distributionId,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: {
Quantity: paths.length,
Items: paths
}
}
};
const result = await this.cloudfront.createInvalidation(params).promise();
return result.Invalidation!.Id;
}
async getInvalidationStatus(invalidationId: string): Promise<string> {
const params = {
DistributionId: this.distributionId,
Id: invalidationId
};
const result = await this.cloudfront.getInvalidation(params).promise();
return result.Invalidation!.Status;
}
async updateCachePolicy(policyId: string, config: CachePolicyConfig): Promise<void> {
const params = {
Id: policyId,
CachePolicyConfig: {
Name: config.name,
MinTTL: config.minTTL,
MaxTTL: config.maxTTL,
DefaultTTL: config.defaultTTL,
ParametersInCacheKeyAndForwardedToOrigin: {
EnableAcceptEncodingGzip: true,
EnableAcceptEncodingBrotli: true,
QueryStringsConfig: {
QueryStringBehavior: config.queryStringBehavior
},
HeadersConfig: {
HeaderBehavior: config.headerBehavior
},
CookiesConfig: {
CookieBehavior: 'none'
}
}
}
};
await this.cloudfront.updateCachePolicy(params).promise();
}
}
interface CachePolicyConfig {
name: string;
minTTL: number;
maxTTL: number;
defaultTTL: number;
queryStringBehavior: 'none' | 'whitelist' | 'all';
headerBehavior: 'none' | 'whitelist';
}
Стратегии кэширования
Cache-Control Headers
import express from 'express';
const app = express();
// Статические ресурсы - долгое кэширование
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
setHeaders: (res, path) => {
// Добавляем fingerprint в URL для версионирования
if (path.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}));
// HTML - короткое кэширование с revalidation
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate');
res.send(html);
});
// API - без кэширования
app.get('/api/user', (req, res) => {
res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.json(user);
});
// Динамический контент с условным кэшированием
app.get('/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
const etag = generateETag(product);
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=3600');
// Проверяем If-None-Match
if (req.headers['if-none-match'] === etag) {
res.status(304).end();
return;
}
res.json(product);
});
function generateETag(data: any): string {
const hash = require('crypto')
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex');
return `"${hash}"`;
}
Vary Header
app.get('/api/content', (req, res) => {
const acceptLanguage = req.headers['accept-language'];
const acceptEncoding = req.headers['accept-encoding'];
// CDN будет кэшировать разные версии для разных языков и кодировок
res.setHeader('Vary', 'Accept-Language, Accept-Encoding');
res.setHeader('Cache-Control', 'public, max-age=3600');
const content = getLocalizedContent(acceptLanguage);
res.json(content);
});
Surrogate-Control
// Разные TTL для CDN и браузера
app.get('/products', (req, res) => {
// CDN кэширует на 1 час
res.setHeader('Surrogate-Control', 'max-age=3600');
// Браузер кэширует на 5 минут
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(products);
});
Оптимизация изображений
Адаптивные изображения
class ImageOptimizer {
constructor(private cdnUrl: string) {}
getResponsiveImageUrl(
imagePath: string,
options: ImageOptions
): string {
const params = new URLSearchParams();
if (options.width) params.append('w', options.width.toString());
if (options.height) params.append('h', options.height.toString());
if (options.quality) params.append('q', options.quality.toString());
if (options.format) params.append('f', options.format);
if (options.fit) params.append('fit', options.fit);
return `${this.cdnUrl}/${imagePath}?${params.toString()}`;
}
generateSrcSet(imagePath: string, widths: number[]): string {
return widths
.map(width => {
const url = this.getResponsiveImageUrl(imagePath, { width, quality: 80 });
return `${url} ${width}w`;
})
.join(', ');
}
generatePicture(imagePath: string): PictureElement {
return {
sources: [
{
media: '(min-width: 1200px)',
srcset: this.generateSrcSet(imagePath, [1200, 1600, 2000]),
type: 'image/webp'
},
{
media: '(min-width: 768px)',
srcset: this.generateSrcSet(imagePath, [768, 1024, 1200]),
type: 'image/webp'
},
{
media: '(max-width: 767px)',
srcset: this.generateSrcSet(imagePath, [375, 414, 768]),
type: 'image/webp'
}
],
fallback: this.getResponsiveImageUrl(imagePath, { width: 800, quality: 80 })
};
}
}
interface ImageOptions {
width?: number;
height?: number;
quality?: number;
format?: 'webp' | 'jpeg' | 'png' | 'avif';
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
}
interface PictureElement {
sources: Array<{
media: string;
srcset: string;
type: string;
}>;
fallback: string;
}
// Использование в React
function ProductImage({ imagePath }: { imagePath: string }) {
const optimizer = new ImageOptimizer('https://cdn.example.com');
const picture = optimizer.generatePicture(imagePath);
return (
<picture>
{picture.sources.map((source, i) => (
<source
key={i}
media={source.media}
srcSet={source.srcset}
type={source.type}
/>
))}
<img src={picture.fallback} alt="Product" loading="lazy" />
</picture>
);
}
Автоматическая оптимизация
// Next.js Image Component с CDN
import Image from 'next/image';
// next.config.js
module.exports = {
images: {
loader: 'cloudinary',
path: 'https://res.cloudinary.com/demo/image/upload/',
domains: ['cdn.example.com'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
formats: ['image/webp', 'image/avif']
}
};
// Использование
function ProductCard({ product }: { product: Product }) {
return (
<div>
<Image
src={product.imageUrl}
alt={product.name}
width={300}
height={300}
quality={80}
loading="lazy"
placeholder="blur"
/>
</div>
);
}
Edge Computing
Cloudflare Workers
// worker.ts
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
// Обработка на edge
if (url.pathname === '/api/geo') {
return handleGeoRequest(request);
}
// A/B тестирование на edge
if (url.pathname === '/') {
return handleABTest(request);
}
// Проксирование к origin
return fetch(request);
}
async function handleGeoRequest(request: Request): Promise<Response> {
const country = request.headers.get('CF-IPCountry');
const city = request.headers.get('CF-IPCity');
return new Response(JSON.stringify({
country,
city,
timestamp: Date.now()
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600'
}
});
}
async function handleABTest(request: Request): Promise<Response> {
const cookie = request.headers.get('Cookie');
let variant = 'A';
if (cookie?.includes('variant=B')) {
variant = 'B';
} else if (!cookie?.includes('variant=')) {
// Новый пользователь - случайно выбираем вариант
variant = Math.random() < 0.5 ? 'A' : 'B';
}
const response = await fetch(request);
const newResponse = new Response(response.body, response);
// Устанавливаем cookie с вариантом
newResponse.headers.set('Set-Cookie', `variant=${variant}; Max-Age=2592000; Path=/`);
return newResponse;
}
AWS Lambda@Edge
// CloudFront function для редиректов
export const handler = async (event: any) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// Редирект с www на без www
const host = headers.host[0].value;
if (host.startsWith('www.')) {
return {
status: '301',
statusDescription: 'Moved Permanently',
headers: {
location: [{
key: 'Location',
value: `https://${host.substring(4)}${request.uri}`
}]
}
};
}
// Добавляем security headers
const response = event.Records[0].cf.response;
response.headers['strict-transport-security'] = [{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains'
}];
response.headers['x-content-type-options'] = [{
key: 'X-Content-Type-Options',
value: 'nosniff'
}];
response.headers['x-frame-options'] = [{
key: 'X-Frame-Options',
value: 'DENY'
}];
return response;
};
Инвалидация кэша
Стратегия инвалидации
class CDNInvalidationManager {
constructor(
private cloudfront: CloudFrontCDN,
private cloudflare: CloudFlareCDN
) {}
async invalidateProduct(productId: string): Promise<void> {
const paths = [
`/products/${productId}`,
`/api/products/${productId}`,
`/images/products/${productId}/*`
];
await Promise.all([
this.cloudfront.createInvalidation(paths),
this.cloudflare.purgeCache(paths.map(p => `https://example.com${p}`))
]);
}
async invalidateCategory(categoryId: string): Promise<void> {
const paths = [
`/categories/${categoryId}`,
`/api/categories/${categoryId}/products`
];
await Promise.all([
this.cloudfront.createInvalidation(paths),
this.cloudflare.purgeCache(paths.map(p => `https://example.com${p}`))
]);
}
async invalidateAll(): Promise<void> {
await Promise.all([
this.cloudfront.createInvalidation(['/*']),
this.cloudflare.purgeAll()
]);
}
}
// Интеграция с событиями
class ProductService {
constructor(
private invalidationManager: CDNInvalidationManager,
private eventBus: EventEmitter
) {
this.setupInvalidationListeners();
}
private setupInvalidationListeners(): void {
this.eventBus.on('product:updated', async (productId: string) => {
await this.invalidationManager.invalidateProduct(productId);
});
this.eventBus.on('category:updated', async (categoryId: string) => {
await this.invalidationManager.invalidateCategory(categoryId);
});
}
}
Cache Tags для инвалидации
// Используем Surrogate-Key для группировки
app.get('/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
// Устанавливаем теги для инвалидации
res.setHeader('Surrogate-Key', [
`product-${product.id}`,
`category-${product.categoryId}`,
`brand-${product.brandId}`,
'products'
].join(' '));
res.setHeader('Cache-Control', 'public, max-age=3600');
res.json(product);
});
// Инвалидация по тегу
async function invalidateProductsByCategory(categoryId: string): Promise<void> {
await cloudflare.purgeBySurrogateKey(`category-${categoryId}`);
}
Мониторинг CDN
class CDNMonitoring {
constructor(
private cloudfront: CloudFrontCDN,
private cloudflare: CloudFlareCDN
) {}
async collectMetrics(): Promise<CDNMetrics> {
const [cloudfrontStats, cloudflareStats] = await Promise.all([
this.getCloudFrontMetrics(),
this.cloudflare.getCacheStats()
]);
return {
cloudfront: cloudfrontStats,
cloudflare: cloudflareStats,
timestamp: new Date()
};
}
private async getCloudFrontMetrics(): Promise<any> {
// Получаем метрики из CloudWatch
return {
requests: 1000000,
bytesDownloaded: 5000000000,
cacheHitRate: 0.85
};
}
calculateCost(metrics: CDNMetrics): CDNCost {
// CloudFront pricing
const cloudfrontCost =
(metrics.cloudfront.bytesDownloaded / 1e9) * 0.085 + // Data transfer
(metrics.cloudfront.requests / 10000) * 0.0075; // Requests
// CloudFlare pricing (Pro plan)
const cloudflareCost = 20; // Fixed monthly cost
return {
cloudfront: cloudfrontCost,
cloudflare: cloudflareCost,
total: cloudfrontCost + cloudflareCost
};
}
}
interface CDNMetrics {
cloudfront: {
requests: number;
bytesDownloaded: number;
cacheHitRate: number;
};
cloudflare: CacheStats;
timestamp: Date;
}
interface CDNCost {
cloudfront: number;
cloudflare: number;
total: number;
}
Best Practices
1. Версионирование ресурсов
// Добавляем hash в имя файла
function getAssetUrl(filename: string, hash: string): string {
const ext = filename.split('.').pop();
const name = filename.replace(`.${ext}`, '');
return `/static/${name}.${hash}.${ext}`;
}
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js'
}
};
2. Preconnect и DNS Prefetch
<!-- Устанавливаем соединение с CDN заранее -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- Предзагрузка критичных ресурсов -->
<link rel="preload" href="https://cdn.example.com/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="https://cdn.example.com/css/main.css" as="style">
3. Compression
import compression from 'compression';
app.use(compression({
level: 6,
threshold: 1024, // Сжимаем файлы > 1KB
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
}
}));
// Предварительное сжатие статических файлов
import { gzip, brotliCompress } from 'zlib';
import { promisify } from 'util';
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
async function compressAssets(filePath: string): Promise<void> {
const content = await fs.readFile(filePath);
// Gzip
const gzipped = await gzipAsync(content);
await fs.writeFile(`${filePath}.gz`, gzipped);
// Brotli (лучшее сжатие)
const brotlied = await brotliAsync(content);
await fs.writeFile(`${filePath}.br`, brotlied);
}
4. Security Headers
app.use((req, res, next) => {
// Content Security Policy
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' https://cdn.example.com; " +
"style-src 'self' https://cdn.example.com; " +
"img-src 'self' https://cdn.example.com data:; " +
"font-src 'self' https://cdn.example.com;"
);
// HTTPS only
res.setHeader('Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
Заключение
CDN критически важен для:
- Производительности — снижение latency за счет географической близости
- Масштабируемости — разгрузка origin серверов
- Доступности — защита от DDoS и высокая отказоустойчивость
- Оптимизации — сжатие, оптимизация изображений, edge computing
- Безопасности — SSL/TLS, WAF, DDoS protection
Ключевые практики:
- Правильные Cache-Control headers
- Версионирование статических ресурсов
- Эффективная инвалидация кэша
- Мониторинг hit rate и latency
- Edge computing для динамического контента