콘텐츠로 이동

로컬 폴백을 지원하는 Cloudflare 엣지 렌더링

Cloudflare 브리지는 HTML을 Cloudflare Worker 렌더 엔드포인트로 전송하고 PDF를 반환합니다. 렌더링은 엣지에서 실행되므로 따로 운영해야 할 장기 실행 브라우저 프로세스가 없습니다. HTTPS 전용 구성을 만들고, PSR-18 클라이언트와 PSR-17 팩토리를 연결하고, render()를 호출하며, 선택적으로 Worker에 도달할 수 없을 때 브리지가 사용할 로컬 렌더러를 연결합니다. 이 가이드는 렌더 호출, 폴백 결정, 그리고 요청이 프로세스를 벗어나기 전에 브리지가 적용하는 SSRF, DNS 리바인딩, TLS 공개 키 피닝 제어를 다룹니다.

사전 요구 사항을 먼저 정리하면 다음과 같습니다.

  • NextPDF core와 nextpdf/cloudflare가 설치되어 있습니다.
  • Worker 엔드포인트는 HTTPS로 렌더 계약을 제공하고 베어러 토큰을 수락합니다. 브리지는 무언가를 전송하기 전에 HTTPS가 아닌 Worker URL을 거부합니다.
  • PSR-18 클라이언트(예: Guzzle 7)와 PSR-17 요청 및 스트림 팩토리를 사용할 수 있어야 합니다. 피닝된 cURL 전송을 사용하려면 PSR-17 응답 팩토리와 ext-curl도 제공해야 합니다.
  • 로컬 폴백에는 nextpdf/artisan 또는 다른 로컬 렌더러를 사용할 수 있습니다.

이 문서는 how-to 가이드입니다. 첫 번째 실행 가능한 렌더를 보려면 Cloudflare 빠른 시작을 참고하세요.

브리지, PSR-18 클라이언트, PSR-17 팩토리를 설치합니다.

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

로컬 폴백을 위해 브리지가 위임할 수 있는 로컬 렌더러를 설치합니다.

Terminal window
composer require nextpdf/artisan

Worker 베어러 토큰과 R2 자격 증명은 환경 변수나 시크릿 관리자에서 읽어 옵니다. 절대로 커밋하지 마세요.

CloudflareHtmlRenderer::render()는 HTML과 대상을 검증하고, 인증된 POST를 Worker로 전송하며, 응답을 파싱합니다. Worker는 원시 PDF 바이트(Content-Type: application/pdf) 또는 base64 pdf 필드가 있는 JSON 본문 중 하나를 반환합니다. 렌더러는 결과를 final readonly CloudflareRenderResult로 매핑하며, 여기에는 바이트, 요청한 너비, 높이, 렌더 위치(CF-Ray 헤더에서 파생됨), 렌더 시간이 담깁니다.

브리지는 두 가지 실패 유형을 의도적으로 구분합니다.

  • CloudflareRenderException — Worker가 응답했지만 렌더가 실패했습니다(HTTP 오류 또는 %PDF로 시작하지 않는 본문). 이는 렌더 실패이며 절대 폴백으로 다시 시도되지 않습니다.
  • CloudflareNotAvailableException — 엣지에 도달할 수 없었고 사용 가능한 폴백도 없을 때 발생합니다.

로컬 폴백은 두 번째 상황을 보완합니다. Worker에 도달할 수 없고 fallbackToLocaltrue이면, 브리지는 사용자가 제공한 LocalRendererFactoryInterface를 지연 방식으로 호출합니다. 즉, 팩토리의 create()는 폴백 경로에서만 실행됩니다. 폴백 렌더링에서는 결과의 renderLocation이 리터럴 문자열 local입니다.

