Cloud Run 教學課程:使用者驗證


本教學課程說明如何建立投票服務,包括:

  • 以瀏覽器為基礎的用戶端,可執行下列操作:

    1. 使用 Identity Platform 擷取 ID 權杖。
    2. 允許使用者投票選出最喜歡的家畜。
    3. 將該 ID 權杖新增至 Cloud Run 伺服器的要求,以處理投票。
  • Cloud Run 伺服器,具備下列功能:

    1. 檢查以確保使用者已提供有效 ID 權杖,並完成驗證。
    2. 處理使用者的投票。
    3. 使用自己的憑證,將投票結果傳送至 Cloud SQL 儲存。
  • 儲存票數的 PostgreSQL 資料庫。

為求簡單起見,本教學課程使用 Google 做為供應商:使用者必須使用 Google 帳戶進行驗證,才能取得 ID 權杖。不過,您可以使用其他供應商或驗證方法登入使用者

這項服務會使用 Secret Manager 保護用於連線至 Cloud SQL 執行個體的機密資料,盡量降低安全風險。此外,它還會使用最低權限的服務身分,確保資料庫存取安全。

目標

撰寫、建構服務,並將服務部署到 Cloud Run,瞭解如何:

  • 使用 Identity Platform 向 Cloud Run 服務後端驗證使用者。

  • 為服務建立最低權限的身分,授予 Google Cloud 資源的最低存取權。

  • 將 Cloud Run 服務連線至 PostgreSQL 資料庫時,請使用 Secret Manager 處理機密資料。

費用

在本文件中,您會使用 Google Cloud的下列計費元件:

如要根據預測用量估算費用,請使用 Pricing Calculator

初次使用 Google Cloud 的使用者可能符合免費試用資格。

事前準備

  1. Sign in to your Google Cloud account. If you're new to Google Cloud, create an account to evaluate how our products perform in real-world scenarios. New customers also get $300 in free credits to run, test, and deploy workloads.
  2. In the Google Cloud console, on the project selector page, select or create a Google Cloud project.

    Go to project selector

  3. Make sure that billing is enabled for your Google Cloud project.

  4. In the Google Cloud console, on the project selector page, select or create a Google Cloud project.

    Go to project selector

  5. Make sure that billing is enabled for your Google Cloud project.

  6. Enable the Cloud Run, Secret Manager, Cloud SQL, Artifact Registry, and Cloud Build APIs.

    Enable the APIs

  7. 必要的角色

    如要取得完成本教學課程所需的權限,請要求管理員為您授予專案的下列 IAM 角色:

    如要進一步瞭解如何授予角色,請參閱「管理專案、資料夾和機構的存取權」。

    您或許還可透過自訂角色或其他預先定義的角色取得必要權限。

設定 gcloud 預設值

如要針對 Cloud Run 服務設定 gcloud 的預設值:

  1. 設定您的預設專案:

    gcloud config set project PROJECT_ID

    PROJECT_ID 改為您為本教學課程建立的專案名稱。

  2. 為所選區域設定 gcloud:

    gcloud config set run/region REGION

    REGION 改為您所選擇的支援 Cloud Run 地區

Cloud Run 位置

Cloud Run 具有「地區性」,這表示執行 Cloud Run 服務的基礎架構位於特定地區,並由 Google 代管,可為該地區內所有區域提供備援功能。

選擇 Cloud Run 服務的執行地區時,請將延遲時間、可用性或耐用性需求做為主要考量。一般而言,您可以選擇最靠近使用者的地區,但您應考量 Cloud Run 服務所使用的其他 Google Cloud 產品位置。使用分散在不同位置的 Google Cloud 產品,可能會影響服務的延遲時間和費用。

Cloud Run 可在下列地區使用:

採用級別 1 定價

採用級別 2 定價

如果您已建立 Cloud Run 服務,即可在 Google Cloud 控制台的 Cloud Run 資訊主頁中查看地區。

擷取程式碼範例

如要擷取要使用的程式碼範例:

  1. 將應用程式存放區範例複製到本機電腦中:

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    您也可以 下載 zip 格式的範例,然後解壓縮該檔案。

    Python

    git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git

    您也可以 下載 zip 格式的範例,然後解壓縮該檔案。

    Java

    git clone https://github.com/GoogleCloudPlatform/java-docs-samples.git

    您也可以 下載 zip 格式的範例,然後解壓縮該檔案。

  2. 變更為包含 Cloud Run 程式碼範例的目錄:

    Node.js

    cd nodejs-docs-samples/run/idp-sql/

    Python

    cd python-docs-samples/run/idp-sql/

    Java

    cd java-docs-samples/run/idp-sql/

將架構視覺化

架構圖
圖表:使用者透過 Identity Platform 提供的 Google 登入對話方塊登入, 然後系統會將使用者重新導向 Cloud Run,並提供使用者身分。
  1. 使用者向 Cloud Run 伺服器發出第一個要求。

  2. 用戶端會在瀏覽器中載入。

  3. 使用者透過 Identity Platform 的 Google 登入對話方塊提供登入憑證。系統會顯示快訊,歡迎已登入的使用者。

  4. 控制項會重新導向回伺服器。使用者透過用戶端投票,用戶端會從 Identity Platform 擷取 ID 權杖,並將其新增至投票要求標頭。

  5. 伺服器收到要求後,會驗證 Identity Platform ID 權杖,確認使用者已通過適當的驗證。然後伺服器會使用自己的憑證,將投票結果傳送至 Cloud SQL。

瞭解核心程式碼

如後所述,這個範例會實作用戶端和伺服器。

與 Identity Platform 整合:用戶端程式碼

這個範例使用 Firebase SDK 與 Identity Platform 整合,以便登入及管理使用者。如要連線至 Identity Platform,用戶端 JavaScript 會將專案憑證的參照保留為設定物件,並匯入必要的 Firebase JavaScript SDK

const config = {
  apiKey: 'API_KEY',
  authDomain: 'PROJECT_ID.firebaseapp.com',
};
<!-- Firebase App (the core Firebase SDK) is always required and must be listed first-->
<script src="https://www.gstatic.com/firebasejs/7.18/firebase-app.js"></script>
<!-- Add Firebase Auth service-->
<script src="https://www.gstatic.com/firebasejs/7.18/firebase-auth.js"></script>

Firebase JavaScript SDK 會透過彈出式視窗提示使用者登入 Google 帳戶,藉此處理登入流程。然後將他們重新導向回服務。

function signIn() {
  const provider = new firebase.auth.GoogleAuthProvider();
  provider.addScope('https://www.googleapis.com/auth/userinfo.email');
  firebase
    .auth()
    .signInWithPopup(provider)
    .then(result => {
      // Returns the signed in user along with the provider's credential
      console.log(`${result.user.displayName} logged in.`);
      window.alert(`Welcome ${result.user.displayName}!`);
    })
    .catch(err => {
      console.log(`Error during sign in: ${err.message}`);
      window.alert('Sign in failed. Retry or check your browser logs.');
    });
}

使用者成功登入後,用戶端會使用 Firebase 方法產生 ID 權杖。用戶端會將 ID 符記加入向伺服器提出的要求標頭 Authorization

