So erstellen Sie ein Bild-CDN mit AWS S3 und CloudFront
Speichern Sie vorbereitete Bilder in einem privaten S3-Bucket und lassen Sie eine öffentliche CloudFront-Distribution sie über HTTPS ausliefern. Die folgende Node.js-Anleitung erstellt diese Konfiguration, veröffentlicht eine PNG-Datei unter einem Inhalts-Hash, prüft alle heruntergeladenen Bytes und stellt einen separaten Befehl zum Entfernen der Ressourcen bereit.
Warum ein CDN für Bilder verwenden?
CloudFront kann Ihre Bilder an seinen Edge-Standorten zwischenspeichern. S3 speichert die Originale; eine Origin Access Control (OAC) signiert die Anfragen von CloudFront an S3. Die Bucket-Richtlinie erlaubt Lesezugriffe ausschließlich von dieser Distribution, während S3 Block Public Access aktiviert bleibt. AWS dokumentiert diese Kombination für einen regulären S3-Ursprung. Die resultierenden CloudFront-URLs sind öffentlich. OAC schützt den Ursprung und authentifiziert keine Betrachter.
Diese Konfiguration liefert die Bytes aus, die Sie hochladen. Sie ändert weder Bildgrößen noch Formate und wertet keine Transformationsparameter in der URL-Abfrage aus. Bereiten Sie jede Bildvariante vor der Veröffentlichung vor.
Ein eigenständiges Node-Projekt einrichten
Verwenden Sie eine POSIX-Shell mit Node.js 26.8.1 oder einer neueren Version innerhalb von Node.js 26 sowie separat installiertem Corepack. Wählen Sie bei einer Neuinstallation den aktuellen Sicherheitspatch aus der Node.js-Versionsliste. Das Paketmanifest legt für dieses Beispiel Yarn 4.12.0 und AWS-SDK-v3-Clients 3.1073.0 fest. Diese SDK-Versionen sind für die Reproduzierbarkeit festgeschrieben; dies ist keine Aussage darüber, ob sie die neuesten Patches sind.
Sie benötigen außerdem ein AWS-Konto mit Berechtigungen zum Erstellen, Prüfen, Aktualisieren und
Löschen eines S3-Buckets, seiner Objekte und Bucket-Richtlinie, einer CloudFront-Distribution sowie
einer OAC. Die Verifikation benötigt auch Lesezugriff auf die verwaltete Cache-Richtlinie und die
S3-Einstellungen für öffentlichen Zugriff, Eigentumsrechte und Versionierung. Das Skript verwendet
die Standard-AWS-Partition und legt die S3-Region auf us-east-1 fest. Es benötigt AWS_ACCOUNT_ID, AWS_ACCESS_KEY_ID
und AWS_SECRET_ACCESS_KEY in seiner Umgebung; ergänzen Sie bei temporären
Zugangsdaten AWS_SESSION_TOKEN. Stellen Sie diese über Ihre vorhandenen Werkzeuge zur
Verwaltung von Zugangsdaten bereit. Das Skript lädt keine Profile und weicht nicht auf eine andere
Identität aus.
Beginnen Sie in einem Arbeitsverzeichnis außerhalb bestehender JavaScript-Projekte. Die Einrichtung
prüft vor dem Erstellen von image-cdn die übergeordneten Verzeichnisse, damit ein
übergeordneter Workspace oder eine Yarn-Konfiguration die Installation nicht unbemerkt verändern
kann. Sie lehnt ein bereits vorhandenes Zielverzeichnis ab und ändert das Verzeichnis Ihrer Shell
nicht.
(
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
node --input-type=module <<'NODE'
import { existsSync } from 'node:fs'
import { mkdir, realpath, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if (major !== 26 || minor < 8 || (minor === 8 && patch < 1)) {
throw new Error('Use Node.js 26.8.1 or a newer patch/minor in Node.js 26.')
}
for (let directory = await realpath('.'); ; directory = dirname(directory)) {
const projectFiles = ['package.json', '.yarnrc.yml', '.yarnrc', '.pnp.cjs', '.pnp.loader.mjs',
'yarn.lock', 'package-lock.json', 'pnpm-lock.yaml', '.npmrc']
if (projectFiles.some((name) => existsSync(join(directory, name)))) {
throw new Error('Start outside an existing JavaScript project or package-manager configuration.')
}
if (dirname(directory) === directory) break
}
await mkdir('image-cdn')
await writeFile('image-cdn/package.json', `${JSON.stringify({
name: 'image-cdn-example', private: true, type: 'module', packageManager: 'yarn@4.12.0',
dependencies: { '@aws-sdk/client-cloudfront': '3.1073.0', '@aws-sdk/client-s3': '3.1073.0' },
}, null, 2)}\n`, { flag: 'wx' })
await writeFile('image-cdn/.yarnrc.yml', 'nodeLinker: node-modules\n', { flag: 'wx' })
NODE
if [ "$?" -ne 0 ]; then exit 1; fi
cd image-cdn || exit 1
corepack yarn install
)
Falls die Installation fehlschlägt, nachdem die Projektdateien erstellt wurden, behalten Sie dieses Verzeichnis und sein Manifest. Wiederholen Sie die Installation aus demselben Arbeitsverzeichnis:
(cd image-cdn && corepack yarn install)
Speichern Sie eine gültige, vorbereitete PNG-Datei als image-cdn/image.png. Dieses Beispiel
akzeptiert eine nicht leere Datei mit höchstens 5 MiB und prüft ihre PNG-Signatur; es validiert nicht
die decodierten Pixel. Eingabepfade dürfen nur druckbare ASCII-Zeichen enthalten und müssen mit
./ oder / beginnen. Verwenden Sie ein Bild, das
Sie veröffentlichen möchten. Führen Sie die Befehle einzeln nacheinander aus und behalten Sie
cdn-state.json, bis alle Ressourcen entfernt sind.
Das Programm für Bereitstellung und Verifikation speichern
Speichern Sie diese vollständige Datei als image-cdn/cdn.ts. Sie verwendet den regulären
regionalen S3-Endpunkt, eine OAC, die Anfragen immer signiert, und die von AWS verwaltete
Richtlinie CachingOptimizedForUncompressedObjects.
Diese Richtlinie hat eine minimale TTL von 1 Sekunde, einen Standardwert von 1 Tag und einen
Maximalwert von 365 Tagen. Jedes hochgeladene Bild setzt Cache-Control: public, max-age=31536000, immutable.
Jede SDK-Anfrage hat einen Versuch und ein Abbruchsignal nach 6 Sekunden. Jeder Wartevorgang auf die Bereitschaft von CloudFront hat ein eigenes Zeitfenster von 20 Minuten. Dazu gehört ein separates Zeitfenster für das Deaktivieren der Distribution beim Entfernen der Ressourcen. Für jeden anonymen Abruf gilt eine Frist von 30 Sekunden bis zum vollständigen Empfang des Antwortkörpers. Eine langsamere, gültige Übertragung kann daher die Verifikation nicht bestehen. Ein abgelaufenes Bereitstellungszeitfenster bedeutet, dass das Ergebnis unvollständig ist; es bedeutet nicht, dass AWS die Bereitstellung abgebrochen hat.
import assert from 'node:assert/strict'
import { createHash, randomUUID } from 'node:crypto'
import { readFile, rename, stat, writeFile } from 'node:fs/promises'
import { addAbortSignal, Readable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
import { parseArgs } from 'node:util'
import {
CloudFrontClient, CreateDistributionCommand, CreateOriginAccessControlCommand,
DeleteDistributionCommand, DeleteOriginAccessControlCommand, GetCachePolicyCommand,
GetDistributionCommand, GetOriginAccessControlCommand, UpdateDistributionCommand,
} from '@aws-sdk/client-cloudfront'
import {
CreateBucketCommand, DeleteBucketCommand, DeleteObjectCommand, GetBucketOwnershipControlsCommand,
GetBucketPolicyCommand, GetBucketVersioningCommand, GetObjectCommand, GetPublicAccessBlockCommand,
HeadBucketCommand, ListObjectsV2Command, PutBucketPolicyCommand, PutObjectCommand, S3Client,
} from '@aws-sdk/client-s3'
type State = {
owner: string
bucket: string
bucketCreated: boolean
keys: string[]
attemptedKey?: string
pending?: string
oacId?: string
distributionId?: string
distributionArn?: string
domain?: string
removed?: boolean
}
const region = 'us-east-1'
const cachePolicyId = 'b2884449-e4de-46a7-ac36-70bc7f1ddd6d'
const cacheControl = 'public, max-age=31536000, immutable'
const maxBytes = 5 * 1024 * 1024
const statePath = 'cdn-state.json'
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
class RecipeError extends Error {}
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new RecipeError(message)
}
function required(name: string): string {
const value = process.env[name]
check(value, `Set ${name} before running this command.`)
return value
}
function statusOf(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null || !('$metadata' in error)) return
const metadata = error.$metadata
if (typeof metadata !== 'object' || metadata === null || !('httpStatusCode' in metadata)) return
return typeof metadata.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined
}
function validState(value: unknown): value is State {
if (typeof value !== 'object' || value === null) return false
return 'owner' in value && typeof value.owner === 'string' && uuidPattern.test(value.owner)
&& 'bucket' in value && value.bucket === `image-cdn-${value.owner}`
&& 'bucketCreated' in value && typeof value.bucketCreated === 'boolean'
&& 'keys' in value && Array.isArray(value.keys)
&& value.keys.every((key: unknown) => typeof key === 'string' && /^images\/[a-f0-9]{64}\.png$/.test(key))
&& ['attemptedKey', 'pending', 'oacId', 'distributionId', 'distributionArn', 'domain'].every((key) =>
!(key in value) || typeof Reflect.get(value, key) === 'string')
&& (!('removed' in value) || typeof value.removed === 'boolean')
}
async function main(): Promise<void> {
const { positionals, values } = parseArgs({ allowPositionals: true, options: { id: { type: 'string' } } })
const [command, input, ...extra] = positionals
check(extra.length === 0 && ['deploy', 'publish', 'verify', 'destroy'].includes(command ?? ''),
'Usage: node cdn.ts deploy|publish|verify image.png [--id UUID], or node cdn.ts destroy')
check(command === 'deploy' || values.id === undefined, '--id is only accepted for deploy.')
check(command === 'destroy' ? input === undefined : input !== undefined, 'Supply exactly one PNG input, except for destroy.')
let bytes = Buffer.alloc(0)
if (input !== undefined) {
check(/^[\x20-\x7e]+$/.test(input) && (input.startsWith('./') || input.startsWith('/')),
'Use an ASCII input path beginning with ./ or /.')
const info = await stat(input).catch(() => { throw new RecipeError('Cannot read the PNG input.') })
check(info.isFile() && info.size > 0 && info.size <= maxBytes, 'Input must be a PNG file of 1 byte to 5 MiB.')
bytes = await readFile(input)
check(bytes.length <= maxBytes && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')),
'Input must have a PNG signature and be at most 5 MiB. Prepare a valid PNG before upload.')
}
const digest = createHash('sha256').update(bytes).digest('hex')
const key = `images/${digest}.png`
const account = required('AWS_ACCOUNT_ID')
check(/^\d{12}$/.test(account), 'AWS_ACCOUNT_ID must be your 12-digit account ID.')
const credentials = {
accessKeyId: required('AWS_ACCESS_KEY_ID'), secretAccessKey: required('AWS_SECRET_ACCESS_KEY'),
sessionToken: process.env.AWS_SESSION_TOKEN,
}
const s3 = new S3Client({ region, endpoint: `https://s3.${region}.amazonaws.com`, credentials, maxAttempts: 1, followRegionRedirects: false })
const cf = new CloudFrontClient({ region: 'us-east-1', endpoint: 'https://cloudfront.amazonaws.com', credentials, maxAttempts: 1 })
const options = () => ({ abortSignal: AbortSignal.timeout(6_000) })
let state: State
async function save(): Promise<void> {
await writeFile(`${statePath}.tmp`, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
await rename(`${statePath}.tmp`, statePath)
}
async function mutate<T>(phase: string, action: () => Promise<T>, remember: (result: T) => void): Promise<void> {
state.pending = phase
await save()
try {
const result = await action()
remember(result)
delete state.pending
await save()
} catch (error) {
const status = statusOf(error)
// A definitive client-error response refused this operation. A lost response stays uncertain.
if (status !== undefined && status >= 400 && status < 500) {
delete state.pending
await save()
}
throw new RecipeError(`${phase} failed. Keep ${statePath}; inspect it before recovery.`, { cause: error })
}
}
function bucketInput() {
return { Bucket: state.bucket, ExpectedBucketOwner: account }
}
function policy() {
return {
Version: '2012-10-17', Statement: [{ Effect: 'Allow', Principal: { Service: 'cloudfront.amazonaws.com' },
Action: 's3:GetObject', Resource: `arn:aws:s3:::${state.bucket}/images/*`,
Condition: { StringEquals: { 'AWS:SourceArn': state.distributionArn } } }],
}
}
async function unversioned(): Promise<void> {
const result = await s3.send(new GetBucketVersioningCommand(bucketInput()), options())
check(result.Status === undefined, 'Versioning changed. Stop and inventory versions before deleting anything.')
}
async function deployed(enabled: boolean) {
check(state.distributionId, 'No confirmed distribution ID in the state file.')
const deadline = Date.now() + 1_200_000
for (let observation = 0; observation < 21 && Date.now() < deadline; observation += 1) {
const result = await cf.send(new GetDistributionCommand({ Id: state.distributionId }), {
abortSignal: AbortSignal.timeout(Math.min(6_000, Math.max(1, deadline - Date.now()))),
})
const config = result.Distribution?.DistributionConfig
check(config?.CallerReference === state.owner, 'Distribution ownership mismatch.')
check(config.Enabled === enabled, 'Distribution enabled state differs from the requested state.')
if (result.Distribution?.Status === 'Deployed') return result
check(result.Distribution?.Status === 'InProgress', 'Unexpected distribution deployment status.')
await delay(Math.min(60_000, Math.max(1, deadline - Date.now())))
}
throw new RecipeError('Deployment wait expired. Outcome is incomplete; retain the state and run destroy.')
}
async function verify(): Promise<void> {
check(state.domain && state.distributionArn && state.oacId, 'Deployment is incomplete.')
const distribution = await deployed(true)
const config = distribution.Distribution?.DistributionConfig
check(config, 'Distribution configuration is missing.')
const origins = config.Origins
check(origins, 'Distribution origins are missing.')
const origin = origins.Items?.[0]
check(origins.Quantity === 1 && origin?.DomainName === `${state.bucket}.s3.${region}.amazonaws.com`
&& origin.OriginAccessControlId === state.oacId && origin.S3OriginConfig?.OriginAccessIdentity === '',
'Expected a regular S3 origin using this OAC.')
const behavior = config.DefaultCacheBehavior
check(behavior, 'Default cache behavior is missing.')
check(behavior.CachePolicyId === cachePolicyId && behavior.ViewerProtocolPolicy === 'https-only'
&& behavior.TrustedKeyGroups?.Enabled !== true && behavior.TrustedSigners?.Enabled !== true,
'Expected public HTTPS viewers and the selected cache policy.')
const access = await s3.send(new GetPublicAccessBlockCommand(bucketInput()), options())
const block = access.PublicAccessBlockConfiguration
check(block?.BlockPublicAcls && block.IgnorePublicAcls && block.BlockPublicPolicy && block.RestrictPublicBuckets,
'All four S3 Block Public Access settings must stay enabled.')
const ownership = await s3.send(new GetBucketOwnershipControlsCommand(bucketInput()), options())
check(ownership.OwnershipControls?.Rules?.[0]?.ObjectOwnership === 'BucketOwnerEnforced', 'S3 ACLs must stay disabled.')
const storedPolicy = await s3.send(new GetBucketPolicyCommand(bucketInput()), options())
assert.deepStrictEqual(JSON.parse(storedPolicy.Policy ?? 'null'), policy())
const oac = await cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options())
const signing = oac.OriginAccessControl?.OriginAccessControlConfig
check(signing?.Name === state.bucket && signing.SigningBehavior === 'always'
&& signing.SigningProtocol === 'sigv4' && signing.OriginAccessControlOriginType === 's3', 'OAC signing mismatch.')
const cache = await cf.send(new GetCachePolicyCommand({ Id: behavior.CachePolicyId }), options())
const cacheConfig = cache.CachePolicy?.CachePolicyConfig
check(cacheConfig?.MinTTL === 1 && cacheConfig.DefaultTTL === 86400 && cacheConfig.MaxTTL === 31536000,
'CloudFront cache TTLs differ from the declared policy.')
const readSignal = AbortSignal.timeout(6_000)
const stored = await s3.send(new GetObjectCommand({ ...bucketInput(), Key: key }), { abortSignal: readSignal })
check(stored.CacheControl === cacheControl && stored.ContentType === 'image/png' && stored.ContentLength === bytes.length,
'Uploaded image metadata differs from the declared cache policy or input.')
check(stored.Body instanceof Readable, 'Expected the Node.js S3 response stream.')
addAbortSignal(readSignal, stored.Body)
const storedBytes = await stored.Body.transformToByteArray()
check(storedBytes && Buffer.from(storedBytes).equals(bytes), 'Stored S3 bytes differ from the input.')
const url = `https://${state.domain}/${key}`
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30_000) })
check(response.status === 200 && response.headers.get('cache-control') === cacheControl
&& response.headers.get('content-type') === 'image/png', 'CloudFront response status or metadata differs.')
const delivered = Buffer.from(await response.arrayBuffer())
check(delivered.length === bytes.length && delivered.equals(bytes)
&& createHash('sha256').update(delivered).digest('hex') === digest, 'CloudFront image bytes differ from the input.')
const originResponse = await fetch(`https://${state.bucket}.s3.${region}.amazonaws.com/${key}`, {
redirect: 'error', signal: AbortSignal.timeout(30_000),
})
await originResponse.body?.cancel()
check([401, 403, 404].includes(originResponse.status), 'Anonymous S3 origin access was not denied.')
console.log(JSON.stringify({ url, key, bytes: delivered.length, sha256: digest,
cacheControl, directOriginStatus: originResponse.status }))
}
async function publish(): Promise<void> {
await unversioned()
state.attemptedKey = key
await mutate('PutObject', () => s3.send(new PutObjectCommand({ ...bucketInput(), Key: key,
Body: bytes, ContentType: 'image/png', CacheControl: cacheControl, IfNoneMatch: '*',
ServerSideEncryption: 'AES256' }), options()), () => {
if (!state.keys.includes(key)) state.keys.push(key)
delete state.attemptedKey
})
await verify()
}
async function absent(action: () => Promise<unknown>): Promise<void> {
try {
await action()
} catch (error) {
if (statusOf(error) === 404) return
throw error
}
throw new RecipeError('A resource still exists after deletion. Retain the state file.')
}
async function destroy(): Promise<void> {
if (state.bucketCreated) {
await unversioned()
const initial = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!initial.IsTruncated && (initial.Contents ?? []).every((object) => object.Key && state.keys.includes(object.Key)),
'Unexpected objects or pagination. Stop; do not delete unrecorded keys.')
}
if (state.distributionId) {
const current = await cf.send(new GetDistributionCommand({ Id: state.distributionId }), options())
const config = current.Distribution?.DistributionConfig
check(config?.CallerReference === state.owner && current.ETag, 'Distribution ownership or ETag mismatch.')
if (config.Enabled) {
await mutate('DisableDistribution', () => cf.send(new UpdateDistributionCommand({ Id: state.distributionId,
IfMatch: current.ETag, DistributionConfig: { ...config, Enabled: false } }), options()), () => {})
}
const disabled = await deployed(false)
check(disabled.ETag, 'Missing disabled distribution ETag.')
await mutate('DeleteDistribution', () => cf.send(new DeleteDistributionCommand({ Id: state.distributionId,
IfMatch: disabled.ETag }), options()), () => {})
await absent(() => cf.send(new GetDistributionCommand({ Id: state.distributionId }), options()))
delete state.distributionId
await save()
}
if (state.oacId) {
const current = await cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options())
check(current.OriginAccessControl?.OriginAccessControlConfig?.Name === state.bucket && current.ETag,
'OAC ownership or ETag mismatch.')
await mutate('DeleteOriginAccessControl', () => cf.send(new DeleteOriginAccessControlCommand({ Id: state.oacId,
IfMatch: current.ETag }), options()), () => {})
await absent(() => cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options()))
delete state.oacId
await save()
}
if (state.bucketCreated) {
await unversioned()
const listing = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!listing.IsTruncated && (listing.Contents ?? []).every((object) => object.Key && state.keys.includes(object.Key)),
'Unexpected objects or pagination. Stop; do not delete unrecorded keys.')
for (const ownedKey of state.keys) {
await mutate('DeleteObject', () => s3.send(new DeleteObjectCommand({ ...bucketInput(), Key: ownedKey }), options()), () => {})
}
const empty = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!empty.IsTruncated && (empty.Contents ?? []).length === 0, 'Bucket is not confirmed empty.')
await mutate('DeleteBucket', () => s3.send(new DeleteBucketCommand(bucketInput()), options()), () => {})
await absent(() => s3.send(new HeadBucketCommand(bucketInput()), options()))
state.bucketCreated = false
await save()
}
state.removed = true
await save()
console.log(JSON.stringify({ removed: true, bucket: state.bucket }))
}
try {
if (command === 'deploy') {
const owner = values.id ?? randomUUID()
check(uuidPattern.test(owner), '--id must be a lowercase UUID v4.')
state = { owner, bucket: `image-cdn-${owner}`, bucketCreated: false, keys: [] }
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
await mutate('CreateBucket', () => s3.send(new CreateBucketCommand({ Bucket: state.bucket }),
options()), () => { state.bucketCreated = true })
await unversioned()
await mutate('CreateOriginAccessControl', () => cf.send(new CreateOriginAccessControlCommand({
OriginAccessControlConfig: { Name: state.bucket, OriginAccessControlOriginType: 's3', SigningBehavior: 'always', SigningProtocol: 'sigv4' },
}), options()), (result) => {
check(result.OriginAccessControl?.Id, 'Missing OAC ID.')
state.oacId = result.OriginAccessControl.Id
})
await mutate('CreateDistribution', () => cf.send(new CreateDistributionCommand({ DistributionConfig: {
CallerReference: state.owner, Comment: state.bucket, Enabled: true,
Origins: { Quantity: 1, Items: [{ Id: 'images', DomainName: `${state.bucket}.s3.${region}.amazonaws.com`,
OriginAccessControlId: state.oacId, S3OriginConfig: { OriginAccessIdentity: '' } }] },
DefaultCacheBehavior: { TargetOriginId: 'images', ViewerProtocolPolicy: 'https-only', CachePolicyId: cachePolicyId,
Compress: false, AllowedMethods: { Quantity: 2, Items: ['GET', 'HEAD'], CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] } },
TrustedSigners: { Enabled: false, Quantity: 0 }, TrustedKeyGroups: { Enabled: false, Quantity: 0 } },
ViewerCertificate: { CloudFrontDefaultCertificate: true },
} }), options()), (result) => {
const distribution = result.Distribution
check(distribution?.Id && distribution.ARN && distribution.DomainName, 'Missing distribution identifiers.')
state.distributionId = distribution.Id
state.distributionArn = distribution.ARN
state.domain = distribution.DomainName
})
check(state.distributionArn === `arn:aws:cloudfront::${account}:distribution/${state.distributionId}`, 'AWS account mismatch.')
await mutate('PutBucketPolicy', () => s3.send(new PutBucketPolicyCommand({ ...bucketInput(), Policy: JSON.stringify(policy()) }), options()), () => {})
await publish()
} else {
const saved: unknown = JSON.parse(await readFile(statePath, 'utf8'))
check(validState(saved), 'Invalid state file. Do not edit ownership records.')
state = saved
check(!state.distributionId || !state.distributionArn || state.distributionArn === `arn:aws:cloudfront::${account}:distribution/${state.distributionId}`,
'State belongs to another account, or the distribution ID was changed.')
check(!state.pending, `Uncertain operation: ${state.pending}. Inspect AWS before recovery; do not blindly retry.`)
if (command === 'destroy') await destroy()
else {
check(!state.removed, 'Resources were removed. Create a fresh project for another deployment.')
if (command === 'publish') await publish()
else await verify()
}
}
} finally {
s3.destroy()
cf.destroy()
}
}
main().catch((error: unknown) => {
console.error(error instanceof RecipeError ? error.message : 'AWS or local operation failed. Retain cdn-state.json and check access, input, and state.')
process.exitCode = 1
})
Ihr CDN testen
Führen Sie Folgendes aus dem Arbeitsverzeichnis aus, nachdem Sie die erforderlichen Umgebungsvariablen gesetzt haben:
(cd image-cdn && node cdn.ts deploy ./image.png)
Der Befehl erstellt einen Bucket, eine OAC und eine Distribution mit UUID-basierten Namen, weist eine
auf die Distribution beschränkte Bucket-Richtlinie zu und lädt das Bild hoch. Für die Automatisierung
akzeptiert deploy auch --id, gefolgt von einer neuen
UUID v4 in Kleinbuchstaben. Eine vorhandene Datei cdn-state.json wird nicht ersetzt.
Bei Erfolg wird ein einzelner JSON-Datensatz mit url, key, bytes, sha256, cacheControl und
directOriginStatus ausgegeben. Öffnen Sie genau diese url in einem
Browser, um das Bild anzuzeigen. Vor der Ausgabe liest das Programm das gespeicherte Objekt mit
Zugangsdaten, vergleicht die vollständige anonyme CloudFront-Antwort mit Ihrer lokalen Eingabe,
prüft Cache-Metadaten und Konfiguration und bestätigt, dass eine anonyme Anfrage für dasselbe
bekannte S3-Objekt abgewiesen wird. HTTP 200 allein reicht nicht aus, um die Prüfung zu bestehen.
So wiederholen Sie die Verifikation ohne erneuten Upload:
(cd image-cdn && node cdn.ts verify ./image.png)
Die Bildauslieferung optimieren
Der Objektschlüssel enthält den SHA-256-Hash der Eingabe. Andere Bytes erhalten einen anderen
Schlüssel, und IfNoneMatch: '*' verhindert das Ersetzen eines vorhandenen Objekts. Um eine
geänderte PNG-Datei zu veröffentlichen, speichern Sie sie als image-cdn/image-v2.png und führen
Sie Folgendes aus:
(cd image-cdn && node cdn.ts publish ./image-v2.png)
Verwenden Sie die neu ausgegebene URL in Ihrer Anwendung. Werden identische Bytes erneut
veröffentlicht, wird dies als Überschreibversuch abgelehnt; verwenden Sie
verify, wenn Sie nur ein vorhandenes Bild erneut prüfen möchten. Überschreiben
Sie keinen zwischengespeicherten Namen und erwarten Sie nicht, dass eine geänderte URL-Abfrage eine
neue Bildversion erzeugt.
CloudFront kombiniert die Cache-Header des Ursprungs mit den TTL-Grenzen der Cache-Richtlinie. Ein
max-age von einem Jahr erlaubt diese Cache-Lebensdauer; es garantiert nicht,
dass ein Edge-Standort das Objekt ein Jahr lang vorhält. AWS beschreibt das Zusammenspiel in seiner
Anleitung zum Cache-Ablauf.
Das Beispiel verifiziert die Konfiguration und Antwort-Header, ohne einen Cache-Hit-Header
vorauszusetzen oder eine gemessene Verbesserung der Latenz zu behaupten.
Häufige Probleme beheben
Wenn deploy oder publish mit einem Fehler endet, behalten
Sie cdn-state.json. Eine fehlgeschlagene AWS-Änderungsoperation nennt ihre Phase.
Eindeutige Clientfehler-Antworten lassen bestätigte, zuvor erstellte Ressourcen für
destroy verfügbar. Geht eine Antwort verloren, bleibt eine Operation im Zustand
pending, da ihr Ergebnis auf der Gegenseite ungewiss ist; das Programm verweigert weitere
Arbeit. Prüfen Sie genau die genannte Ressource und klären Sie die ungewisse Operation, bevor Sie
eine Wiederherstellung versuchen. Löschen Sie nicht die Zustandsdatei, um die Bereitstellung blind
erneut auszuführen.
Prüfen Sie bei einem gewöhnlichen Verifikationsfehler die Eingabe erneut und führen Sie
verify noch einmal aus. Das Programm kann falsche CloudFront-Bytes, geänderte
Upload-Metadaten, eine unerwartete Cache-Richtlinie und anonymen S3-Zugriff unterscheiden. Eine
Zeitüberschreitung oder ein Zugriffsfehler bleibt eine fehlgeschlagene Prüfung; keines von beiden
belegt, dass der Zugriff auf den privaten Ursprung verweigert wird. Dieses Beispiel installiert
keine CORS-Regeln und unterstützt keine Abrufe durch Browser-JavaScript von einem anderen Ursprung.
Die Ressourcen entfernen
Behalten Sie dieselbe AWS-Umgebung bei und führen Sie Folgendes aus:
(cd image-cdn && node cdn.ts destroy)
CloudFront verlangt, dass eine Distribution vor dem Löschen deaktiviert wird. Das Programm bewahrt
ihre Konfiguration und verwendet die zurückgegebenen ETags für Aktualisierungen und das Löschen
gemäß der DeleteDistribution-API.
Es wartet, bis die deaktivierte Konfiguration bereitgestellt ist, löscht die Distribution und OAC,
entfernt ausschließlich die erfassten Bildschlüssel, prüft, dass der Bucket leer und nicht
versioniert ist, und löscht ihn. Es prüft, dass die Ressourcen nicht mehr vorhanden sind, bevor es
removed: true ausgibt.
Schlägt das Entfernen der Ressourcen fehl, endet das Programm mit einem Fehler und bewahrt den Zustand zur Untersuchung auf. Unerwartete Objekte, Änderungen an der Versionierung und ungewisse Änderungsoperationen erfordern eine Untersuchung statt umfassenderer Löschvorgänge. Das Beenden von Node.js entfernt keine AWS-Ressourcen. Behalten Sie das Projekt und organisieren Sie die Bereinigung, wenn die Übernahme der Änderungen das Wartezeitfenster überschreitet; die Ressourcennutzung läuft weiter, bis Sie die Ressourcen entfernen.
