教程:保护 Cloud Run 服务安全

本教程介绍如何创建一个在 Cloud Run 上运行且包含两个服务的安全应用。此应用是一个 Markdown 编辑器,它包含一个任何人都可以用来编写 Markdown 文本的公共“前端”服务和一个将 Markdown 文本渲染成 HTML 的专用“后端”服务。

该图表显示从前端“编辑器”到后端“渲染程序”的请求流程。
“渲染程序”后端是一项专用服务。它无需跟踪多种语言库中的更改,便可以保证组织中的文本转换标准。

后端服务使用 Cloud Run 内置且基于 IAM 的服务到服务身份验证功能,是一项用来限制谁可以调用服务的专用服务。这两项服务均采用最小权限原则构建,只有在必要情况下才能访问 Google Cloud 的其余部分。

本教程的限制或非目标

目标

  • 创建一个专用服务帐号,将对服务到服务身份验证和 Google Cloud 其余部分的服务访问权限设置为最小。
  • 编写、构建两个交互的服务并将其部署到 Cloud Run。
  • 在公共 Cloud Run 服务和专用 Cloud Run 服务之间发出请求。

费用

本教程使用 Google Cloud 的以下收费组件:

请使用价格计算器根据您的预计用量来估算费用。

Google Cloud 新用户可能有资格申请免费试用

准备工作

  1. 登录您的 Google Cloud 帐号。如果您是 Google Cloud 新手,请创建一个帐号来评估我们的产品在实际场景中的表现。新客户还可获享 $300 赠金,用于运行、测试和部署工作负载。
  2. 在 Google Cloud Console 的项目选择器页面上,选择或创建一个 Google Cloud 项目。

    转到“项目选择器”

  3. 确保您的 Cloud 项目已启用结算功能。 了解如何确认您的项目是否已启用结算功能

  4. 在 Google Cloud Console 的项目选择器页面上,选择或创建一个 Google Cloud 项目。

    转到“项目选择器”

  5. 确保您的 Cloud 项目已启用结算功能。 了解如何确认您的项目是否已启用结算功能

  6. 启用 Cloud Run API。

    启用 API

  7. 安装并初始化 Cloud SDK。
  8. 安装 curl 以试用该服务

设置 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 价格

  • asia-east2(香港)
  • asia-northeast3(韩国首尔)
  • asia-southeast1(新加坡)
  • asia-southeast2 (雅加达)
  • asia-south1(印度孟买)
  • asia-south2(印度德里)
  • australia-southeast1(悉尼)
  • australia-southeast2(墨尔本)
  • europe-central2(波兰,华沙)
  • europe-west2(英国伦敦)
  • europe-west3(德国法兰克福)
  • europe-west6(瑞士苏黎世) 叶形图标 二氧化碳排放量低
  • northamerica-northeast1(蒙特利尔) 叶形图标 二氧化碳排放量低
  • northamerica-northeast2(多伦多)
  • southamerica-east1(巴西圣保罗) 叶形图标 二氧化碳排放量低
  • us-west2(洛杉矶)
  • us-west3(盐湖城)
  • us-west4(拉斯维加斯)

如果您已创建 Cloud Run 服务,则可以在 Cloud Console 的 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 文件并将其解压缩。

    Go

    git clone https://github.com/GoogleCloudPlatform/golang-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/markdown-preview/

    Python

    cd python-docs-samples/run/markdown-preview/

    Go

    cd golang-samples/run/markdown-preview/

    Java

    cd java-docs-samples/run/markdown-preview/

查看专用 Markdown 渲染服务

从前端的角度来看,Markdown 服务应遵从一种简单的 API 规范:

  • 一个位于 / 的端点
  • 期望 POST 请求
  • POST 请求的正文为 Markdown 文本

您可能需要查看所有代码以解决任何安全问题,或者只是浏览 ./renderer/ 目录来详细了解代码。请注意,本教程未介绍 Markdown 转换代码。

开发专用 Markdown 渲染服务