브리지는 요청이 PHP를 벗어나기 전에 네트워크 경계를 방어합니다. HTTPS가 아닌 Worker URL을 거부합니다. 첫 번째 레코드만 보지 않고 모든 A 및 AAAA 레코드를 검사하여, 사설 또는 예약된 주소 공간으로 해석되는 Worker 호스트를 거부합니다. 그리고 연결 직전에 호스트를 다시 해석하여, DNS 리바인딩의 time-of-check/time-of-use 윈도를 닫습니다. PSR-17 응답 팩토리와 함께 해석된 IP 집합 또는 SPKI 핀 중 하나를 제공하면, 브리지는 피닝된 cURL 전송을 사용합니다. 이 전송은 검증된 IP에 연결을 바인딩하고(CURLOPT_RESOLVE), TLS 공개 키 피닝을 적용하며(CURLOPT_PINNEDPUBLICKEY), 피어와 호스트를 검증하고, 리디렉션을 따르지 않습니다.

// Configuration (final readonly):
new CloudflareRendererConfig(
string $workerUrl, // required, must be HTTPS
string $apiToken, // required, #[SensitiveParameter]
int $renderTimeout = 30,
string $defaultCss = '',
int $maxHtmlSize = 5_000_000,
?string $r2FontBucket = null,
bool $fallbackToLocal = true,
list<string> $pinnedPublicKeys = [], // sha256/<base64>
list<string> $backupPublicKeys = [],
)
CloudflareRendererConfig::fromArray(array $config): self
// The renderer:
new CloudflareHtmlRenderer(
CloudflareRendererConfig $config,
ClientInterface $httpClient, // PSR-18
RequestFactoryInterface $requestFactory, // PSR-17
StreamFactoryInterface $streamFactory, // PSR-17
?LoggerInterface $logger = null, // PSR-3
?LocalRendererFactoryInterface $localRendererFactory = null,
?HtmlSecurityPolicyInterface $htmlSecurityPolicy = null,
?ResponseFactoryInterface $responseFactory = null, // enables pinned transport
)
CloudflareHtmlRenderer::render(string $html, float $widthPt = 595.28, float $heightPt = 0.0, list<string> $fontFiles = []): CloudflareRenderResult
CloudflareHtmlRenderer::isAvailable(): bool

render()는 기본적으로 A4 너비(595.28 포인트)와 자동 감지 높이(heightPt: 0)를 사용합니다. 전체 필드 레퍼런스와 fromArray() 키 맵은 함께 보기에서 연결된 Cloudflare 구성 페이지를 참고하세요.

구성을 만들고, 렌더러를 빌드하고, 렌더링한 다음, 바이트를 기록합니다.

edge-quickstart.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
use NextPDF\Cloudflare\CloudflareRendererConfig;
use NextPDF\Cloudflare\Exception\CloudflareNotAvailableException;
use NextPDF\Cloudflare\Exception\CloudflareRenderException;
$config = new CloudflareRendererConfig(
workerUrl: 'https://pdf-renderer.example.workers.dev/render',
apiToken: getenv('CF_PDF_TOKEN') ?: throw new RuntimeException('CF_PDF_TOKEN not set'),
);
$httpFactory = new HttpFactory();
$renderer = new CloudflareHtmlRenderer(
config: $config,
httpClient: new Client(),
requestFactory: $httpFactory,
streamFactory: $httpFactory,
responseFactory: $httpFactory, // enables the pinned cURL transport
);
try {
$result = $renderer->render('<h1>Hello from the edge</h1>');
if (!$result->isValid()) {
throw new RuntimeException('Worker did not return a valid PDF');
}
file_put_contents('output.pdf', $result->pdfData);
} catch (CloudflareRenderException $exception) {
// Worker answered but the render failed. Not retried with fallback.
fwrite(STDERR, 'Render failed: ' . $exception->getMessage() . PHP_EOL);
exit(1);
} catch (CloudflareNotAvailableException $exception) {
// Edge unreachable and no usable fallback.
fwrite(STDERR, 'Edge unavailable: ' . $exception->getMessage() . PHP_EOL);
exit(2);
}

