Retrofitting Apps for Cloud Storage with Zero Code Changes

Michael Lynch

코드 한 줄도 바꾸지 않고 앱을 클라우드 스토리지용으로 개조하기

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

최근에 서버 중 하나에 미디어 공유 앱을 설치했다. 설치 자체는 간단했지만, 장기적인 유지보수 측면에서는 교묘한 함정이 숨어 있었다.

사용자가 파일을 업로드할 때마다 웹 앱은 그 파일을 로컬 파일 시스템에 저장했다. 만약 서버를 날리고 다시 구축해야 한다면 모든 파일을 수동으로 백업하고 복원해야 하는 상황이었다. 더 나은 아키텍처라면 앱이 파일을 별도의 스토리지 서버에 쓰도록 하는 것이겠지만, 그러자고 앱을 몇 달에 걸쳐 다시 작성하고 싶지는 않았다.

단순한 아키텍처와 이상적인 아키텍처 비교

Docker와 Google Cloud Storage, 그리고 gcsfuse 유틸리티를 이용해 앱 코드 한 줄도 바꾸지 않고 이 분리를 이뤄냈다.

이 튜토리얼에서는 소스 코드 수정을 최소화하면서 레거시 앱을 Google Cloud Storage에 맞게 개조하는 방법을 보여주겠다.

용어 정리

Google Cloud Platform이 익숙하지 않다면, 이 튜토리얼에서 알아두면 좋을 약어들을 정리했다:

약어정식 명칭설명비슷한 서비스
GCSGoogle Cloud StorageGoogle의 클라우드 스토리지 서비스Amazon S3
GCEGoogle Compute EngineGoogle의 온디맨드 가상 머신 서비스Amazon EC2
GCRGoogle Container RegistryDocker 이미지를 위한 Google의 호스팅 서비스Docker Hub
GCPGoogle Cloud PlatformGoogle의 클라우드 컴퓨팅 플랫폼(GCS, GCE, GCR은 모두 GCP의 일부)Amazon Web Services 또는 Microsoft Azure

Docker 로고

Docker를 사용하면 개발자가 어디서든 실행할 수 있는 독립적인 애플리케이션 환경을 구축할 수 있다:

  • Docker 이미지란 운영체제와 모든 서드파티 의존성을 포함해 앱을 실행하는 데 필요한 모든 파일의 집합이다.
  • Docker 컨테이너란 Docker 이미지가 실제로 실행되는 라이브 환경이다.

이 튜토리얼에서는 엄밀히 말하면 그렇지 않지만, Docker 컨테이너를 가벼운 가상 머신이라고 생각하면 이해하기 쉽다. 엄밀히는 그렇지 않다.

사전 준비

예제를 따라 하려면 다음이 필요하다:

예제 앱

이 튜토리얼을 위해 예제 프로젝트를 하나 만들었다. Flask 프레임워크의 업로드 문서에 기반한 아주 단순한 웹 앱이다.

실행하려면 다음 명령을 입력하면 된다:

git clone https://github.com/mtlynch/flask_upload_demo.git
cd flask_upload_demo
sudo pip install -r requirements.txt
gunicorn \
  demo.app:app \
  --bind 0.0.0.0:5000

이 앱에서는 파일을 선택해 업로드할 수 있다:

데모 앱 랜딩 페이지 스크린샷

flask-upload-demo의 업로드 페이지

업로드된 파일은 이후 http://[server address]:5000/uploads/[filename] URL에서 계속 제공된다:

데모 앱 업로드 결과 스크린샷

업로드된 이미지를 제공하는 flask-upload-demo

서버 내부에서 보면 앱이 파일을 로컬 파일 시스템의 demo/uploads 폴더에 저장한 것을 확인할 수 있다:

$ ls -l demo/uploads/
-rw-rw-r-- 1 mike mike 230720 Nov 24 21:45 Space_Duck_Desktop_RGB_PNG.png

예제 앱 도커라이징하기

이 앱은 의존성이 몇 개 없는 단순한 앱이라 Docker 이미지를 만들기도 쉽다:

Dockerfile

FROM debian:stretch

RUN apt-get update
RUN apt-get install --yes \
      git-core \
      python \
      python-pip \
      python-virtualenv

