[Lap 03] GCP Keyless ADC 패치와 2560px 이미지 에러 해결

🏁 LAP 03 / 07 · ⏱️ 13 min read

“서버리스 컨테이너 환경에서는 로컬 디스크가 영구적이지 않기 때문에, 미디어 파일 하나를 올리는 일도 정교한 파이프라인 설계가 필요합니다.”
Lap 02에서 DB 메모리 다이어트와 무인 CI/CD 배포를 성공적으로 안착시킨 뒤, 첫 글에 넣을 고해상도 아키텍처 다이어그램 이미지를 에디터에 드래그 앤 드롭하는 순간이었습니다. 화면에 붉은색 에러 메시지가 나타났습니다.
“The server cannot process the image. This can happen if the server is busy or does not have enough resources. Suggested maximum size is 2560 pixels.”
Warm-up Lap에서 예고했던 ‘장벽 4: 엔터프라이즈 보안 조직 정책과의 충돌(JSON 키 생성 차단)’과 워드프레스 5.3+ 썸네일 생성 버그가 정면으로 터져 나온 것입니다. 이번 글에서는 3단계 미디어 업로드 장애의 원인을 하나씩 추적하고, Keyless ADC 코어 패치(ADR-006)로 영구 해결한 과정을 공유합니다.


1. 배경 이해: 무상태(Stateless) 컨테이너와 외부 스토리지 연동

일반적인 가상 서버(EC2나 VPS)에서는 워드프레스에 이미지를 올리면 서버 로컬 디렉터리(/var/www/html/wp-content/uploads/)에 파일이 영구 저장됩니다.

하지만 Google Cloud Run은 무상태(Stateless) 서버리스 컨테이너입니다.

  • 컨테이너 내부의 파일시스템은 메모리에 마운트된 휘발성(tmpfs/overlay) 공간입니다.
  • 비용을 아끼기 위해 min-instances: 0으로 설정했기 때문에, 방문자가 없으면 컨테이너는 자동으로 내려갑니다(Scale-to-Zero).
  • 이후 새 방문자가 오거나 새 코드가 배포되면 초기화된 새 컨테이너가 다시 올라옵니다(Cold Start).

즉, 로컬 폴더에 이미지를 그대로 두면 컨테이너가 재시작되는 순간 이전에 올린 모든 이미지가 404 Not Found(엑스박스)로 깨지게 됩니다. 그래서 업로드된 미디어를 외부 영구 저장소인 Google Cloud Storage(GCS) 버킷으로 실시간 전송해 주는 WP-Stateless 플러그인 연동이 꼭 필요했습니다.

그런데 이 플러그인을 Cloud Run에 붙이는 과정에서 세 가지 까다로운 문제와 마주쳤습니다.


2. 3연속 장애 분석 및 해결 과정 (ADR-006)

제가 겪은 세 가지 문제와 해결 구조를 한눈에 정리한 다이어그램입니다.

GCP Keyless ADC 패치 및 2560px 이미지 업로드 장애 3단계 해결 아키텍처
GCP Keyless ADC 패치 및 2560px 이미지 업로드 장애 3단계 해결 아키텍처
📐 원본 Mermaid 도식 구조 접기/펼치기
flowchart TD
    Upload["사용자 이미지 업로드 (JPG/PNG)"] --> Core["WordPress 5.3+ 이미지 처리 엔진"]
    
    subgraph Bug2 ["이슈 3: 2560px 에러 & 원본 조기 삭제"]
        Core -->|"1. 메모리 급증"| Imagick["Imagick (OpenMP 멀티스레드)<br/>512Mi 메모리 한계 초과 Crash!"]
        Core -->|"2. 썸네일 루프"| Subsizes["_wp_make_subsizes()"]
        Subsizes -->|"첫 번째 서브사이즈 직후"| Premature["WP-Stateless @unlink(원본 로컬 파일)<br/>후속 썸네일 생성 시 원본 소실!"]
    end
    
    subgraph Bug1 ["이슈 1 & 2: 인증 및 권한 충돌"]
        WPStateless["WP-Stateless 플러그인"] -->|"JSON 키 요구"| OrgPolicy["GCP 조직 정책 차단<br/>disableServiceAccountKeyCreation"]
        WPStateless -->|"buckets.get() 호출"| PermDeny["최소 권한 거부<br/>roles/storage.objectAdmin 부족"]
    end

    Imagick -.->|"해결: mu-plugins"| FixGD["WP_Image_Editor_GD 강제 전환<br/>단일 스레드 저메모리 처리"]
    Premature -.->|"해결: mu-plugins"| FixSync["wp_generate_attachment_metadata 완료 시까지<br/>GCS 동기화 지연 & shutdown 일괄 삭제"]
    OrgPolicy -.->|"해결: 빌드 타임 패치"| FixADC["Keyless ADC 주입<br/>useApplicationDefaultCredentials()"]
    PermDeny -.->|"해결: 빌드 타임 패치"| FixList["objects.listObjects() 대체 & ephemeral 모드"]

    FixGD & FixSync & FixADC & FixList ==> Success["gs://wp-media 버킷 안전 저장 완료!"]