如要交付代码,请先使用 Cloud Build 进行构建,然后上传到 Container Registry,最后再部署到 Cloud Run:

  1. 切换到 renderer 目录:

    cd renderer/
  2. 运行以下命令来构建容器并将其发布到 Container Registry 上。

    Node.js

    gcloud builds submit --tag gcr.io/PROJECT_ID/renderer

    其中 PROJECT_ID 是您的 GCP 项目 ID,renderer 是您要为服务指定的名称。

    成功完成后,您将看到一条包含 ID、创建时间和映像名称的 SUCCESS 消息。映像存储在 Container Registry 中,并可根据需要重复使用。

    Python

    gcloud builds submit --tag gcr.io/PROJECT_ID/renderer

    其中 PROJECT_ID 是您的 GCP 项目 ID,renderer 是您要为服务指定的名称。

    成功完成后,您将看到一条包含 ID、创建时间和映像名称的 SUCCESS 消息。映像存储在 Container Registry 中,并可根据需要重复使用。

    Go

    gcloud builds submit --tag gcr.io/PROJECT_ID/renderer

    其中 PROJECT_ID 是您的 GCP 项目 ID,renderer 是您要为服务指定的名称。

    成功完成后,您将看到一条包含 ID、创建时间和映像名称的 SUCCESS 消息。映像存储在 Container Registry 中,并可根据需要重复使用。

    Java

    此示例使用 Jib 利用常见 Java 工具构建 Docker 映像。Jib 已优化容器构建,无需使用 Dockerfile 或安装 Docker。详细了解如何使用 Jib 构建 Java 容器

    1. 使用 gcloud 凭据帮助程序,授权 Docker 推送到您的 Container Registry。

      gcloud auth configure-docker

    2. 使用 Jib Maven 插件来构建容器并将其推送到 Container Registry。

      mvn compile jib:build -Dimage=gcr.io/PROJECT_ID/renderer

    其中 PROJECT_ID 是您的 GCP 项目 ID,renderer 是您要为服务指定的名称。

    成功后,您会看到一条 BUILD SUCCESS 消息。该映像存储在 Container Registry 中,并可根据需要重复使用。

  3. 部署为访问受限的专用服务。

    Cloud Run 提供开箱即用的访问控制功能和服务身份功能。访问控制功能提供了一个身份验证层,限制用户和其他服务调用该服务。而借助服务身份功能,您可以通过创建权限有限的专用服务帐号来限制您的服务对其他 Google Cloud 资源的访问权限。

    1. 创建一个服务帐号作为渲染服务的“计算身份”。默认情况下,该帐号不具备除项目成员资格之外的任何特权。

       gcloud iam service-accounts create renderer-identity
      

      Markdown 渲染服务不会直接与 Google Cloud 中的其他任何服务集成。它不需要进一步的权限。

    2. 使用 renderer-identity 服务帐号进行部署,并拒绝未经身份验证的访问。

      gcloud run deploy renderer \
        --image gcr.io/PROJECT_ID/renderer \
        --service-account renderer-identity \
        --no-allow-unauthenticated

      如果服务帐号属于同一项目,则 Cloud Run 可以使用服务帐号名称的缩写形式,而非完整的电子邮件地址。

试用专用 Markdown 渲染服务

专用服务无法通过网络浏览器直接加载。请改用 curl 或允许注入 Authorization 标头的类似的 HTTP 请求 CLI 工具。

如需将一些粗体文本发送到该服务,并看到该服务将 Markdown 星号转换为 HTML <strong> 代码,请执行以下操作:

  1. 从部署输出中获取网址。

  2. 使用 gcloud 派生特殊开发专用身份令牌,以进行身份验证:

    TOKEN=$(gcloud auth print-identity-token)
  3. 创建将原始 Markdown 文本作为网址转义查询字符串参数进行传递的 curl 请求:

    curl -H "Authorization: Bearer $TOKEN" \
       -H 'Content-Type: text/plain' \
       -d '**Hello Bold Text**' \
       SERVICE_URL
  4. 响应应为 HTML 代码段:

     <strong>Hello Bold Text</strong>
    

查看编辑器和渲染服务之间的集成

编辑器服务提供了一个简单的文本输入界面和一个用于查看 HTML 预览的空间。在继续操作之前,请打开 ./editor/ 目录,查看之前检索到的代码。

接下来,请浏览以下几段安全集成这两项服务的代码。

Node.js

render.js 模块会创建经过身份验证的专用渲染程序服务请求。它使用 Cloud Run 环境中的 Google Cloud 元数据服务器来创建身份令牌,并将其作为 Authorization 标头的一部分添加到 HTTP 请求。

在其他环境中,render.js 使用应用默认凭据从 Google 的服务器请求令牌。

const {GoogleAuth} = require('google-auth-library');
const got = require('got');
const auth = new GoogleAuth();

let client, serviceUrl;

