---
title: "[Lap 02] 1GB 무료 VM MariaDB 튜닝과 Cloud Build 배포"
canonical_url: "https://wealth-podium.com/2026/10/21/track-01-lap-2-db-tuning-and-cicd/"
published_at: "2026-10-21T09:00:00+09:00"
modified_at: "2026-11-07T15:44:35+09:00"
author: "Dellwon (Software Developer & System Architect)"
summary: "1GB RAM 무료 VM에서 MariaDB가 OOM으로 종료되지 않도록 2GB 스왑과 버퍼 풀을 튜닝하고, GCP의 변경된 IAM 정책과 태그 충돌을 해결해 git push 한 번으로 배포되는 Cloud Build CI/CD 파이프라인을 구축한 기록입니다."
---
# [Lap 02] 1GB 무료 VM MariaDB 튜닝과 Cloud Build 배포

> **"서버리스 컨테이너가 아무리 유연하게 확장되어도, 데이터를 책임지는 백엔드 데이터베이스가 버텨주지 못하면 서비스는 한순간에 멈춰 섭니다."**  
> [Lap 01](/2026/10/20/track-01-lap-1-zero-cost-architecture/)에서 우리는 오리건 단일 리전 통합(ADR-001)을 통해 태평양 횡단 쿼리 지연을 해결했습니다. 하지만 [Warm-up Lap](/2026/10/19/track-01-lap-0-warm-up-adr-genesis/)에서 예고했던 두 번째 복병, 바로 **'장벽 3: 1GB RAM의 메모리 고갈(OOM Crash)'**과 마주하게 됩니다.  
> GCP Always Free 티어로 제공되는 `e2-micro` 인스턴스는 무료라는 강력한 장점이 있지만, vCPU 0.25개와 **단 1GB의 RAM**이라는 빡빡한 물리적 제약을 가지고 있습니다. 아무런 튜닝 없이 기본 설정 그대로 MySQL이나 MariaDB를 설치하면, 리눅스 커널의 OOM Killer(Out-Of-Memory Killer)가 수시로 DB 프로세스를 강제 종료해 버립니다.  
> 
> 이번 글에서는 1GB RAM 무료 VM에서 MariaDB를 250MB 이내로 다이어트해 안정적으로 구동하기 위한 **ADR-002 튜닝 설정**과, 최신 Artifact Registry 보안 정책을 반영해 구축한 **ADR-005 Cloud Build 무인 자동 배포 파이프라인**을 소개합니다.

---

## 1. 문제 진단: 기본 설정의 MySQL은 왜 1GB RAM에서 죽는가?

우분투(Ubuntu) 서버를 생성하고 흔히 하듯 `apt-get install mariadb-server`를 실행한 뒤 워드프레스를 연결하면, 처음에는 아무 문제 없이 잘 돌아가는 것처럼 보입니다. 하지만 글을 몇 편 올리고 트래픽이 조금만 들어오면 갑자기 사이트에 `Error establishing a database connection` 화면이 뜨면서 먹통이 됩니다.

혹시 저사양 클라우드 인스턴스에 DB를 직접 올려보신 분이라면 이 에러가 낯설지 않으실 텐데요. 서버 터미널에 접속해 `dmesg`나 `/var/log/syslog`를 열어보면 원인은 명확합니다.

```text
[ 1420.582190] Out of memory: Killed process 1842 (mysqld) total-vm:1342890kB, anon-rss:789240kB, file-rss:0kB, shmem-rss:0kB, UID:112 pgtables:1820kB oom_score_adj:0
[ 1420.610012] systemd[1]: mariadb.service: Main process exited, code=killed, status=9/KILL
[ 1420.610540] systemd[1]: mariadb.service: Failed with result 'oom-kill'.
```

### 메모리 소비 구조를 직접 계산해 보았습니다
왜 1GB RAM이 순식간에 바닥나는지 컴포넌트별 메모리 점유량을 하나씩 뜯어보았습니다.
1. **`performance_schema`의 기본 메모리 점유**:  
   DB 성능 지표를 수집하기 위해 기본 활성화된 Performance Schema는 수많은 내부 테이블을 메모리에 상주시킵니다. 이것만으로도 약 **150MB~200MB**를 차지합니다.
2. **`innodb_buffer_pool_size` 기본값**:  
   InnoDB 스토리지 엔진은 기본적으로 상당량의 메모리를 버퍼 풀로 할당하려 시도합니다 (보통 128MB~512MB).
3. **스레드 및 커넥션 버퍼**:  
   `max_connections` 기본값(151개) 상태에서 동시 연결이 늘어날 때마다 `sort_buffer_size`, `read_buffer_size`, `join_buffer_size`가 커넥션 수만큼 할당됩니다.
4. **OS 커널과 GCP 기본 데몬**:  
   Ubuntu 커널, `systemd`, `sshd`, 그리고 GCP 게스트 에이전트(`google-guest-agent`)가 기본적으로 **250MB~300MB**를 사용합니다.

$$300\text{MB (OS)} + 200\text{MB (P\_S)} + 256\text{MB (Buffer Pool)} + 350\text{MB (Threads/Others)} = 1,106\text{MB} > 1,024\text{MB (1GB RAM)}$$

물리 메모리 한계선(1,024MB)을 넘어서는 순간, 리눅스 커널의 OOM Killer는 시스템 전체가 멈추는 것을 막기 위해 메모리를 가장 많이 쓰고 있는 `mysqld` 프로세스를 강제로 종료(`SIGKILL`)해 버립니다.

---

## 2. 해결 과정: 2GB Swap 확보와 저메모리 맞춤형 MariaDB 튜닝

저는 이 문제를 근본적으로 해결하기 위해 VM 초기화 스크립트(`scripts/setup-db-vm.sh`)에 두 단계의 메모리 보호 장치를 적용했습니다.

### Step 1. 2GB 스왑(Swap) 메모리와 `swappiness` 튜닝
메모리 사용량이 일시적으로 튀더라도 프로세스가 즉시 강제 종료되지 않도록 디스크 공간 일부를 완충 지대로 쓰는 2GB 스왑 파일을 구성했습니다.

```bash
# 1. 2GB 스왑 파일 생성 및 마운트
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# 2. 커널 스왑 성향(Swappiness) 튜닝
echo "vm.swappiness=10" >> /etc/sysctl.conf
echo "vm.vfs_cache_pressure=50" >> /etc/sysctl.conf
sysctl -p
```

> 💡 **왜 `vm.swappiness=10`으로 낮추었을까요?**  
> 리눅스 기본값인 `60`으로 두면 물리 RAM에 여유가 있는데도 커널이 디스크 스왑을 꽤 적극적으로 사용합니다. 디스크 I/O는 RAM보다 훨씬 느리기 때문에 DB 쿼리 응답 속도가 떨어질 수 있습니다.  
> 그래서 `swappiness=10`으로 낮추어, 평상시에는 물리 RAM이 90% 가까이 찰 때까지 최대한 빠른 RAM 위주로 동작하게 하고 **정말 메모리가 부족한 비상 상황에서만 스왑이 방어막 역할을 하도록** 조정했습니다.

---

### Step 2. MariaDB 메모리 상한선 튜닝 (`99-e2-micro.cnf`)
스왑만으로는 근본적인 해결이 되지 않습니다. 애초에 MariaDB가 1GB RAM 안에서 가볍게 돌아가도록 설정 파일(`/etc/mysql/mariadb.conf.d/99-e2-micro.cnf`)을 직접 작성해 주입했습니다.

```ini
[mysqld]
# 사설망 내부 IP 바인딩 (Direct VPC 통신 허용)
bind-address = 0.0.0.0

# 1. 불필요한 성능 수집 스키마 비활성화 (약 180MB 즉시 절약)
performance_schema = OFF

# 2. InnoDB 엔진 메모리 제한 (1GB VM에 맞춘 버퍼 풀 크기)
innodb_buffer_pool_size = 128M
innodb_log_buffer_size = 8M

# 3. 최대 동시 연결 수 현실화 (Cloud Run 컨테이너 수 고려)
max_connections = 50

# 4. 키 버퍼 및 캐시 최적화
key_buffer_size = 16M
table_open_cache = 400
thread_cache_size = 8
max_heap_table_size = 32M
tmp_table_size = 32M
```

### 튜닝 전후 메모리 점유율 비교

| 측정 지표 | 튜닝 전 (기본 설정) | 튜닝 후 (`e2-micro` 최적화) | 개선 결과 |
| :--- | :--- | :--- | :--- |
| **평상시 MariaDB RAM 점유** | 약 650MB ~ 850MB | **약 180MB ~ 220MB** | **약 75% 메모리 절감** |
| **OOM 강제 종료 발생 빈도** | 하루 1~2회 불시 다운 | **0회 (무중단 안정 가동)** | 서비스 안정성 확보 |
| **가용 여유 메모리 (Free)** | 50MB 미만 (위험) | **450MB 이상 유지** | OS 및 버퍼 여유분 확보 |

이렇게 튜닝을 마치고 나니 1GB RAM VM에서도 평상시 450MB 이상의 여유 메모리를 유지하며 DB가 안정적으로 돌아가는 것을 확인할 수 있었습니다.

---

## 3. 보안 설계: Secret Manager와 사설 VPC 내부망 격리

DB가 안정화되었다면 다음은 보안입니다. 설정 파일이나 Git 저장소에 DB 비밀번호를 평문으로 적어두는 것은 보안상 가장 위험한 습관 중 하나입니다.

저는 GCP **Secret Manager**와 **Direct VPC Egress**를 결합해 코드에서 비밀번호를 완전히 분리했습니다.

```bash
# 1. DB 비밀번호를 GCP Secret Manager에 암호화 보관
echo -n "MySuperSecurePassword123!" | gcloud secrets create wordpress-db-password \
    --data-file=- \
    --replication-policy="automatic"

# 2. Cloud Run을 구동하는 서비스 계정(Compute SA)에만 시크릿 읽기 권한 부여
gcloud secrets add-iam-policy-binding wordpress-db-password \
    --member="serviceAccount:<GCP_PROJECT_NUMBER>-compute@developer.gserviceaccount.com" \
    --role="roles/secretmanager.secretAccessor"
```

Cloud Run 배포 시에는 `--set-secrets` 옵션을 통해 컨테이너가 기동될 때 런타임 메모리에만 환경변수(`WORDPRESS_DB_PASSWORD`)로 주입되도록 구성했습니다.

또한 DB VM의 외부 방화벽에서는 3306 포트를 완전히 닫아두고, Cloud Run 컨테이너만 **Direct VPC Egress(`--network=default --vpc-egress=private-ranges-only`)**를 통해 내부 사설 IP(예: `10.x.x.x:3306`)로 접근할 수 있게 격리했습니다.

---

## 4. 배포 자동화 트러블슈팅: Cloud Build CI/CD 실패 2건 해결기 (ADR-005)

DB와 네트워크 구성을 마친 뒤, 코드를 `git push`하면 자동으로 도커 이미지를 빌드해 Cloud Run에 배포하는 CI/CD 파이프라인(`cloudbuild.yaml`)을 연결했습니다. 그런데 첫 빌드를 돌리자마자 두 번 연속으로 예상치 못한 에러를 만났습니다.

### 이슈 1: `docker build`는 성공하는데 `docker push`가 거부되는 문제
GitHub 트리거를 통해 빌드가 시작되었고 Step 1(`docker build`)은 무사히 넘어갔는데, 이미지를 저장소에 올리는 Step 2(`docker push`)에서 아래 에러를 내며 멈췄습니다.

```text
Step #2: denied: Permission "artifactregistry.repositories.uploadArtifacts" denied on resource "projects/<GCP_PROJECT_ID>/locations/us-west1/repositories/wordpress-repo" (or it may not exist)
Step #2: exitCode: 1
Finished Step #2 - ERROR
ERROR: build step 2 "gcr.io/cloud-builders/docker" failed: step exited with non-zero status: 1
```

> 🔍 **원인 파악: 달라진 GCP 기본 서비스 계정 정책**  
> 예전 GCP 튜토리얼들을 보면 별도의 권한 설정 없이도 Cloud Build가 잘 돌아갔습니다. 과거에는 기본 Compute 서비스 계정(`<GCP_PROJECT_NUMBER>-compute@developer.gserviceaccount.com`)에 프로젝트 전체 **`Editor(편집자)`** 권한이 자동 부여되었기 때문입니다.  
> 하지만 최근 GCP는 보안 강화를 위해 신규 프로젝트에서 기본 서비스 계정의 `Editor` 자동 부여를 폐지했고, Cloud Build의 기본 실행 주체도 `Compute SA`로 통합했습니다.  
> 결국 서비스 계정에 **Artifact Registry 쓰기 권한**과 **Cloud Logging 로그 기록 권한**을 명시적으로 주어야 했던 것입니다.

```bash
# 해결: Compute SA에 필요한 최소 권한 2종 직접 부여
gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> \
    --member="serviceAccount:<GCP_PROJECT_NUMBER>-compute@developer.gserviceaccount.com" \
    --role="roles/artifactregistry.writer"

gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> \
    --member="serviceAccount:<GCP_PROJECT_NUMBER>-compute@developer.gserviceaccount.com" \
    --role="roles/logging.logWriter"
```

---

### 이슈 2: `--all-tags` 푸시와 `:latest` 태그 덮어쓰기 충돌
권한을 부여한 뒤 첫 번째 배포는 성공했습니다. 그런데 설정을 조금 수정해 두 번째 커밋을 푸시하자 이번에는 다른 에러가 발생했습니다.

```text
Step #2: The push refers to repository [us-west1-docker.pkg.dev/<GCP_PROJECT_ID>/wordpress-repo/wordpress]
Step #2: tag latest already exists and repository does not allow tag mutation
Step #2: ERROR: failed to push some refs to 'us-west1-docker.pkg.dev/<GCP_PROJECT_ID>/wordpress-repo/wordpress'
```

Artifact Registry 저장소에 태그 불변성(Immutable Tags) 옵션이 켜져 있거나 태그 덮어쓰기 제약이 걸려 있으면, 이미 존재하는 `:latest` 태그를 덮어쓰려 할 때 푸시 전체가 실패하는 문제였습니다.

> 🛠️ **해결 방법: 고유 `${BUILD_ID}` 태그 기반 배포 (`cloudbuild.yaml`)**  
> 아키텍처 관점에서도 `:latest` 태그에 의존해 운영 배포를 하는 것은 권장되지 않습니다. 그래서 Cloud Build가 매 빌드마다 생성하는 고유 식별자인 **`${BUILD_ID}`**를 메인 배포 이미지 태그로 사용하도록 `cloudbuild.yaml`을 수정했습니다.

```yaml
# cloudbuild.yaml 핵심 발췌
steps:
  # Step 2: 빌드 시 고유 BUILD_ID 태그 부여
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - '${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:${BUILD_ID}'
      - '--cache-from'
      - '${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:latest'
      - '.'

  # Step 3: BUILD_ID 이미지를 우선 푸시하고, latest 덮어쓰기 실패 시에도 빌드가 중단되지 않도록 처리
  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        set -euo pipefail
        docker push ${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:${BUILD_ID}
        docker tag ${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:${BUILD_ID} ${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:latest
        docker push ${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:latest || echo "Notice: :latest overwrite skipped"

  # Step 4: 검증된 고유 BUILD_ID 이미지로 Cloud Run 무중단 배포
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        gcloud run deploy ${_SERVICE_NAME} \
          --image "${_AR_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_AR_REPOSITORY}/${_SERVICE_NAME}:${BUILD_ID}" \
          --region "${_REGION}" \
          --platform managed
```

이렇게 바꾸고 나니 매번 빌드할 때마다 고유 버전이 깔끔하게 기록되고, 혹시 문제가 생겨도 이전 `${BUILD_ID}`로 즉시 롤백할 수 있는 배포 파이프라인이 완성되었습니다.

---

## 5. 마치며: 다음 글(Lap 3) 예고

이번 단계에서는 두 가지 핵심 과제를 마무리했습니다.

1. **1GB RAM 무료 VM**에 2GB Swap과 저메모리 MariaDB 설정을 적용해, 평상시 메모리 점유율을 200MB 안팎으로 낮추고 OOM 종료 문제를 해결했습니다.
2. GCP의 최신 IAM 권한 정책과 이미지 태그 제약을 반영해 **`git push` 한 번으로 자동 배포되는 Cloud Build CI/CD 파이프라인**을 구축했습니다.

인프라와 DB, 자동 배포까지 모두 준비되었으니 이제 글만 쓰면 될 것 같았는데요. 막상 첫 포스트에 고해상도 이미지를 첨부하려는 순간 생각지도 못한 에러가 제 발목을 잡았습니다.

> *"이미지를 업로드했는데 2560px 에러가 뜨면서 실패한다?"*

서비스 계정 JSON 키 발급을 막아둔 GCP 조직 보안 정책과, 워드프레스 5.3 이상 버전의 썸네일 생성 루프 버그가 맞물려 발생한 **미디어 파이프라인 디버깅 이야기**를 다음 글에서 자세히 풀어보겠습니다.