토큰은 환경에서 읽어 오며, 절대로 하드코딩하지 않습니다. workerUrl은 HTTPS여야 하며, 브리지는 요청을 전송하기 전에 http:// URL을 거부합니다.

프로덕션에서는 Worker에 도달할 수 없을 때 요청이 실패하지 않고 폴백하도록 로컬 렌더러 팩토리를 연결하고, 백업 핀과 함께 TLS 핀을 구성하세요. 팩토리의 create()는 폴백 경로에서만 실행됩니다.

ArtisanLocalRendererFactory.php
<?php
declare(strict_types=1);
use NextPDF\Artisan\ChromeHtmlRenderer;
use NextPDF\Cloudflare\Contract\LocalRendererFactoryInterface;
use NextPDF\Cloudflare\Contract\LocalRendererInterface;
final readonly class ArtisanLocalRendererFactory implements LocalRendererFactoryInterface
{
public function __construct(private ChromeHtmlRenderer $chrome) {}
public function create(): LocalRendererInterface
{
return new readonly class($this->chrome) implements LocalRendererInterface {
public function __construct(private ChromeHtmlRenderer $chrome) {}
/** @param array<string, mixed> $options */
public function render(string $html, array $options = []): string
{
$widthPt = (float) ($options['widthPt'] ?? 595.28); // A4 width
$heightPt = (float) ($options['heightPt'] ?? 0.0); // 0 = auto-fit
return $this->chrome->render($html, $widthPt, $heightPt)->getPdfData();
}
};
}
}

팩토리와 핀을 렌더러에 연결합니다.

build the production renderer
<?php
declare(strict_types=1);
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
use NextPDF\Cloudflare\CloudflareRendererConfig;
$config = CloudflareRendererConfig::fromArray([
'worker_url' => getenv('CF_WORKER_URL') ?: '',
'api_token' => getenv('CF_PDF_TOKEN') ?: '',
'render_timeout' => 60,
'fallback_to_local' => true,
'pinned_public_keys' => ['sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg='],
'backup_public_keys' => ['sha256/Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys='],
]);
$renderer = new CloudflareHtmlRenderer(
config: $config,
httpClient: $httpClient,
requestFactory: $httpFactory,
streamFactory: $httpFactory,
logger: $logger,
localRendererFactory: new ArtisanLocalRendererFactory($chrome),
responseFactory: $httpFactory,
);

폴백이 실행되면 결과의 renderLocationlocal이고 heightPt0.0입니다. 브리지는 폴백 발생을 warning 수준으로 기록한 뒤 info 수준 로그를 남깁니다. 계획된 로테이션 중 브리지가 엔드포인트에 접근하지 못하는 일이 없도록, 인증서 로테이션 전에 항상 백업 핀을 구성하세요.

  • Worker 오류는 도달 가능성 실패가 아닙니다. HTTP 오류 또는 형식이 잘못된 본문으로 응답하는 Worker는 CloudflareRenderException을 발생시키며 폴백으로 다시 시도되지 않습니다. 엣지에 도달할 수 없을 때만 폴백합니다. 두 catch 분기는 분리해 두세요.
  • 폴백에는 플래그와 팩토리가 모두 필요합니다. fallbackToLocal: true이지만 팩토리가 연결되지 않은 경우, 도달할 수 없는 Worker는 팩토리가 없음을 명시하는 CloudflareNotAvailableException을 발생시킵니다. 팩토리를 연결하세요.
  • isAvailable()은 힌트이지 보장이 아닙니다. 인증된 HEAD를 전송하고 상태가 500 미만이면 true를 반환합니다. 이어지는 POST는 여전히 실패할 수 있습니다. 이를 계약으로 취급하지 마세요.
  • 피닝은 옵트인 방식입니다. 빈 핀 집합은 피닝을 비활성화합니다. 빈 집합은 안정적이고 알려진 인증서 체인에서만 사용하고, 피닝을 적용한 뒤에는 백업 핀을 유지하세요.
  • fontFiles에는 R2 버킷이 필요합니다. fontFiles 인수는 구성에서 r2FontBucket을 설정할 때만 의미가 있으며, 그렇지 않으면 효과가 없습니다.
  • 브리지는 서명하지 않습니다. PDF 바이트를 반환합니다. 엣지에서 렌더링한 다음 자체 프로세스에서 서명하면, 서명 키가 엣지 경계를 절대 넘지 않습니다.

