Retrofitting Apps for Cloud Storage with Zero Code Changes

Michael Lynch

不改一行程式碼,為應用程式加裝雲端儲存

原文由 Michael Lynch 發布,訂閱此部落格

我最近在一台伺服器上安裝了一套媒體分享應用程式。安裝很簡單,但它卻為長期的維護埋下了一個棘手的陷阱。

使用者每次上傳檔案時,這個網頁應用程式都會把它存到本地檔案系統。如果哪天我把伺服器整個砍掉重建,就得手動備份並還原所有檔案。更好的架構是讓應用程式把檔案寫到獨立的儲存伺服器上,但我可不想為此花上好幾個月重寫程式。

簡易架構 vs 理想架構

透過 Docker、Google Cloud Storage 和 gcsfuse 這個工具,我在完全沒有修改應用程式任何一行程式碼的情況下,實現了這種分離。

在這篇教學中,我會示範如何在盡量不更動原始碼的前提下,為舊有應用程式 retrofit,使其支援 Google Cloud Storage。

專有名詞定義

如果你對 Google Cloud Platform 還不熟悉,以下是這篇教學會用到的幾個縮寫:

縮寫全名是什麼?類似於
GCSGoogle Cloud StorageGoogle 的雲端儲存服務Amazon S3
GCEGoogle Compute EngineGoogle 的隨需虛擬機器服務Amazon EC2
GCRGoogle Container RegistryGoogle 的 Docker 映像檔託管服務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] 這個網址永久提供該檔案:

範例應用程式上傳結果截圖

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 化

因為這個應用程式只有幾個簡單的相依套件,要為它建立 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. 加入 CMD 指令,在 port 5000 上啟動範例應用程式

你可以透過複製我的儲存庫並在本地建置 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 儲存庫中加入了nginx 分支,用來展示一個更接近真實世界應用情境的 Docker 映像檔。相關的變更如下:

# 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 通常以 root 身分執行,才能監聽 80 和 443 這類需要特權的 HTTP 連接埠。因此,Dockerfile 使用 sudo 讓範例應用程式的使用者能以 root 身分啟動 Nginx,同時其他所有操作仍維持在受限權限下執行。

最後,它會將 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 { ... } 這個區塊讓 Nginx 能直接從 /uploads 資料夾提供檔案,而不必將請求轉發給 flask-upload-demo 後端。這是很常見的設定,因為 Nginx 處理靜態檔案的速度比應用程式後端更快、更有效率。

你可以用以下指令來執行 Nginx 版本的 flask-upload-demo:

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 建立的服務帳號,將 Docker 映像檔推送到 gcr.io(見下一節)時會失敗。

服務帳號建立畫面截圖

從 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 流量。要允許流量,你需要建立一條防火牆規則,接受 port 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。這個參數確保它能接收 port 80 上的 HTTP 流量。

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

這些參數會告訴 GCE 在Container-Optimized OS 之下執行容器,這是 Google 為執行 Docker 容器而打造的精簡版 Linux 作業系統。--image-family=cos-stable 則是告訴 gcloud 使用最新穩定版的 Container-Optimized OS。

--container-image="$GCR_IMAGE_PATH" \

上面的參數則是告訴 GCE 要在 GCE VM 中執行哪個 Docker 映像檔,使用的是你稍早建立的 GCR 網址。

當指令執行完成後,你會看到類似以下的輸出:

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 的檔案截圖

flask-upload-demo 從 GCE 提供圖片檔案

問題在於,如果你終止那台 VM 並用同樣的 Docker 映像檔啟動一台新的 VM,你上傳的檔案就不見了:

先前上傳檔案的 404 錯誤截圖

新的 GCE VM 無法提供該檔案,因為檔案儲存在上一台 VM 上

當然,這正是催生整篇教學的問題所在。容器將檔案儲存在其內部的檔案系統中。當你終止宿主 VM 時,所有檔案都會遺失。

為了解決這個問題,你需要設定 Docker 容器,將所有需要持久保存的資料都儲存到 Google Cloud Storage(GCS)儲存桶中。

規劃支援 GCS 的架構

以下是解決這個問題的目標架構:

flask-demo-app 架構圖

在 Google Cloud Platform 上部署 Flask 應用程式的架構

  • 網頁瀏覽器只與網頁伺服器 Nginx 溝通,由 Nginx 作為所有前端請求的協調者。
  • 如果網頁瀏覽器請求的是檔案,Nginx 會透過名為gcsfuse 的工具從 GCS 取得檔案,該工具會將 GCS 儲存桶掛載為檔案系統上的資料夾。
  • 對於其他所有請求,Nginx 會將請求轉發給 flask-upload-demo 應用程式。
  • flask-upload-demo 也能透過 gcsfuse 工具將新檔案寫入 GCS。

這個架構滿足了我在文章開頭定義的目標。VM 中的所有東西都是可拋棄的,因為 GCS 儲存了所有需要永久保存的狀態。所有應用程式碼都在 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 環境變數設定為現有 GCS 儲存桶的名稱。

讓 Docker 容器存取 GCS

你需要修改 Docker 映像檔以整合 gcsfuse 工具。幸好,我已經幫你做好了。完整的 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 目錄沒有寫入權限,因此 Dockerfileroot 使用者身分建立 /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 上執行的容器來說是成立的)。雖然可以調整 Dockerfile,讓它在 GCE 之外也能掛載 GCS 儲存桶,但這就留給讀者自行練習了。

有了新的自訂 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 容器。你可以透過上傳檔案到已部署的應用程式來測試:

flask-upload-demo 提供檔案的截圖

flask-upload-demo 從 GCS 上的永久儲存空間提供檔案

如果你查看 GCS 儲存桶,就會看到你剛上傳的檔案:

顯示已上傳檔案的 GCS 儲存桶截圖

GCS 儲存桶中的圖片檔案

真正的考驗在於,這個狀態是否能在不同的 VM 之間持續存在。你可以透過完全終止 VM 並重新部署來驗證。來自上一台 VM 的圖片網址,在新的伺服器上依然可以存取:

flask-upload-demo 從不同 IP 提供檔案的截圖

即使 VM 已被摧毀並重建,flask-upload-demo 仍持續提供該檔案

番外篇:記錄介面

這個解決方案有個不錯的附加好處,就是 GCP 提供了一個精美的網頁介面來檢視應用程式的記錄。

你隨時可以透過 SSH 連進 VM,再執行 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 分支) - 一個真實世界的媒體分享應用程式的 Docker 設定,我在其中使用了相同的技巧,將應用程式的永久資料重新導向到 Google Cloud Storage。

軟體架構圖由 Loraine Yow 繪製

本文章由 muse-spark-1.2-contributor 進行翻譯

留言