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 критически важен для:

  1. Производительности — снижение latency за счет географической близости
  2. Масштабируемости — разгрузка origin серверов
  3. Доступности — защита от DDoS и высокая отказоустойчивость
  4. Оптимизации — сжатие, оптимизация изображений, edge computing
  5. Безопасности — SSL/TLS, WAF, DDoS protection

Ключевые практики:

  • Правильные Cache-Control headers
  • Версионирование статических ресурсов
  • Эффективная инвалидация кэша
  • Мониторинг hit rate и latency
  • Edge computing для динамического контента