엣지 렌더링은 브라우저 비용을 호스트 밖으로 완전히 옮깁니다. 대신 부담하는 비용은 Worker로의 HTTPS 왕복 1회와 Worker 자체의 렌더 시간이며, 결과는 이를 renderTimeMs로 보고합니다. 브리지는 피닝된 전송을 통해 구성된 타임아웃을 적용합니다. 타임아웃은 측정된 Worker 지연 시간에 여유를 더해 설정하고, 모든 업스트림 게이트웨이 타임아웃보다 낮게 유지하세요. 패키지는 자체적으로 적용하는 한도만 명시합니다. Cloudflare 플랫폼의 CPU, 메모리, 요청 본문 상한에 대해서는 어떤 주장도 하지 않습니다. 이러한 사항은 Cloudflare 문서와 Worker를 참고하세요.

  • 대상은 요청이 PHP를 벗어나기 전에 검증됩니다. HTTPS가 아닌 URL은 거부됩니다. 사설 또는 예약된 주소 공간으로 해석되는 호스트는 모든 A 및 AAAA 레코드를 기준으로 거부됩니다. DNS 리바인딩을 방어하기 위해 호스트는 연결 직전에 다시 해석됩니다.
  • 피닝된 전송은 DNS와 TLS를 바인딩합니다. 응답 팩토리와 핀이 구성된 상태에서, 브리지는 검증된 IP에 연결을 바인딩하고, SPKI 피닝을 적용하고, 피어와 호스트를 검증하며, 검증되지 않은 호스트로의 리디렉션을 따르지 않습니다.
  • 입력은 제한됩니다. maxHtmlSize(기본 5 MB)를 초과하는 HTML, 지나치게 큰 base64 데이터 URI, 그리고 모든 <meta http-equiv="refresh"> 태그는 요청이 전송되기 전에 거부됩니다.
  • 시크릿은 마스킹되고 변경 불가능합니다. apiToken과 R2 키는 #[SensitiveParameter]를 지니므로 스택 트레이스에서 마스킹되며, 구성 객체는 final readonly입니다. 시크릿은 환경이나 시크릿 관리자에서 가져오고, 절대로 커밋하지 마세요.
  • catch 블록을 절대 작성하지 마세요. 각 예제는 특정 예외 유형을 catch하고 정해진 코드로 로깅하거나 종료합니다.

전체 보안 모델(SSRF 및 DNS 리바인딩 방어, 피닝 운영 지침, 시크릿 처리 태세)은 함께 보기에서 연결된 Cloudflare security-and-operations 페이지에 있으며, 이 페이지는 관련 OWASP 및 RFC 7469 조항을 고정합니다.

이 가이드는 자체적으로 규범적 표준 주장을 하지 않습니다. 상위 Cloudflare security-and-operations 페이지와 구성 페이지에서, 브리지의 전체 레코드 DNS 해석과 TOCTOU 재검사는 OWASP SSRF 방지 지침에 매핑되고, TLS 공개 키 피닝과 백업 핀 복구는 RFC 7469에 매핑됩니다. 이 쿡북 페이지는 사용 방법을 다시 설명하고 해당 인용은 그 페이지들로 미룹니다. 브리지는 서명을 수행하지 않으며 서명 적합성 주장을 하지 않습니다.