// renderRequest creates a new HTTP request with IAM ID Token credential.
// This token is automatically handled by private Cloud Run (fully managed) and Cloud Functions.
const renderRequest = async markdown => {
  if (!process.env.EDITOR_UPSTREAM_RENDER_URL)
    throw Error('EDITOR_UPSTREAM_RENDER_URL needs to be set.');
  serviceUrl = process.env.EDITOR_UPSTREAM_RENDER_URL;

  // Build the request to the Renderer receiving service.
  const serviceRequestOptions = {
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain',
    },
    body: markdown,
    timeout: 3000,
  };

  try {
    // Create a Google Auth client with the Renderer service url as the target audience.
    if (!client) client = await auth.getIdTokenClient(serviceUrl);
    // Fetch the client request headers and add them to the service request headers.
    // The client request headers include an ID token that authenticates the request.
    const clientHeaders = await client.getRequestHeaders();
    serviceRequestOptions.headers['Authorization'] =
      clientHeaders['Authorization'];
  } catch (err) {
    throw Error('could not create an identity token: ', err);
  }

  try {
    // serviceResponse converts the Markdown plaintext to HTML.
    const serviceResponse = await got(serviceUrl, serviceRequestOptions);
    return serviceResponse.body;
  } catch (err) {
    throw Error('request to rendering service failed: ', err);
  }
};

从 JSON 解析 Markdown,并将其发送到渲染程序服务以转换为 HTML。

app.post('/render', async (req, res) => {
  try {
    const markdown = req.body.data;
    const response = await renderRequest(markdown);
    res.status(200).send(response);
  } catch (err) {
    console.log('error: markdown rendering:', err);
    res.status(500).send(err);
  }
});

Python

new_request 方法会创建经过身份验证的专用服务请求。它使用 Cloud Run 环境中的 Google Cloud 元数据服务器来创建身份令牌,并将其作为 Authorization 标头的一部分添加到 HTTP 请求。

在其他环境中,new_request 使用应用默认凭据进行身份验证,从 Google 服务器请求身份令牌。

import os
import urllib

import google.auth.transport.requests
import google.oauth2.id_token

def new_request(data):
    """
    new_request creates a new HTTP request with IAM ID Token credential.
    This token is automatically handled by private Cloud Run (fully managed)
    and Cloud Functions.
    """

    url = os.environ.get("EDITOR_UPSTREAM_RENDER_URL")
    if not url:
        raise Exception("EDITOR_UPSTREAM_RENDER_URL missing")

    req = urllib.request.Request(url, data=data.encode())
    auth_req = google.auth.transport.requests.Request()
    target_audience = url

    id_token = google.oauth2.id_token.fetch_id_token(auth_req, target_audience)
    req.add_header("Authorization", f"Bearer {id_token}")

    response = urllib.request.urlopen(req)
    return response.read()

从 JSON 解析 Markdown,并将其发送到渲染程序服务以转换为 HTML。

@app.route("/render", methods=["POST"])
def render_handler():
    body = request.get_json()
    if not body:
        return "Error rendering markdown: Invalid JSON", 400

    data = body["data"]
    try:
        parsed_markdown = render.new_request(data)
        return parsed_markdown, 200
    except Exception as err:
        return f"Error rendering markdown: {err}", 500

Go

RenderService 会创建经过身份验证的专用服务请求。它使用 Cloud Run 环境中的 Google Cloud 元数据服务器来创建身份令牌,并将其作为 Authorization 标头的一部分添加到 HTTP 请求。

在其他环境中,RenderService 使用应用默认凭据进行身份验证,从 Google 服务器请求身份令牌。

import (
	"bytes"
	"context"
	"fmt"
	"io/ioutil"
	"net/http"
	"time"

	"golang.org/x/oauth2"
	"google.golang.org/api/idtoken"
)

// RenderService represents our upstream render service.
type RenderService struct {
	// URL is the render service address.
	URL string
	// tokenSource provides an identity token for requests to the Render Service.
	tokenSource oauth2.TokenSource
}