# Create demo user system account.
ARG APP_USER="demo-user"
ARG APP_GROUP="demo-user"
ARG APP_HOME_DIR="/home/demo-user"
RUN set -x && \
    groupadd "$APP_GROUP" && \
    useradd \
      --comment "Demo app system account" \
      --home-dir "$APP_HOME_DIR" \
      --create-home \
      --system \
      --gid "$APP_GROUP" \
      "$APP_USER"

# Create directory for app source code.
ARG APP_ROOT="/srv/demo-app"
RUN mkdir --parents "$APP_ROOT" && \
    chown \
      --no-dereference \
      --recursive \
      "${APP_USER}:${APP_GROUP}" "$APP_ROOT"

USER "$APP_USER"
WORKDIR "$APP_ROOT"

# Install demo app.
ARG DEMO_APP_REPO="https://github.com/mtlynch/flask_upload_demo"
RUN set -x && \
    git clone "$DEMO_APP_REPO" . && \
    virtualenv VIRTUAL && \
    . VIRTUAL/bin/activate && \
    pip install --requirement requirements.txt

EXPOSE 5000

# Run demo app.
ENV FLASK_APP "demo/app.py"
CMD virtualenv VIRTUAL && \
    . VIRTUAL/bin/activate && \
    gunicorn \
      demo.app:app \
      --bind 0.0.0.0:5000 \
      --log-level info

Dockerfile은 이미지를 준비하기 위해 몇 가지 주요 작업을 수행한다:

  1. Git, Python 및 관련 패키지 설치
  2. 제한된 권한으로 앱을 실행할 시스템 계정(demo-user) 생성
  3. 앱 소스 저장소를 로컬에 클론
  4. 포트 5000에서 데모 앱을 시작하는 CMD 추가

내 저장소를 클론한 뒤 로컬에서 Docker 컨테이너를 빌드해 이 Dockerfile을 테스트해 볼 수 있다:

cd ~
git clone https://github.com/mtlynch/docker-flask-upload-demo.git
cd docker-flask-upload-demo

docker build \
  --tag demo-app-image \
  .

docker run \
  --detach \
  --publish 80:5000 \
  --name demo-app \
  demo-app-image

브라우저에서 http://localhost/에 접속하면 데모 앱이 보인다.

좀 더 현실적인 Docker 이미지

대부분의 웹 앱은 브라우저의 트래픽을 직접 받지 않는다. 대신 Nginx나 Apache 같은 HTTP 서버가 로드 밸런싱, 정적 파일 제공 등 공통 작업을 처리하고, 백엔드는 앱 고유의 로직을 담당한다.

실제 서비스에 더 가까운 Docker 이미지를 보여주기 위해 Docker 저장소에 nginx 브랜치를 추가했다. 주요 변경 내용은 다음과 같다:

# Install nginx.
ARG NGINX_GROUP="www-data"
COPY nginx.conf /etc/nginx/sites-enabled/nginx.conf
RUN set -x && \
    apt-get install --yes \
      nginx \
      sudo && \
    rm /etc/nginx/sites-enabled/default && \
    usermod --append --groups "$NGINX_GROUP" "$APP_USER" && \
    echo "$APP_USER ALL=(ALL:ALL) NOPASSWD: /usr/sbin/nginx" >> /etc/sudoers

Nginx는 보통 특권 포트인 80번과 443번에서 대기해야 하므로 root로 실행된다. 따라서 Dockerfile에서는 sudo를 이용해 데모 앱 사용자가 다른 작업은 제한된 권한으로 수행하면서도 Nginx만 root로 실행할 수 있도록 했다.

마지막으로 Nginx 설정 파일을 컨테이너에 복사한다:

nginx.conf

server {
    listen       80;
    server_name  example.org; # Replace with your server's domain name
    client_max_body_size 20m;

    # Serve static resources directly (bypass backend).
    location /uploads/ {
        alias /srv/demo-app/demo/uploads/;
    }

    # Forward all other requests to the application backend.
    location / {
        proxy_pass http://127.0.0.1:5000;
    }
}

location /uploads { ... } 블록은 요청을 flask-upload-demo 백엔드로 전달하는 대신 Nginx가 /uploads 폴더의 파일을 직접 제공하도록 한다. Nginx가 정적 파일을 앱 백엔드보다 더 빠르고 효율적으로 처리하기 때문에 흔히 사용하는 설정이다.