💥 이슈 1: GCP 조직 정책의 JSON 키 생성 차단과 Keyless ADC 패치

WP-Stateless 플러그인을 활성화하면 설정 화면에서 가장 먼저 “Google Cloud 서비스 계정 JSON 키를 입력하라”고 안내합니다.

하지만 GCP 콘솔이나 CLI에서 서비스 계정 키를 발급하려고 하면 다음과 같은 조직 정책 위반 에러가 발생했습니다.

ERROR: (gcloud.iam.service-accounts.keys.create) FAILED_PRECONDITION: 
Precondition check failed.
- One or more organization policies violated:
  constraints/iam.disableServiceAccountKeyCreation: 
  Key creation is disabled for service accounts in this organization.

최근 보안이 강화된 GCP 환경에서는 키 파일 유출 사고를 막기 위해 disableServiceAccountKeyCreation 조직 정책으로 JSON 키 생성을 기본 차단하는 경우가 많습니다. 키를 만들 수 없으니 플러그인 설정란을 비워둘 수밖에 없었고, 플러그인은 즉시 에러를 냈습니다.

Fatal Error: Service Account JSON is invalid.

제가 선택한 해결책: Docker 빌드 타임 Keyless ADC 코드 패치

사실 Cloud Run 위에서 돌아가는 컨테이너는 굳이 외부 JSON 키 파일이 필요하지 않습니다. Cloud Run 자체가 이미 실행 서비스 계정(Compute SA)의 권한을 내장하고 있어, 내부 메타데이터 서버를 통해 인증 토큰을 자동으로 받아오는 ADC(Application Default Credentials)를 지원하기 때문입니다.

문제는 WP-Stateless 플러그인 코드가 JSON 키 문자열이 비어 있으면 무조건 에러를 던지도록 짜여 있었다는 점입니다. 그래서 저는 Docker 이미지를 빌드할 때 플러그인의 PHP 코드를 자동으로 수정하는 패치 스크립트(scripts/patch-wp-stateless-adc.php)를 작성했습니다.

// scripts/patch-wp-stateless-adc.php 발췌
// WP-Stateless의 class-gs-client.php에서 JSON 키가 없으면 Cloud Run 내장 ADC를 사용하도록 패치
$gsClientPatched = str_replace(
    '$this->client->setAuthConfig($this->key_json);',
    'if (!empty($this->key_json) && is_array($this->key_json) && !empty($this->key_json['private_key'])) {' . "n" .
    '            $this->client->setAuthConfig($this->key_json);' . "n" .
    '          } else {' . "n" .
    '            // JSON 키가 비어있으면 Cloud Run 내장 ADC 인증 사용' . "n" .
    '            $this->client->useApplicationDefaultCredentials();' . "n" .
    '          }',
    $gsClientCode
);

이 스크립트를 Dockerfile 빌드 단계에서 실행하도록 넣어두니, 물리적인 JSON 키 파일을 하나도 만들지 않고도 안전하게 GCS 인증을 통과할 수 있었습니다.


💥 이슈 2: 최소 권한 원칙과 Unable to create directory 충돌

인증 문제를 넘기자마자 이번에는 버킷 연결 확인과 업로드 폴더 생성 단계에서 권한 에러가 발생했습니다.

Could not connect to Google Storage bucket: AccessDenied
Unable to create directory /wp-content/uploads/2026/10. Is its parent directory writable by the server?

코드를 열어 원인을 확인해 보니 두 가지 문제가 있었습니다.

  1. 과도한 버킷 메타데이터 조회 권한 요구:

WP-Stateless는 버킷 연결 상태를 확인하기 위해 $this->service->buckets->get($this->bucket) API를 호출합니다. 하지만 제가 서비스 계정에 부여한 권한은 최소 권한 원칙에 따른 Storage Object Admin(객체 관리자)이었습니다. 파일(Object)을 읽고 쓸 권한은 충분하지만, 버킷 자체의 설정 정보를 조회(storage.buckets.get)할 권한은 포함되어 있지 않아 거부된 것입니다.

  1. 스트림 래퍼의 디렉터리 확인 충돌:

GCS 스트림 래퍼가 업로드 경로를 생성할 때마다 버킷 존재 여부를 다시 조회하면서 디렉터리 생성 실패로 이어졌습니다.

해결책: objects->listObjects() 대체 및 ephemeral 모드 적용

서비스 계정에 과도한 전체 관리자 권한을 주는 대신, 패치 스크립트에서 버킷 연결 확인 코드를 Object Admin 권한만으로도 실행 가능한 objects->listObjects(['maxResults' => 1]) 호출로 바꾸었습니다.

// 버킷 메타데이터 조회 대신 객체 목록 1건 조회로 연결 테스트 수행
$gsClientPatched = str_replace(
    '$this->service->buckets->get($this->bucket);',
    '$this->service->objects->listObjects($this->bucket, array('maxResults' => 1));',
    $gsClientPatched
);

그리고 환경변수로 WP_STATELESS_MEDIA_MODE=ephemeral을 설정했습니다. ephemeral 모드를 사용하면 업로드 시 컨테이너 로컬 폴더에 임시 파일을 만들어 이미지 리사이징을 마친 뒤 GCS로 전송하고 로컬 파일을 지워주므로, 스트림 래퍼의 디렉터리 생성 충돌을 깔끔하게 피할 수 있습니다.


💥 이슈 3: 고해상도 이미지 업로드 시 발생하는 2560px 에러와 원본 조기 삭제 버그

여기까지 패치하자 작은 아이콘 이미지(400×300)는 GCS 버킷에 잘 올라갔습니다. 그런데 실제 본문에 넣을 고해상도 스크린샷(3840×2160)을 올리자 다시 에러가 떴습니다.

The server cannot process the image. This can happen if the server is busy or does not have enough resources. 
Suggested maximum size is 2560 pixels.

보통 워드프레스에서 이 메시지를 보면 PHP memory_limit 부족을 의심하곤 합니다. 혹시 여러분도 이 에러 때문에 php.ini의 메모리 설정만 한참 늘려보신 적이 있나요? 저도 처음엔 memory_limit = 512M으로 올려보았지만 소용이 없었습니다.

직접 워드프레스 코어의 이미지 처리 함수와 플러그인 훅 실행 순서를 따라가며 디버깅해 본 결과, 진짜 원인은 다른 두 곳에 있었습니다.

원인 A: Cloud Run 512Mi 환경에서 Imagick의 멀티스레드 메모리 급증

워드프레스가 기본으로 사용하는 이미지 처리 라이브러리는 Imagick(ImageMagick)입니다. 그런데 ImageMagick은 OpenMP를 통해 이미지 리사이징 시 여러 스레드로 병렬 처리를 시도합니다.

Cloud Run 컨테이너(1 vCPU, 512MiB RAM, gVisor 샌드박스) 환경에서 4K 해상도 이미지를 Imagick으로 열면 순간적으로 메모리 할당량이 급증하면서 리사이징 도중 프로세스가 비정상 종료되고 있었습니다.

원인 B: WordPress 5.3+ 썸네일 생성 루프와 WP-Stateless의 조기 삭제(@unlink) 충돌

더 결정적인 원인은 워드프레스 코어와 WP-Stateless 사이의 타이밍 문제였습니다.

  1. 워드프레스 5.3 이상부터는 가로/세로 2560px을 넘는 큰 이미지가 올라오면 _wp_make_subsizes() 함수가 실행되어 원본을 scaled 이미지로 줄이고, 6~7개의 서브사이즈 썸네일(thumbnail, medium, large 등)을 루프를 돌며 하나씩 만듭니다.
  2. 이때 워드프레스 코어는 썸네일 1개를 만들 때마다 중간 진행 상태를 DB에 기록하려고 wp_update_attachment_metadata 필터를 매번 호출합니다.
  3. 그런데 WP-Stateless의 ephemeral 모드는 이 필터가 호출되자마자 “메타데이터 업데이트가 끝났으니 GCS로 파일을 올리고 로컬 원본 파일을 삭제(@unlink)해야겠다”라고 판단해 버립니다.
  4. 결국 첫 번째 작은 썸네일 하나만 만들어진 상태에서 로컬 디스크의 원본 이미지가 삭제되어 버리고, 두 번째 썸네일(medium)을 만들려던 워드프레스 코어는 원본 파일이 사라진 것을 보고 2560 pixels 에러를 뱉으며 업로드를 중단했던 것입니다.