async function vote(team) {
  if (firebase.auth().currentUser) {
    // Retrieve JWT to identify the user to the Identity Platform service.
    // Returns the current token if it has not expired. Otherwise, this will
    // refresh the token and return a new one.
    try {
      const token = await firebase.auth().currentUser.getIdToken();
      const response = await fetch('/', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Bearer ${token}`,
        },
        body: 'team=' + team, // send application data (vote)
      });
      if (response.ok) {
        const text = await response.text();
        window.alert(text);
        window.location.reload();
      }
    } catch (err) {
      console.log(`Error when submitting vote: ${err}`);
      window.alert('Something went wrong... Please try again!');
    }
  } else {
    window.alert('User not signed in.');
  }
}

與 Identity Platform 整合:伺服器端程式碼

伺服器會使用 Firebase Admin SDK 驗證從用戶端傳送的使用者 ID 符記。如果提供的 ID 權杖格式正確、未過期且已正確簽署,這個方法會傳回已解碼的 ID 權杖。伺服器會擷取該使用者的 Identity Platform uid

Node.js

const firebase = require('firebase-admin');
// Initialize Firebase Admin SDK
firebase.initializeApp();

// Extract and verify Id Token from header
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (authHeader) {
    const token = authHeader.split(' ')[1];
    // If the provided ID token has the correct format, is not expired, and is
    // properly signed, the method returns the decoded ID token
    firebase
      .auth()
      .verifyIdToken(token)
      .then(decodedToken => {
        const uid = decodedToken.uid;
        req.uid = uid;
        next();
      })
      .catch(err => {
        req.logger.error(`Error with authentication: ${err}`);
        return res.sendStatus(403);
      });
  } else {
    return res.sendStatus(401);
  }
};

Python

def jwt_authenticated(func: Callable[..., int]) -> Callable[..., int]:
    """Use the Firebase Admin SDK to parse Authorization header to verify the
    user ID token.

    The server extracts the Identity Platform uid for that user.
    """

    @wraps(func)
    def decorated_function(*args: a, **kwargs: a) -> a:
        header = request.headers.get("Authorization", None)
        if header:
            token = header.split(" ")[1]
            try:
                decoded_token = firebase_admin.auth.verify_id_token(token)
            except Exception as e:
                logger.exception(e)
                return Response(status=403, response=f"Error with authentication: {e}")
        else:
            return Response(status=401)

        request.uid = decoded_token["uid"]
        return func(*args, **kwargs)

    return decorated_function

Java

/** Extract and verify Id Token from header */
private String authenticateJwt(Map<String, String> headers) {
  String authHeader =
      (headers.get("authorization") != null)
          ? headers.get("authorization")
          : headers.get("Authorization");
  if (authHeader != null) {
    String idToken = authHeader.split(" ")[1];
    // If the provided ID token has the correct format, is not expired, and is
    // properly signed, the method returns the decoded ID token
    try {
      FirebaseToken decodedToken = FirebaseAuth.getInstance().verifyIdToken(idToken);
      String uid = decodedToken.getUid();
      return uid;
    } catch (FirebaseAuthException e) {
      logger.error("Error with authentication: " + e.toString());
      throw new ResponseStatusException(HttpStatus.FORBIDDEN, "", e);
    }
  } else {
    logger.error("Error no authorization header");
    throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
  }
}

將伺服器連線至 Cloud SQL

伺服器會使用 /cloudsql/CLOUD_SQL_CONNECTION_NAME 格式,連線至 Cloud SQL 執行個體 Unix 網域通訊端。

Node.js

/**
 * Connect to the Cloud SQL instance through UNIX Sockets
 *
 * @param {object} credConfig The Cloud SQL connection configuration from Secret Manager
 * @returns {object} Knex's PostgreSQL client
 */
const connectWithUnixSockets = async credConfig => {
  const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql';
  // Establish a connection to the database
  return Knex({
    client: 'pg',
    connection: {
      user: credConfig.DB_USER, // e.g. 'my-user'
      password: credConfig.DB_PASSWORD, // e.g. 'my-user-password'
      database: credConfig.DB_NAME, // e.g. 'my-database'
      host: `${dbSocketPath}/${credConfig.CLOUD_SQL_CONNECTION_NAME}`,
    },
    ...config,
  });
};

Python

def init_unix_connection_engine(
    db_config: dict[str, int]
) -> sqlalchemy.engine.base.Engine:
    """Initializes a Unix socket connection pool for a Cloud SQL instance of PostgreSQL.

    Args:
        db_config: a dictionary with connection pool config

    Returns:
        A SQLAlchemy Engine instance.
    """
    creds = credentials.get_cred_config()
    db_user = creds["DB_USER"]
    db_pass = creds["DB_PASSWORD"]
    db_name = creds["DB_NAME"]
    db_socket_dir = creds.get("DB_SOCKET_DIR", "/cloudsql")
    cloud_sql_connection_name = creds["CLOUD_SQL_CONNECTION_NAME"]

    pool = sqlalchemy.create_engine(
        # Equivalent URL:
        # postgres+pg8000://<db_user>:<db_pass>@/<db_name>
        #                         ?unix_sock=<socket_path>/<cloud_sql_instance_name>/.s.PGSQL.5432
        sqlalchemy.engine.url.URL.create(
            drivername="postgresql+pg8000",
            username=db_user,  # e.g. "my-database-user"
            password=db_pass,  # e.g. "my-database-password"
            database=db_name,  # e.g. "my-database-name"
            query={
                "unix_sock": f"{db_socket_dir}/{cloud_sql_connection_name}/.s.PGSQL.5432"
                # e.g. "/cloudsql", "<PROJECT-NAME>:<INSTANCE-REGION>:<INSTANCE-NAME>"
            },
        ),
        **db_config,
    )
    pool.dialect.description_encoding = None
    logger.info("Database engine initialized from unix connection")

    return pool

Java

使用 Spring Cloud Google Cloud PostgreSQL 啟動器整合功能,透過 Spring JDBC 程式庫與 Cloud SQL 中的 PostgreSQL 資料庫互動。將 MySQL 適用的 Cloud SQL 設定為自動設定 DataSource bean,搭配 Spring JDBC 即可提供 JdbcTemplate 物件 bean,方便您執行查詢及修改資料庫等作業。

# Uncomment and add env vars for local development
# spring.datasource.username=${DB_USER}
# spring.datasource.password=${DB_PASSWORD}
# spring.cloud.gcp.sql.database-name=${DB_NAME}
# spring.cloud.gcp.sql.instance-connection-name=${CLOUD_SQL_CONNECTION_NAME}  
private final JdbcTemplate jdbcTemplate;

public VoteController(JdbcTemplate jdbcTemplate) {
  this.jdbcTemplate = jdbcTemplate;
}

使用 Secret Manager 處理機密設定

Secret Manager 可集中儲存 Cloud SQL 設定等機密資料,並確保資料安全。伺服器會在執行階段透過環境變數,從 Secret Manager 插入 Cloud SQL 憑證。進一步瞭解如何搭配 Cloud Run使用密鑰

Node.js

// CLOUD_SQL_CREDENTIALS_SECRET is the resource ID of the secret, passed in by environment variable.
// Format: projects/PROJECT_ID/secrets/SECRET_ID/versions/VERSION
const {CLOUD_SQL_CREDENTIALS_SECRET} = process.env;
if (CLOUD_SQL_CREDENTIALS_SECRET) {
  try {
    // Parse the secret that has been added as a JSON string
    // to retrieve database credentials
    return JSON.parse(CLOUD_SQL_CREDENTIALS_SECRET.toString('utf8'));
  } catch (err) {
    throw Error(
      `Unable to parse secret from Secret Manager. Make sure that the secret is JSON formatted: ${err}`
    );
  }
}

Python

def get_cred_config() -> dict[str, str]:
    """Retrieve Cloud SQL credentials stored in Secret Manager
    or default to environment variables.

    Returns:
        A dictionary with Cloud SQL credential values
    """
    secret = os.environ.get("CLOUD_SQL_CREDENTIALS_SECRET")
    if secret:
        return json.loads(secret)

Java

/** Retrieve config from Secret Manager */
public static HashMap<String, Object> getConfig() {
  String secret = System.getenv("CLOUD_SQL_CREDENTIALS_SECRET");
  if (secret == null) {
    throw new IllegalStateException("\"CLOUD_SQL_CREDENTIALS_SECRET\" is required.");
  }
  try {
    HashMap<String, Object> config = new Gson().fromJson(secret, HashMap.class);
    return config;
  } catch (JsonSyntaxException e) {
    logger.error(
        "Unable to parse secret from Secret Manager. Make sure that it is JSON formatted: "
            + e);
    throw new RuntimeException(
        "Unable to parse secret from Secret Manager. Make sure that it is JSON formatted.");
  }
}

設定 Identity Platform

您必須在 Google Cloud 控制台中手動設定 Identity Platform。

  1. 在 Google Cloud 控制台啟用 Identity Platform API:

    啟用 API

  2. 設定專案:

    1. 在新視窗中前往 Google Auth Platform >「Overview」(總覽) 頁面。

      前往總覽頁面

    2. 按一下「開始使用」,然後按照專案設定精靈操作。

    3. 在「應用程式資訊」對話方塊中:

      1. 提供應用程式名稱。
      2. 選取其中一個顯示的使用者支援電子郵件地址。
    4. 在「目標對象」對話方塊中,選取「外部」

    5. 在「聯絡資訊」對話方塊中,輸入聯絡人電子郵件地址。

    6. 同意使用者資料政策,然後按一下「建立」

  3. 建立及取得 OAuth 用戶端 ID 和密鑰:

    1. 在 Google Cloud 控制台中,前往「API 和服務」>「憑證」頁面。

      前往「憑證」

    2. 按一下頁面頂端的「建立憑證」,然後選取 OAuth client ID

    3. 在「應用程式類型」中選取「網頁應用程式」,並提供名稱。

    4. 按一下 [建立]。

    5. client_idclient_secret 值將用於下一個步驟。

  4. 將 Google 設定為提供者:

    1. 前往 Google Cloud 控制台的「Identity Providers」頁面。

      前往「Identity Providers」(識別資訊提供者)

    2. 按一下「Add A Provider」

    3. 從清單中選取「Google」

    4. 在 Web SDK 設定中,輸入上一個步驟中的 client_idclient_secret 值。

    5. 在「設定應用程式」下方,按一下「設定詳細資料」

  5. 將設定複製到應用程式:

    • apiKeyauthDomain 值複製到範例的 static/config.js 中,初始化 Identity Platform 用戶端 SDK。

部署服務

按照下列步驟完成基礎架構佈建和部署作業:

  1. 使用控制台或 CLI 建立 PostgreSQL 資料庫的 Cloud SQL 執行個體:

    gcloud sql instances create CLOUD_SQL_INSTANCE_NAME \
        --database-version=POSTGRES_16 \
        --region=CLOUD_SQL_REGION \
        --cpu=2 \
        --memory=7680MB \
        --root-password=DB_PASSWORD
  2. 將 Cloud SQL 憑證值新增至 postgres-secrets.json

    Node.js

    {
      "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE",
      "DB_NAME": "postgres",
      "DB_USER": "postgres",
      "DB_PASSWORD": "PASSWORD_SECRET"
    }
    

    Python

    {
      "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE",
      "DB_NAME": "postgres",
      "DB_USER": "postgres",
      "DB_PASSWORD": "PASSWORD_SECRET"
    }
    

    Java

    {
      "spring.cloud.gcp.sql.instance-connection-name": "PROJECT_ID:REGION:INSTANCE",
      "spring.cloud.gcp.sql.database-name": "postgres",
      "spring.datasource.username": "postgres",
      "spring.datasource.password": "PASSWORD_SECRET"
    }

  3. 使用控制台或 CLI 建立具版本控制的密鑰:

    gcloud secrets create idp-sql-secrets \
        --replication-policy="automatic" \
        --data-file=postgres-secrets.json
  4. 使用控制台或 CLI 為伺服器建立服務帳戶:

    gcloud iam service-accounts create idp-sql-identity
  5. 使用控制台或 CLI 授予 Secret Manager 和 Cloud SQL 存取權的角色:

    1. 允許與伺服器相關聯的服務帳戶存取所建立的密鑰:

      gcloud secrets add-iam-policy-binding idp-sql-secrets \
        --member serviceAccount:idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/secretmanager.secretAccessor
    2. 允許與伺服器相關聯的服務帳戶存取 Cloud SQL:

      gcloud projects add-iam-policy-binding PROJECT_ID \
        --member serviceAccount:idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/cloudsql.client
  6. 建立 Artifact Registry:

    gcloud artifacts repositories create REPOSITORY \
        --repository-format docker \
        --location REGION
    • REPOSITORY 是存放區的名稱。專案中每個存放區位置的存放區名稱不得重複。
  7. 使用 Cloud Build 建構容器映像檔:

    Node.js

    gcloud builds submit --tag REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql

    Python

    gcloud builds submit --tag REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql

    Java

    本範例使用 Jib,透過常見的 Java 工具建構 Docker 映像檔。Jib 可最佳化容器建構作業,不需要 Dockerfile,也不必安裝 Docker。進一步瞭解如何使用 Jib 建構 Java 容器

    1. 使用 gcloud 憑證輔助程式授權 Docker 推送至 Artifact Registry。

      gcloud auth configure-docker

    2. 使用 Jib Maven 外掛程式建構容器,並推送至 Artifact Registry。

      mvn compile jib:build -Dimage=REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql

  8. 使用控制台或 CLI,將容器映像檔部署至 Cloud Run。請注意,伺服器部署完成後,即可允許未經驗證的存取要求。 這樣使用者就能載入用戶端並開始程序。伺服器會手動驗證新增至投票要求的 ID 權杖,驗證使用者身分。

    gcloud run deploy idp-sql \
        --image REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql \
        --allow-unauthenticated \
        --service-account idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --add-cloudsql-instances PROJECT_ID:REGION:CLOUD_SQL_INSTANCE_NAME \
        --update-secrets CLOUD_SQL_CREDENTIALS_SECRET=idp-sql-secrets:latest

    請注意 --service-account--add-cloudsql-instances--update-secrets 旗標,這些旗標分別指定服務身分、Cloud SQL 執行個體連線,以及以環境變數形式提供的密鑰名稱和版本。

最後修飾

Identity Platform 要求您授權 Cloud Run 服務網址,允許使用者登入後重新導向至該網址:

  1. 在「身分識別提供者」頁面中,按一下筆圖示,編輯 Google 提供者。

  2. 在右側面板的「授權網域」下方,按一下「新增網域」,然後輸入 Cloud Run 服務網址。

    您可以在建構或部署作業完成後,於記錄中找到服務網址,也可以隨時使用下列指令尋找:

    gcloud run services describe idp-sql --format 'value(status.url)'
  3. 前往「API 和服務」>「憑證」頁面

    1. 按一下 OAuth 用戶端 ID 旁的鉛筆圖示進行編輯,然後按一下「新增 URI」Authorized redirect URIs click the按鈕。

    2. 在欄位中複製並貼上下列網址,然後按一下頁面底部的「儲存」按鈕。

    https://PROJECT_ID.firebaseapp.com/__/auth/handler

立即體驗

如要試用完整服務:

  1. 在瀏覽器中前往上述部署步驟提供的網址。

  2. 按一下「使用 Google 帳戶登入」按鈕,然後完成驗證流程。

  3. 快來投票!

    內容應該會類似這樣:

    使用者介面會顯示各隊的票數和投票清單。

如果您選擇繼續開發這些服務,請注意,這些服務對其餘服務的存取權受到身分與存取權管理 (IAM) 限制,且需要額外指派 IAM 角色,才能存取許多其他服務。 Google Cloud

清除所用資源

如果您是為了這個教學課程建立新專案,請刪除專案。如果您使用現有專案,並想保留專案,但不要在本教學課程中新增的變更,請刪除為本教學課程建立的資源

刪除專案

如要避免付費,最簡單的方法就是刪除您為了本教學課程所建立的專案。

如要刪除專案:

  1. In the Google Cloud console, go to the Manage resources page.

    Go to Manage resources

  2. In the project list, select the project that you want to delete, and then click Delete.
  3. In the dialog, type the project ID, and then click Shut down to delete the project.

刪除教學課程資源

  1. 刪除您在本教學課程中部署的 Cloud Run 服務:

    gcloud run services delete SERVICE-NAME

    其中 SERVICE-NAME 是您選擇的服務名稱。

    您也可以從Google Cloud 控制台刪除 Cloud Run 服務。

  2. 移除您在教學課程設定期間新增的 gcloud 預設區域設定:

     gcloud config unset run/region
    
  3. 移除專案設定:

     gcloud config unset project
    
  4. 刪除在本教學課程中建立的其他 Google Cloud 資源:

後續步驟