아래 명령으로 flask-upload-demo의 Nginx 버전을 실행할 수 있다:

cd ~/docker-flask-upload-demo
git checkout nginx

docker build \
  --tag demo-app-image \
  .

docker run \
  --detach \
  --publish 80:80 \
  --name demo-app \
  demo-app-image

앱은 다시 http://localhost/에서 보인다. 동작은 이전 버전과 동일하지만, 파일 업로드를 제공하는 데 더 적은 리소스를 사용한다.

GCP 프로젝트 준비하기

앱을 로컬에 배포하는 것도 좋지만, 모든 사용자가 접근할 수 있도록 클라우드에 게시하면 더 흥미롭다. GCP에 배포할 수 있도록 시스템을 설정하려면 몇 가지 단계를 거쳐야 한다.

먼저 gcloud에서 GCP 프로젝트 이름을 지정한다:

PROJECT_ID="ENTER-YOUR-PROJECT-ID-HERE"
gcloud config set project "$PROJECT_ID"

다음으로 GCP 웹 콘솔에서 owner 역할을 가진 서비스 계정을 만든다:

주의: GCP의 버그로 보이는 문제 때문에 루트 GCP 계정(예: @gmail.com 계정)이나 gcloud를 통해 만든 서비스 계정을 사용하면 다음 섹션에서 설명할 gcr.io로의 Docker 이미지 푸시가 실패한다.

GCP 웹 콘솔의 서비스 계정 생성 화면 스크린샷

GCP 웹 콘솔에서 새 서비스 계정 만들기

서비스 계정 역할 선택 화면 스크린샷

서비스 계정에 역할 할당하기

개인 키를 key.json 파일로 다운로드한다:

서비스 계정 개인 키 다운로드 스크린샷

서비스 계정의 개인 키 다운로드

마지막으로 gcloud를 이용해 방금 만든 서비스 계정으로 인증한다:

gcloud auth activate-service-account --key-file key.json

인증을 마쳤다면 튜토리얼의 나머지 과정을 위해 프로젝트를 준비하는 몇 가지 명령을 실행해야 한다:

# Enable APIs you'll need for this workflow.
gcloud services enable \
  cloudresourcemanager.googleapis.com \
  compute.googleapis.com \
  containerregistry.googleapis.com \
  iam.googleapis.com

# Enable gcloud to provide credentials for Docker image pushes.
gcloud auth configure-docker --quiet

Google Container Registry에 이미지 업로드하기

Docker 이미지를 GCE에 배포하기 전에 Docker 이미지 호스팅 서비스에 게시해야 한다. GCR은 Google Cloud에 통합된 Docker 이미지 호스팅 서비스이므로 가장 간편한 선택지다.

Docker 이미지를 GCR에 업로드하려면 내 예제 저장소의 nginx 브랜치를 체크아웃한다:

cd ~/docker-flask-upload-demo
git checkout nginx

이제 Docker 이미지를 로컬에서 빌드해 GCR에 푸시한다:

LOCAL_IMAGE_NAME="flask-upload-demo-image"
docker build --tag "$LOCAL_IMAGE_NAME" .

GCR_HOSTNAME="gcr.io"
GCR_IMAGE_PATH="${GCR_HOSTNAME}/${PROJECT_ID}/flask-demo-app"
docker tag "$LOCAL_IMAGE_NAME" "$GCR_IMAGE_PATH"
docker push "$GCR_IMAGE_PATH"

Docker 컨테이너 배포하기

컨테이너를 배포하려면 한 단계를 거쳐야 한다. GCP에서는 Docker 이미지를 직접 배포할 수 없다. 대신 GCE로 완전한 가상 머신(VM)을 띄우고 그 VM에서 Docker를 실행한 뒤, GCE가 그 VM 안의 컨테이너에서 Docker 이미지를 실행하는 방식이다. 다행히 GCP 도구가 이 과정을 단순화해 준다.

GCE VM은 기본적으로 인바운드 HTTP 트래픽을 차단한다. 이를 허용하려면 포트 80(평문 HTTP 트래픽의 표준 포트)에서 TCP 연결을 허용하는 방화벽 규칙을 만들어야 한다.