3. 최종 해결 코드: mu-plugins/cloudrun.php 3단계 제어

원인을 정확히 찾았으니 해결은 명확했습니다. 테마나 플러그인이 업데이트되어도 초기화되지 않도록 필수 플러그인(mu-plugins/cloudrun.php)에 아래 제어 로직을 작성해 넣었습니다.

// 1. Imagick 메모리 급증 방지: 단일 스레드 기반의 가벼운 WP_Image_Editor_GD 강제 사용
add_filter('wp_image_editors', function () {
    return array('WP_Image_Editor_GD');
}, 99);

$GLOBALS['cloudrun_generating_subsizes'] = array();
$GLOBALS['cloudrun_cleanup_paths'] = array();

// 2. 대용량 이미지 서브사이즈 생성 루프 시작 감지
add_filter('big_image_size_threshold', function ($threshold, $imagesize, $file, $attachment_id) {
    if ($attachment_id) {
        $GLOBALS['cloudrun_generating_subsizes'][$attachment_id] = true;
    }
    if ($file) {
        $GLOBALS['cloudrun_cleanup_paths'][] = $file;
    }
    return $threshold;
}, 1, 4);

// 3. 썸네일 루프가 도는 동안에는 WP-Stateless의 조기 업로드 및 로컬 파일 삭제(@unlink) 보류
add_filter('wp_stateless_skip_remove_media', '__return_true', 99);
add_filter('wp_stateless_skip_add_media', function ($skip, $metadata, $attachment_id) {
    if ($attachment_id && !empty($GLOBALS['cloudrun_generating_subsizes'][$attachment_id])) {
        return true;
    }
    return $skip;
}, 99, 3);

// 4. 모든 썸네일 생성이 완전히 끝난 시점(wp_generate_attachment_metadata)에 일괄 GCS 동기화
add_filter('wp_generate_attachment_metadata', function ($metadata, $attachment_id) {
    if ($attachment_id && isset($GLOBALS['cloudrun_generating_subsizes'][$attachment_id])) {
        unset($GLOBALS['cloudrun_generating_subsizes'][$attachment_id]);
    }
    $full_path = get_attached_file($attachment_id);
    if ($full_path) {
        $GLOBALS['cloudrun_cleanup_paths'][] = $full_path;
        $base_dir = dirname($full_path);
        if (!empty($metadata['sizes']) && is_array($metadata['sizes'])) {
            foreach ($metadata['sizes'] as $size_data) {
                if (!empty($size_data['file'])) {
                    $GLOBALS['cloudrun_cleanup_paths'][] = $base_dir . '/' . $size_data['file'];
                }
            }
        }
    }
    return $metadata;
}, 999, 2);

// 5. HTTP 응답이 끝나는 shutdown 시점에 로컬 임시 파일들을 안전하게 정리
add_action('shutdown', function () {
    if (!empty($GLOBALS['cloudrun_cleanup_paths'])) {
        foreach (array_unique($GLOBALS['cloudrun_cleanup_paths']) as $path) {
            if ($path && file_exists($path)) {
                @unlink($path);
            }
        }
    }
});

적용 후 달라진 점

  • GD 라이브러리 전환: 4K 이상의 고해상도 PNG/JPG 이미지도 60MB 미만의 메모리만 사용하며 빠르게 리사이징됩니다.
  • 조기 삭제 버그 해결: 모든 크기의 썸네일이 다 만들어질 때까지 원본 파일이 보존된 후 한 번에 GCS로 동기화되어, 2560px 에러가 완전히 사라졌습니다.
  • 메모리 누수 방지: 요청이 종료(shutdown)될 때 컨테이너 내부의 임시 파일들을 깔끔하게 지워 컨테이너의 가용 메모리를 지킵니다.

4. 마치며: 다음 글(Lap 4) 예고

처음에는 단순한 이미지 업로드 에러처럼 보였지만, 그 안에는 GCP 조직 정책과 Keyless ADC 인증, 최소 권한 IAM, 그리고 워드프레스 코어 훅의 실행 순서라는 꽤 깊은 아키텍처 이슈들이 얽혀 있었습니다.

이제 미디어 파이프라인까지 안정적으로 동작하게 되었으니, 다음 단계에서는 외부 도메인을 연결할 때 마주친 :8080 포트 노출 문제와 무차별적으로 날아오는 자동화 해킹 봇들을 막아낸 Cloudflare WAF 및 컨테이너 5중 보안 설정(Lap 4)을 살펴보겠습니다.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *