[Lap 02] 1GB 무료 VM MariaDB 튜닝과 Cloud Build 배포

🏁 LAP 02 / 07 · ⏱️ 11 min read

“서버리스 컨테이너가 아무리 유연하게 확장되어도, 데이터를 책임지는 백엔드 데이터베이스가 버텨주지 못하면 서비스는 한순간에 멈춰 섭니다.”
Lap 01에서 우리는 오리건 단일 리전 통합(ADR-001)을 통해 태평양 횡단 쿼리 지연을 해결했습니다. 하지만 Warm-up Lap에서 예고했던 두 번째 복병, 바로 ‘장벽 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를 열어보면 원인은 명확합니다.

[ 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를 차지합니다.

  1. innodb_buffer_pool_size 기본값:

InnoDB 스토리지 엔진은 기본적으로 상당량의 메모리를 버퍼 풀로 할당하려 시도합니다 (보통 128MB~512MB).

  1. 스레드 및 커넥션 버퍼:

max_connections 기본값(151개) 상태에서 동시 연결이 늘어날 때마다 sort_buffer_size, read_buffer_size, join_buffer_size가 커넥션 수만큼 할당됩니다.

  1. OS 커널과 GCP 기본 데몬:

Ubuntu 커널, systemd, sshd, 그리고 GCP 게스트 에이전트(google-guest-agent)가 기본적으로 250MB~300MB를 사용합니다.

$$300text{MB (OS)} + 200text{MB (P_S)} + 256text{MB (Buffer Pool)} + 350text{MB (Threads/Others)} = 1,106text{MB} > 1,024text{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 스왑 파일을 구성했습니다.

# 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)을 직접 작성해 주입했습니다.

[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를 결합해 코드에서 비밀번호를 완전히 분리했습니다.

# 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>[email protected]" 
    --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)에서 아래 에러를 내며 멈췄습니다.

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>[email protected])에 프로젝트 전체 Editor(편집자) 권한이 자동 부여되었기 때문입니다.
하지만 최근 GCP는 보안 강화를 위해 신규 프로젝트에서 기본 서비스 계정의 Editor 자동 부여를 폐지했고, Cloud Build의 기본 실행 주체도 Compute SA로 통합했습니다.
결국 서비스 계정에 Artifact Registry 쓰기 권한과 Cloud Logging 로그 기록 권한을 명시적으로 주어야 했던 것입니다.

# 해결: Compute SA에 필요한 최소 권한 2종 직접 부여
gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> 
    --member="serviceAccount:<GCP_PROJECT_NUMBER>[email protected]" 
    --role="roles/artifactregistry.writer"

gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> 
    --member="serviceAccount:<GCP_PROJECT_NUMBER>[email protected]" 
    --role="roles/logging.logWriter"

이슈 2: --all-tags 푸시와 :latest 태그 덮어쓰기 충돌

권한을 부여한 뒤 첫 번째 배포는 성공했습니다. 그런데 설정을 조금 수정해 두 번째 커밋을 푸시하자 이번에는 다른 에러가 발생했습니다.

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을 수정했습니다.

# 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 이상 버전의 썸네일 생성 루프 버그가 맞물려 발생한 미디어 파이프라인 디버깅 이야기를 다음 글에서 자세히 풀어보겠습니다.

Similar Posts

Leave a Reply

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