간단한 방법은 http-server 태그가 있는 모든 VM에 이 규칙을 적용하는 것이다:

VM_TAGS="http-server"
gcloud compute \
  --project="$PROJECT_ID" \
  firewall-rules create default-allow-http \
  --direction=INGRESS \
  --action=ALLOW \
  --rules=tcp:80 \
  --target-tags="$VM_TAGS"

그런 다음 http-server 태그를 이용해 GCE VM을 배포한다:

VM_NAME="flask-demo-app-vm"
MACHINE_TYPE="n1-standard-1"
ZONE=us-east1-b
gcloud compute \
  --project="$PROJECT_ID" \
  instances create-with-container "$VM_NAME" \
  --zone="$ZONE" \
  --machine-type="$MACHINE_TYPE" \
  --network-tier=STANDARD \
  --metadata=google-logging-enabled=true \
  --maintenance-policy=MIGRATE \
  --scopes=https://www.googleapis.com/auth/cloud-platform \
  --tags="$VM_TAGS" \
  --image-family=cos-stable \
  --image-project=cos-cloud \
  --boot-disk-size=10GB \
  --boot-disk-type=pd-standard \
  --boot-disk-device-name="$VM_NAME" \
  --container-image="$GCR_IMAGE_PATH" \
  --container-restart-policy=on-failure

주요 플래그는 다음과 같다:

  --tags="$VM_TAGS" \

--tags 플래그는 방화벽 규칙을 위해 만든 태그를 이용해 VM을 시작한다. 이 플래그 덕분에 VM이 포트 80에서 HTTP 트래픽을 받을 수 있다.

  --image-family=cos-stable \
  --image-project=cos-cloud \

이 플래그들은 GCE에 Container-Optimized OS에서 컨테이너를 실행하라고 지시한다. Container-Optimized OS는 Google이 Docker 컨테이너 실행을 위해 만든 경량 Linux OS다. --image-family=cos-stablegcloud에 Container-Optimized OS의 최신 안정 버전을 사용하라고 알려준다.

--container-image="$GCR_IMAGE_PATH" \

위 플래그는 앞서 만든 GCR URL을 이용해 GCE VM 안에서 실행할 Docker 이미지가 무엇인지 GCE에 알려준다.

명령이 완료되면 다음과 같은 출력이 보인다:

Created [https://www.googleapis.com/compute/v1/projects/flask-upload-demo-2018-11-26/zones/us-east1-b/instances/flask-demo-app-vm].
NAME               ZONE        MACHINE_TYPE   PREEMPTIBLE  INTERNAL_IP  EXTERNAL_IP     STATUS
flask-demo-app-vm  us-east1-b  n1-standard-1               10.142.0.2   35.211.106.214  RUNNING

브라우저에 EXTERNAL_IP의 주소를 입력하면 로컬 버전과 똑같이 동작한다:

GCE에서 실행 중인 flask-upload-demo 스크린샷

GCE에서 실행 중인 flask-upload-demo

GCE의 flask-upload-demo에 업로드된 파일 스크린샷

GCE에서 이미지 파일을 제공하는 flask-upload-demo

문제는 해당 VM을 종료하고 같은 Docker 이미지로 새 VM을 띄우면 업로드했던 파일이 더 이상 없다는 점이다:

이전에 업로드한 파일에 대한 404 스크린샷

새 GCE VM은 파일을 제공할 수 없다. 파일이 이전 VM에 저장되어 있었기 때문이다

물론 이것이 이 튜토리얼을 만들게 된 근본적인 문제다. 컨테이너는 파일을 내부 파일 시스템에 저장한다. 호스트 VM을 종료하면 모든 파일이 사라진다.

이 문제를 해결하려면 Docker 컨테이너가 모든 영구 데이터를 Google Cloud Storage(GCS) 버킷에 저장하도록 설정해야 한다.

GCS를 고려한 아키텍처 설계하기

이 문제를 해결하는 목표 아키텍처는 다음과 같다:

flask-demo-app 아키텍처 다이어그램

Flask 앱을 Google Cloud Platform에 배포하기 위한 아키텍처

  • 웹 브라우저는 웹 서버인 Nginx하고만 통신하며, Nginx는 모든 프론트엔드 요청을 조율하는 오케스트레이터 역할을 한다.
  • 웹 브라우저가 파일을 요청하면 Nginx는 GCS 버킷을 파일 시스템의 폴더처럼 마운트해 주는 gcsfuse 유틸리티를 통해 GCS에서 파일을 가져온다.
  • 그 외의 모든 요청에 대해서는 Nginx가 flask-upload-demo 앱으로 요청을 전달한다.
  • flask-upload-demo 역시 gcsfuse 유틸리티를 통해 GCS에 새 파일을 쓸 수 있다.

이 아키텍처는 글 서두에서 정의한 목표를 만족한다. GCS가 모든 영구 상태를 저장하므로 VM 안의 모든 것은 버릴 수 있다. 모든 앱 코드는 Docker 컨테이너 안에 있어 배포가 간단하고 원자적으로 이루어진다.

GCS 버킷 만들기(선택 사항)

아직 GCS 버킷이 없다면 다음 gcloud 명령으로 만들 수 있다:

STORAGE_LOCATION="us-east1"
GCS_BUCKET="${PROJECT_ID}-storage"
gsutil mb \
  -p "$PROJECT_ID" \
  -l "$STORAGE_LOCATION" \
  "gs://${GCS_BUCKET}"

이미 버킷이 있다면 GCS_BUCKET 환경 변수를 해당 버킷 이름으로 설정하면 된다.

Docker 컨테이너에 GCS 접근 권한 부여하기

gcsfuse 유틸리티를 통합하려면 Docker 이미지를 수정해야 한다. 다행히 내가 대신 해 두었다. 전체 Dockerfile내 GitHub 저장소의 gcsfuse 브랜치에서 확인할 수 있지만, 주요 변경 내용은 다음과 같다:

# Install gcsfuse.
ARG GCSFUSE_REPO="gcsfuse-stretch"
ARG GCS_MOUNT_ROOT="/mnt/gcsfuse"
RUN set -x && \
    apt-get install --yes --no-install-recommends \
    ca-certificates \
    curl && \
    echo "deb http://packages.cloud.google.com/apt $GCSFUSE_REPO main" \
      | tee /etc/apt/sources.list.d/gcsfuse.list && \
    curl https://packages.cloud.google.com/apt/doc/apt-key.gpg \
      | apt-key add -
RUN apt-get update
RUN set -x && \
    apt-get install --yes gcsfuse && \
    echo 'user_allow_other' > /etc/fuse.conf && \
    mkdir --parents "$GCS_MOUNT_ROOT" && \
    chown \
      --no-dereference \
      "${APP_USER}:${NGINX_GROUP}" "$GCS_MOUNT_ROOT"

여기서 주목할 부분은 두 가지다:

echo 'user_allow_other' > /etc/fuse.conf

위 라인은 gcsfuse의 -o allow_other 옵션을 사용할 수 있게 한다. 앱 시스템 계정과 Nginx 시스템 계정 모두 GCS 폴더에 접근해야 하므로 이 설정이 필요하다. 설정 파일에 user_allow_other 라인이 없으면 하나의 계정만 GCS 폴더에 접근할 수 있다.

주의: 두 개 이상의 시스템 계정이 GCS 버킷에 접근해야 한다면 /etc/fuse.conf 파일에 반드시 user_allow_other 라인이 포함되어야 한다.

mkdir --parents "$GCS_MOUNT_ROOT" && \
chown \
  --no-dereference \
  "${APP_USER}:${NGINX_GROUP}" "$GCS_MOUNT_ROOT"

gcsfuse는 실행 사용자가 쓰기 권한을 가진 기존 디렉터리를 필요로 한다. 일반 사용자는 /mnt 디렉터리에 쓰기 권한이 없으므로, Dockerfile에서는 root 사용자로 /mnt/gcsfuse 디렉터리를 만들고 chown으로 Nginx와 데모 앱 시스템 계정에 소유권을 부여한다.

다른 흥미로운 변경 사항은 컨테이너의 런타임 동작을 정의하는 DockerfileCMD 부분에 있다:

ENV GCS_BUCKET "REPLACE-WITH-YOUR-GCS-BUCKET-NAME"
ENV GCS_MOUNT_ROOT "$GCS_MOUNT_ROOT"
ENV APP_UPLOADS_DIR "${APP_ROOT}/demo/uploads"

CMD set -x && \
    sudo nginx && \
    gcsfuse \
      -o nonempty \
      -o allow_other \
      --implicit-dirs \
      "$GCS_BUCKET" "$GCS_MOUNT_ROOT" && \
    if [ ! -d "$APP_UPLOADS_DIR" ]; then \
      ln --symbolic "$GCS_MOUNT_ROOT" "$APP_UPLOADS_DIR"; \
    fi && \
    virtualenv VIRTUAL && \
    . VIRTUAL/bin/activate && \
    gunicorn \
      demo.app:app \
      --bind 127.0.0.1:5000 \
      --log-level info

이번에도 흥미로운 부분을 나눠서 살펴보자:

gcsfuse \
  -o nonempty \
  -o allow_other \
  --implicit-dirs \
  "$GCS_BUCKET" "$GCS_MOUNT_ROOT"

앞서 설명한 user_allow_other 옵션과 마찬가지로 -o allow_other는 여러 사용자가 마운트된 폴더에 접근할 수 있게 한다. nginx(www-data 사용자로 실행)와 데모 앱(demo-user로 실행) 모두 접근해야 하기 때문이다.

주의: 여러 사용자 계정이 GCS 마운트의 파일에 접근한다면 gcsfuse에 -o allow_other 플래그가 필요하다.

--implicit-dirs 플래그가 없으면 gcsfuse는 GCS 버킷의 하위 폴더에 있는 파일에 접근할 수 없다.

주의: GCS 버킷에 하위 폴더가 있다면 gcsfuse에 --implicit-dirs 플래그가 필요하다.

if [ ! -d "$APP_UPLOADS_DIR" ]; then \
  ln --symbolic "$GCS_MOUNT_ROOT" "$APP_UPLOADS_DIR"; \
fi

앱은 업로드된 파일을 demo/uploads 디렉터리에 쓴다. 위 블록은 demo/uploads에서 /mnt/gcsfuse로 심볼릭 링크를 만든다. 이렇게 하면 앱이 demo/uploads 경로에 쓴다고 생각할 때 실제로는 GCS 버킷에 쓰게 된다. if/then 블록은 컨테이너가 재시작될 때처럼 이 단계가 두 번 이상 실행되는 것을 방지한다.

GCS 접근 권한을 가진 서비스 계정 만들기

이 이미지를 GCE에 배포하기 전에 한 단계가 더 필요하다. 기본적으로 GCE 인스턴스는 표준 GCE 서비스 계정 컨텍스트에서 실행된다. 해당 계정은 GCS에 대해 읽기 전용 권한만 가지고 있어 앱이 GCS에 새 파일을 쓰지 못하고 실패한다.

이를 해결하려면 다음 두 가지 역할을 가진 커스텀 서비스 계정을 만들어야 한다:

  • storage.objectAdmin: VM 내 프로세스가 GCS에 객체를 읽고 쓸 수 있게 한다.
  • logging.logWriter: 앱의 로그 출력이 GCP 로깅 인터페이스에 나타나도록 한다.

주의: 커스텀 서비스 계정으로 실행하지 않으면 GCE 인스턴스는 GCS 버킷에 쓸 수 없다.

다음 명령은 필요한 권한을 가진 서비스 계정을 만든다:

SERVICE_ACCOUNT_NAME=flask-demo-app-service-account
SERVICE_ACCOUNT_EMAIL="${SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"

gcloud iam service-accounts create "$SERVICE_ACCOUNT_NAME"
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member "serviceAccount:${SERVICE_ACCOUNT_EMAIL}" \
  --role roles/storage.objectAdmin
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member "serviceAccount:${SERVICE_ACCOUNT_EMAIL}" \
  --role roles/logging.logWriter

GCS를 인식하는 컨테이너 배포하기

docker-flask-upload-demo 저장소 클론으로 돌아가 gcsfuse 브랜치를 체크아웃한다:

cd ~/docker-flask-upload-demo
git checkout gcsfuse

이제 Docker 이미지를 다시 빌드해 GCR에 푸시한다:

LOCAL_IMAGE_NAME="flask-upload-demo-image"
docker build --tag "$LOCAL_IMAGE_NAME" .

GCR_HOSTNAME="gcr.io"
GCR_IMAGE_PATH="${GCR_HOSTNAME}/${PROJECT_ID}/flask-demo-app"
docker tag "$LOCAL_IMAGE_NAME" "$GCR_IMAGE_PATH"
docker push "$GCR_IMAGE_PATH"

참고: 이 이미지를 로컬에서 실행하려고 하면 실패한다. gcsfuse 버전의 Dockerfile은 실행 환경이 이미 gcloud에 인증되어 있다고 가정하는데, 이는 GCE에서 실행되는 컨테이너에서는 사실이다. GCE 외부에서 실행해도 GCS 버킷을 마운트하도록 Dockerfile을 조정하는 것은 가능하지만, 이는 독자의 몫으로 남겨둔다.

새로운 커스텀 GCE 서비스 계정이 준비되었으니 이제 GCS를 인식하는 컨테이너를 배포할 준비가 되었다:

VM_NAME="flask-demo-app-vm-gcsfuse"
MACHINE_TYPE="n1-standard-1"
ZONE=us-east1-b
gcloud compute \
  --project="$PROJECT_ID" \
  instances create-with-container "$VM_NAME" \
  --zone="$ZONE" \
  --machine-type="$MACHINE_TYPE" \
  --network-tier=STANDARD \
  --metadata=google-logging-enabled=true \
  --maintenance-policy=MIGRATE \
  --scopes=https://www.googleapis.com/auth/cloud-platform \
  --service-account="$SERVICE_ACCOUNT_EMAIL" \
  --tags="$VM_TAGS" \
  --image-family=cos-stable \
  --image-project=cos-cloud \
  --boot-disk-size=10GB \
  --boot-disk-type=pd-standard \
  --boot-disk-device-name="$VM_NAME" \
  --container-image="$GCR_IMAGE_PATH" \
  --container-restart-policy=on-failure \
  --container-privileged \
  --container-env="GCS_BUCKET=$GCS_BUCKET"

이 명령은 이전 배포 명령과 동일하지만 플래그 두 개가 추가되었다:

  --container-privileged \

--container-privileged 플래그는 Docker 컨테이너가 FUSE 파일 시스템을 마운트할 수 있도록 하는 데 필요하다(Docker는 이를 달성하는 더 세분화된 방법을 제공하지만, GCE에서는 아직 지원하지 않는다).

주의: --container-privileged 플래그로 VM을 배포하지 않으면 gcsfuse가 GCE에서 GCS 버킷을 마운트할 수 없다.

  --container-env="GCS_BUCKET=$GCS_BUCKET"

Docker 이미지는 런타임까지 GCS 버킷 이름을 알 필요가 없도록 의도적으로 설계했다. --container-env 플래그를 이용하면 배포 시점에 GCS_BUCKET 환경 변수를 지정할 수 있다.

영속성은 결국… 영속성으로 보답한다

이제 상태를 GCS에 영속화하는 Docker 컨테이너가 생겼다. 배포된 앱에 파일을 업로드해 이를 테스트해 볼 수 있다:

GCS의 영구 저장소에서 파일을 제공하는 flask-upload-demo 스크린샷

GCS의 영구 저장소에서 파일을 제공하는 flask-upload-demo

GCS 버킷을 확인해 보면 방금 업로드한 파일이 보인다:

GCS 버킷에 업로드된 파일이 보이는 스크린샷

GCS 버킷 안의 이미지 파일

진짜 시험대는 이 상태가 서로 다른 VM 간에도 유지되는지 여부다. VM을 완전히 종료하고 다시 배포해 이를 확인할 수 있다. 이전 VM의 이미지 URL이 새 서버에서도 접근 가능하다:

다른 IP에서 파일을 제공하는 flask-upload-demo 스크린샷

VM을 삭제하고 다시 구축한 뒤에도 파일을 계속 제공하는 flask-upload-demo

보너스: 로그 인터페이스

이 솔루션의 또 다른 장점은 GCP가 앱 로그를 볼 수 있는 깔끔한 웹 인터페이스를 제공한다는 점이다.

언제든 VM에 SSH로 접속한 뒤 docker logs를 실행해 로그를 직접 확인할 수 있다:

$ docker logs klt-flask-demo-app-vm-gcsfuse-qnnf
...
[2018-11-26 21:28:54 +0000] [33] [INFO] Starting gunicorn 19.9.0
[2018-11-26 21:28:54 +0000] [33] [INFO] Listening at: http://127.0.0.1:5000 (33)
[2018-11-26 21:28:54 +0000] [33] [INFO] Using worker: sync
[2018-11-26 21:28:54 +0000] [37] [INFO] Booting worker with pid: 37
[2018-11-26 21:32:59 +0000] [37] [INFO] Saving uploaded file "zestful-logo.png" to "/srv/demo-app/demo/uploads/zestful-logo.png"

더 쉬운 방법은 GCP의 StackDriver 로깅 인터페이스를 여는 것이다. 거기서 기능이 풍부한 웹 인터페이스로 모든 로그를 확인할 수 있다:

GCP StackDriver 인터페이스에 표시된 앱 로그

GCP StackDriver 인터페이스에 표시된 앱 로그 출력

새 릴리스 배포하기

이 아키텍처 덕분에 새 릴리스를 푸시하기 쉽다. 앱이나 의존성을 업데이트하고 싶을 때마다 새 이미지를 빌드해 GCR에 푸시하면 된다:

docker build --tag "$LOCAL_IMAGE_NAME" .
docker tag "$LOCAL_IMAGE_NAME" "$GCR_IMAGE_PATH"
docker push "$GCR_IMAGE_PATH"

그런 다음 update-container 명령을 이용해 실행 중인 GCE 인스턴스의 Docker 이미지를 업데이트한다:

gcloud compute \
  --project="$PROJECT_ID" \
  instances update-container "$VM_NAME" \
  --container-image="$GCR_IMAGE_PATH"

주의: 고정 IP를 할당하지 않았다면 이 명령이 완료된 뒤 VM의 외부 IP 주소가 바뀐다.

잘못된 릴리스를 푸시했더라도 update-container 명령으로 이전의 정상 이미지로 롤백할 수 있다.

한계

이 솔루션은 gcsfuse 유틸리티와 GCS 자체가 가진 한계를 그대로 공유한다. gcsfuse는 GCS 버킷을 일반 파일 시스템 폴더처럼 보이게 하려고 최선을 다하지만, 추상화는 두 가지 측면에서 깨진다:

  • GCS는 락을 지원하지 않으므로 앱이 파일 락을 시도하면 문제가 생긴다.
  • 지연 시간이 길며, 특히 대용량 파일에 대해 작고 무작위한 읽기/쓰기를 할 때 심하다.

특히 sqlite의 경우 데이터베이스를 gcsfuse 마운트에 두면 금방 실패한다는 것을 확인했다.

결론

이 튜토리얼에서는 앱 자체를 수정하지 않고 앱의 데이터를 클라우드 스토리지로 리다이렉트하는 방법을 배웠다. 예제 앱은 Google Cloud Platform을 전혀 인식하지 못했지만, Google Compute Engine에 배포하고 영구 데이터를 Google Cloud Storage로 리다이렉트했다.

소스 코드

  • flask-upload-demo: 로컬 파일 시스템에 상태를 유지하는 Flask 예제 앱.
  • docker-flask-upload-demo: flask-upload-demo를 위한 Docker 설정, 세 가지 버전으로 제공된다:
    • master 브랜치 - 앱의 기본 패키징을 보여준다
    • nginx 브랜치 - nginx가 앱의 트래픽을 프록시하는 좀 더 현실적인 아키텍처를 보여준다
    • gcsfuse 브랜치 - Docker 컨테이너 내부에서 Google Cloud Storage 버킷을 마운트하는 방법을 보여준다(컨테이너가 Google Cloud Storage에 대해 읽기/쓰기 권한을 가진 Google Compute Engine VM에서 실행된다고 가정한다).
  • mediagoblin-docker (gcsfuse 브랜치) - 동일한 기법을 사용해 앱의 영구 데이터를 Google Cloud Storage로 리다이렉트한 실제 미디어 공유 앱용 Docker 설정.

소프트웨어 아키텍처 다이어그램: Loraine Yow

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글