// NewRequest creates a new HTTP request to the Render service.
// If authentication is enabled, an Identity Token is created and added.
func (s *RenderService) NewRequest(method string) (*http.Request, error) {
	req, err := http.NewRequest(method, s.URL, nil)
	if err != nil {
		return nil, fmt.Errorf("http.NewRequest: %w", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// Create a TokenSource if none exists.
	if s.tokenSource == nil {
		s.tokenSource, err = idtoken.NewTokenSource(ctx, s.URL)
		if err != nil {
			return nil, fmt.Errorf("idtoken.NewTokenSource: %w", err)
		}
	}

	// Retrieve an identity token. Will reuse tokens until refresh needed.
	token, err := s.tokenSource.Token()
	if err != nil {
		return nil, fmt.Errorf("TokenSource.Token: %w", err)
	}
	token.SetAuthHeader(req)

	return req, nil
}

添加要转换为 HTML 的 Markdown 文本后,请求会发送到渲染程序服务。系统会处理响应错误,以将通信问题与呈现功能区分开来。


var renderClient = &http.Client{Timeout: 30 * time.Second}

// Render converts the Markdown plaintext to HTML.
func (s *RenderService) Render(in []byte) ([]byte, error) {
	req, err := s.NewRequest(http.MethodPost)
	if err != nil {
		return nil, fmt.Errorf("RenderService.NewRequest: %w", err)
	}
	req.Body = ioutil.NopCloser(bytes.NewReader(in))
	defer req.Body.Close()

	resp, err := renderClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("http.Client.Do: %w", err)
	}

	out, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("ioutil.ReadAll: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return out, fmt.Errorf("http.Client.Do: %s (%d): request not OK", http.StatusText(resp.StatusCode), resp.StatusCode)
	}

	return out, nil
}

Java

makeAuthenticatedRequest 会创建经过身份验证的专用服务请求。它使用 Cloud Run 环境中的 Google Cloud 元数据服务器来创建身份令牌,并将其作为 Authorization 标头的一部分添加到 HTTP 请求。

在其他环境中,makeAuthenticatedRequest 使用应用默认凭据进行身份验证,从 Google 服务器请求身份令牌。

// makeAuthenticatedRequest creates a new HTTP request authenticated by a JSON Web Tokens (JWT)
// retrievd from Application Default Credentials.
public String makeAuthenticatedRequest(String url, String markdown) {
  String html = "";
  try {
    // Retrieve Application Default Credentials
    GoogleCredentials credentials = GoogleCredentials.getApplicationDefault();
    IdTokenCredentials tokenCredentials =
        IdTokenCredentials.newBuilder()
            .setIdTokenProvider((IdTokenProvider) credentials)
            .setTargetAudience(url)
            .build();

    // Create an ID token
    String token = tokenCredentials.refreshAccessToken().getTokenValue();
    // Instantiate HTTP request
    MediaType contentType = MediaType.get("text/plain; charset=utf-8");
    okhttp3.RequestBody body = okhttp3.RequestBody.create(markdown, contentType);
    Request request =
        new Request.Builder()
            .url(url)
            .addHeader("Authorization", "Bearer " + token)
            .post(body)
            .build();

    Response response = ok.newCall(request).execute();
    html = response.body().string();
  } catch (IOException e) {
    logger.error("Unable to get rendered data", e);
  }
  return html;
}

从 JSON 解析 Markdown,并将其发送到渲染程序服务以转换为 HTML。

// '/render' expects a JSON body payload with a 'data' property holding plain text
// for rendering.
@PostMapping(value = "/render", consumes = "application/json")
public String render(@RequestBody Data data) {
  String markdown = data.getData();

  String url = System.getenv("EDITOR_UPSTREAM_RENDER_URL");
  if (url == null) {
    String msg =
        "No configuration for upstream render service: "
            + "add EDITOR_UPSTREAM_RENDER_URL environment variable";
    logger.error(msg);
    throw new IllegalStateException(msg);
  }

  String html = makeAuthenticatedRequest(url, markdown);
  return html;
}

开发公共编辑器服务

如需构建并部署代码,请执行以下操作:

  1. 切换到 editor 目录:

    cd ../editor
  2. 运行以下命令来构建容器并将其发布到 Container Registry 上。

    Node.js

    gcloud builds submit --tag gcr.io/PROJECT_ID/editor

    其中 PROJECT_ID 是您的 GCP 项目 ID,editor 是您要为服务指定的名称。

    成功完成后,您将看到一条包含 ID、创建时间和映像名称的 SUCCESS 消息。映像存储在 Container Registry 中,并可根据需要重复使用。

    Python

    gcloud builds submit --tag gcr.io/PROJECT_ID/editor

    其中 PROJECT_ID 是您的 GCP 项目 ID,editor 是您要为服务指定的名称。

    成功完成后,您将看到一条包含 ID、创建时间和映像名称的 SUCCESS 消息。映像存储在 Container Registry 中,并可根据需要重复使用。

    Go

    gcloud builds submit --tag gcr.io/PROJECT_ID/editor

    其中 PROJECT_ID 是您的 GCP 项目 ID,editor 是您要为服务指定的名称。

    成功完成后,您将看到一条包含 ID、创建时间和映像名称的 SUCCESS 消息。映像存储在 Container Registry 中,并可根据需要重复使用。

    Java

    此示例使用 Jib 利用常见 Java 工具构建 Docker 映像。Jib 已优化容器构建,无需使用 Dockerfile 或安装 Docker。详细了解如何使用 Jib 构建 Java 容器

    mvn compile jib:build -Dimage=gcr.io/PROJECT_ID/editor

    其中 PROJECT_ID 是您的 GCP 项目 ID,editor 是您要为服务指定的名称。

    成功后,您会看到一条 BUILD SUCCESS 消息。该映像存储在 Container Registry 中,并可根据需要重复使用。

  3. 部署为对渲染服务具有特殊访问权限的专用服务。

    1. 创建一个服务帐号作为渲染服务的“计算身份”。默认情况下,该帐号不具备除项目成员资格之外的任何特权。

       gcloud iam service-accounts create editor-identity
      

      编辑器服务不需要与 Markdown 渲染服务之外的其他任何 Google Cloud 服务交互。

    2. editor-identity 计算身份授予访问权限,以调用 Markdown 渲染服务。使用此身份的任何服务都将拥有此特权。

      gcloud run services add-iam-policy-binding renderer \
        --member serviceAccount:editor-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/run.invoker

      因为在渲染服务环境中向此身份赋予了调用方角色,所以渲染服务是编辑器可以调用的唯一一项专用 Cloud Run 服务。

    3. 使用 editor-identity 服务帐号进行部署,并允许未经身份验证的公开访问。

      gcloud run deploy editor --image gcr.io/PROJECT_ID/editor \
        --service-account editor-identity \
        --set-env-vars EDITOR_UPSTREAM_RENDER_URL=RENDERER_SERVICE_URL \
        --allow-unauthenticated

      注意替换如下内容:

      • PROJECT_ID 替换为您的项目 ID。
      • RENDERER_SERVICE_URL 替换为在部署 Markdown 渲染服务后提供的网址。

了解 HTTPS 流量

使用这些服务渲染 Markdown 时有三个 HTTP 请求。

该图表显示了从用户到编辑器的请求流程,包括编辑器从元数据服务器获取令牌、编辑器向渲染服务发出请求、渲染服务将 HTML 返回至编辑器。
具有 editor-identity 的前端服务会调用渲染服务。editor-identityrenderer-identity 权限有限,因此任何安全漏洞或代码注入都只能有限访问其他 Google Cloud 资源。

测试

如需试用完整的双服务应用,请执行以下操作:

  1. 在浏览器中导航至上述部署步骤提供的网址。

  2. 尝试编辑左侧的 Markdown 文本,然后点击按钮以在右侧预览文本。

    它应该类似于如下所示:

    Markdown 编辑器界面的屏幕截图

如果您选择继续开发这些服务,请注意,它们已限制了 Identity and Access Management (IAM) 对 Google Cloud 其余服务的访问权限,并需要额外的 IAM 角色才能访问众多其他服务。

清除数据

如果您为本教程创建了一个新项目,请删除项目。 如果您使用的是现有项目,希望保留此项目且不保留本教程中添加的任何更改,请删除为教程创建的资源

删除项目

为了避免产生费用,最简单的方法是删除您为本教程创建的项目。

如需删除项目,请执行以下操作:

  1. 在 Cloud Console 中,转到管理资源页面。

    转到“管理资源”

  2. 在项目列表中,选择要删除的项目,然后点击删除
  3. 在对话框中输入项目 ID,然后点击关闭以删除项目。

删除教程资源

  1. 删除您在本教程中部署的 Cloud Run 服务:

    gcloud run services delete SERVICE

    其中,SERVICE 是您选择的服务名称。

    您还可以从 Google Cloud Console 中删除 Cloud Run 服务。

  2. 移除您在教程设置过程中添加的 gcloud 默认配置。

     gcloud config unset run/region
    
  3. 移除项目配置:

     gcloud config unset project
    
  4. 删除在本教程中创建的其他 Google Cloud 资源:

后续步骤