NGINX.COM
Web Server Load Balancing with NGINX Plus

本文是“Microservices June 微服务之月 2023”系列教程之一,旨在帮助您将概念付诸实践。

7 月 1 日前免费注册线上教学项目 NGINX 微服务之月,并在 8 月 1 日前按要求完成课程,即可获得 NGINX 独家纪念礼品以及结课证书。

本文末尾包括本实验的验收标准,想要获取礼品和证书的同学,请在 8 月 1 日前随单元小测提交实验结果。

本系列教程包括:

您的许多微服务必须设置 Secrets 才能安全地运行。Secrets 的示例包括 SSL/TLS 证书和密钥,用于向另一个服务进行身份验证的 API 密钥,或用于远程登录的 SSH 密钥。妥善的 Secrets 管理要求严格限制 Secrets 使用的上下文(仅限在所需位置使用),并防止不必要的 Secrets 访问。但在仓促的应用开发中这一步通常会被跳过。结果呢?不当的 Secrets 管理已成为信息泄露和漏洞利用的常见原因。

 

教程概述

在本教程中,我们将展示当客户端容器访问服务时如何安全地分发和使用 JSON Web Token(JWT)。在本教程的四个挑战中,您将尝试使用四种不同的 Secrets 管理方法,以学习如何在容器中正确地管理 Secrets,并了解几种不完善的 Secrets 管理方法:

虽然本教程使用 JWT 作为 Secrets 示例,但这些技巧适用于任何您需要用来保存 Secrets 的载体,例如数据库凭证、SSL 私钥及其他 API 密钥。

本教程用到了两个主要的软件组件:

  • API 服务器——一个运行 NGINX 开源版和一些基本 NGINX JavaScript 代码的容器,它可从 JWT 中提取声明,并从其中一个声明中返回一个值,如果没有声明,则返回一条错误消息
  • API 客户端——一个运行简单 Python 代码的容器,只向 API 服务器发起 GET 请求

学习本教程的最简单方法就是注册参加 Microservices June,并根据本教程中的实验指南搭建您自己的实验环境,完成所有实验步骤。

 

准备工作和设置

准备工作

要想在自己的环境中完成本教程的实验,您需要:

注:

  • 本教程使用了一个侦听 80 端口的测试服务器。如果 80 端口已被占用,则可在使用 docker run 命令启动测试服务器时,使用 ‑p 标记为该服务器设置其他值。然后,使用 curl 命令时在 localhost 上添加 :<port_number> 后缀。
  • 本教程中省略了 Linux 命令行提示符,以便您将命令复制和粘贴到终端。

设置

在本节中,您需要复制教程代码库并生成 JWT启动身份验证服务器,并在有无令牌两种情况下发送测试请求

复制教程代码库及生成 JWT

  1. 在家目录下,创建 microservices-june 目录,并将 Jihulab 代码库复制到其中。(您也可以使用其他目录名称,相应修改指令即可)。该代码库包含配置文件以及使用不同方法来获取 Secrets 的 API 客户端应用的多个版本。

    mkdir ~/microservices-june
    cd ~/microservices-june
    git clone https://jihulab.com/f5will/microservices-june-2023-auth.git
    
  2. 签发一个测试的 JWT,可以使用以下网站来生成 JWT:https://tooltt.com/jwt-encode/

    请注意其中的 Subject 字段,必须使用自己的名字,这会作为我们验证实验完成情况的依据!

    将生成的 Token 保存至以下目录并命名为 token1.jwt

    cat ~/microservices-june/microservices-june-2023-auth/apiclient/token1.jwt
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2Nz
    UyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-
    jEZdihy-H1glooSq_z162VKghA

虽然可通过多种方法使用该令牌进行身份验证,但在本教程中,API 客户端应用使用 OAuth 2.0 Bearer 令牌授权框架将其传递给身份验证服务器。这需要您在 JWT 前面加上 Authorization: Bearer 前缀,如本例所示:

"Authorization: Bearer
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-
jEZdihy-H1glooSq_z162VKghA"

构建并启动身份验证服务器

  1. 切换到身份验证服务器目录:

    cd apiserver
  2. 构建身份验证服务器的 Docker 镜像(注意最后的句号):

    docker build -t apiserver .
  3. 启动身份验证服务器,并确认它正在运行(为方便阅读,输出结果分成了多行):

    docker run -d -p 80:80 apiserver
    docker ps
    CONTAINER ID   IMAGE       COMMAND                  ...
    2b001f77c5cb   apiserver   "nginx -g 'daemon of..." ...  
    
    
        ... CREATED         STATUS          ...                                    
        ... 26 seconds ago  Up 26 seconds   ... 
    
    
        ... PORTS                                      ...
        ... 0.0.0.0:80->80/tcp, :::80->80/tcp, 443/tcp ...
    
    
        ... NAMES
        ... relaxed_proskuriakova

测试身份验证服务器

  1. 验证身份验证服务器是否拒绝没有 JWT 的请求,返回 401 Authorization Required:

    curl -X GET http://localhost
    <html>
    <head><title>401 Authorization Required</title></head>
    <body>
    <center><h1>401 Authorization Required</h1></center>
    <hr><center>nginx/1.23.3</center>
    </body>
    </html>
  2. 使用 Authorization 请求头提供 JWT。200 OK 返回状态码表明 API 客户端应用身份验证成功。

    curl -i -X GET -H "Authorization: Bearer `cat $HOME/microservices-june/microservices-june-2023-auth/apiclient/token1.jwt`" http://localhost
    HTTP/1.1 200 OK
    Server: nginx/1.23.2
    Date: Day, DD Mon YYYY hh:mm:ss TZ
    Content-Type: text/html
    Content-Length: 64
    Last-Modified: Day, DD Mon YYYY hh:mm:ss TZ
    Connection: keep-alive
    ETag: "63dc0fcd-40"
    X-MESSAGE: Success wtang
    Accept-Ranges: bytes
    
    
    { "response": "success", "authorized": true, "value": "999" }

 

挑战 1:将 Secrets 硬编码至应用(不可以!)

在开始这个挑战之前,需要明确一点:将密钥硬编码至应用是一个糟糕的主意!您会发现任何可访问容器镜像的人员都能够轻松地找到并提取硬编码凭证。

这个挑战中,您需要将 API 客户端应用的代码复制到 build 目录中,构建并运行该应用然后提取密钥

复制 API 客户端应用

apiclient 目录下的 app_versions 子目录中包含了一个简单 API 客户端应用的不同版本,这些版本将分别用于四个挑战,并且随着版本的升级安全性能逐步提高(详情请见“教程概述”)。

  1. 切换到 API 客户端目录:

    cd ~/microservices-june/microservices-june-2023-auth/apiclient
  2. 将该挑战会用到的应用(采用硬编码 Secret 的应用)复制到工作目录下:

    cp ./app_versions/very_bad_hard_code.py ./app.py
  3. 检查应用(注意将 jwt 的内容换成你自己的!):

    cat app.py
    import urllib.request
    import urllib.error
    
    jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-
    jEZdihy-H1glooSq_z162VKghA"
    authstring = "Bearer " + jwt
    req = urllib.request.Request("http://host.docker.internal")
    req.add_header("Authorization", authstring)
    try:
        with urllib.request.urlopen(req) as response:
            the_page = response.read()
            message = response.getheader("X-MESSAGE")
            print("200  " + message)
    except urllib.error.URLError as e:
        print(str(e.code) + " s " + e.msg)

    该代码只向本地主机发送请求,并生成成功消息或失败状态码。

    该请求在此行中添加了 Authorization 请求头:

    req.add_header("Authorization", authstring)

    您还注意到了什么?是否看到了一个硬编码的 JWT?稍后我们会谈到这一点。首先,让我们构建并运行应用。

构建并运行 API 客户端应用

我们会用到 docker compose 命令和 Docker Compose YAML 文件——这有助于我们轻松了解运行状况。

(注:在上一节的第二步中,您已将挑战 1 会用到的 API 客户端应用的 Python 文件 (very_bad_hard_code.py) 重命名为 app.py。在其他三个挑战中您也要这样做。使用 app.py 可以简化流程,因为您无需更改 Dockerfile。这也意味着您需要在 docker compose 命令中添加 --build 参数,以每次都强制重建容器)。

docker compose 命令可构建容器,启动应用,发起一个 API 请求,然后关闭容器,同时在控制台上显示 API 调用的结果。

输出结果倒数第二行上的 200 Success 状态码表明身份验证成功。wtang 值是进一步的确认,它表明身份验证服务器能够解码 JWT 中该名称的声明(在您的环境中,wtang 应该被替换成您自己的名称):

docker compose -f docker-compose.hardcode.yml up --build
...
apiclient-apiclient-1  | 200  Success wtang
apiclient-apiclient-1 exited with code 0

因此,硬编码凭证可在我们的 API 客户端应用正常运行,这并不奇怪。但安全吗?或许安全,因为容器在退出之前只运行该脚本一次,并且没有 shell?

但事实上,一点也不安全。

从容器镜像检索 Secret

硬编码凭证可供任何能够访问容器镜像的人员查看,因为提取容器的文件系统易如反掌。

  1. 创建提取目录并转到该目录:

    mkdir extract
    cd extract
  2. 列出有关容器镜像的基本信息。--format 标记提高了输出结果的可读性(出于同样的原因,输出结果分成了两行):

    docker ps -a --format "table
     {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
    CONTAINER ID   NAMES                   IMAGE       ...
    11b73106fdf8   apiclient-apiclient-1   apiclient   ...
    ad9bdc05b07c   exciting_clarke         apiserver   ...
    
    
        ... CREATED          STATUS
        ... 6 minutes ago    Exited (0) 4 minutes ago
        ... 43 minutes ago   Up 43 minutes
  3. 提取最新的 apiclient 镜像为 .tar 文件。对于 <container_ID>,用上述输出结果中 CONTAINER ID 字段的值(在本教程中为 11b73106fdf8)代替。

    docker export -o api.tar <container_ID>

    创建 api.tar 归档文件需要几秒钟的时间,其中包括容器的整个文件系统。一种查找 Secrets 的方法是提取整个归档文件并对其进行解析,但事实证明,可通过一种快捷方式迅速找到或许值得注意的内容,即使用 docker history 命令显示容器的历史记录。(这个快捷方式特别方便,它还可以帮助您在 Docker Hub 或其他容器注册表上查找可能没有 Dockerfile 而只有容器镜像的容器)。

  4. 显示容器的历史记录:

    docker history apiclient
    IMAGE         CREATED        ...
    9396dde2aad0  8 minutes ago  ...                    
    <missing>     8 minutes ago  ...   
    <missing>     28 minutes ago ...  
                   
        ... CREATED BY                          SIZE ... 
        ... CMD ["python" "./app.py"]           622B ...   
        ... COPY ./app.py ./app.py # buildkit   0B   ... 
        ... WORKDIR /usr/app/src                0B   ...   
                 
        ... COMMENT
        ... buildkit.dockerfile.v0
        ... buildkit.dockerfile.v0
        ... buildkit.dockerfile.v0

    输出行按时间倒序排列。从中可以看出,工作目录被设置为 /usr/app/src,然后复制并运行了应用的 Python 代码文件。由此可轻松地推断出该容器的核心代码库在 /usr/app/src/app.py 中,那么凭证很可能位于此处。

  5. 确定这点后,提取该文件:

    tar --extract --file=api.tar usr/app/src/app.py
  6. 显示该文件的内容,这样我们就获取了对“安全”JWT 的访问权限:

    cat usr/app/src/app.py
    ...
    jwt="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-
    jEZdihy-H1glooSq_z162VKghA"
    ...

 

挑战 2:将 Secrets 作为环境变量传递(同样不可以!)

如果您已经学完 Microservices June 2023 的第一单元(将十二要素应用于微服务架构),那么便了解如何使用环境变量将配置数据传递给容器。如果您错过了,也无妨,完成注册后,即可点播观看。

在这个挑战中,您需要把 Secrets 作为环境变量传递。与挑战 1 中一样,我们也不推荐这种方法! 它不像硬编码 Secrets 那样糟糕,但也存在一些弱点。

可通过四种方法将环境变量传递给容器:

  • Dockerfile 中使用 ENV 语句进行变量替换(为所有构建的镜像设置变量)。例如:

    ENV PORT $PORT
  • docker run 命令上使用 ‑e 标记。例如:

    docker run -e PASSWORD=123 mycontainer
  • 在 Docker Compose YAML 文件中使用 environment key。
  • 使用包含变量的 .env 文件。

在这个挑战中,您将使用环境变量来设置 JWT,并检查容器,以查看是否已暴露 JWT。

传递环境变量

  1. 返回 API 客户端目录:

    cd ~/microservices-june/microservices-june-2023-auth/apiclient
  2. 将该挑战会用到的应用(使用环境变量的应用)复制到工作目录下,覆盖挑战 1 中的 app.py 文件:

    cp ./app_versions/medium_environment_variables.py ./app.py
  3. 检查应用。在相关输出行中,Secret (JWT) 被作为本地容器中的环境变量读取:

    cat app.py
    ...
    jwt = ""
    if "JWT" in os.environ:
        jwt = "Bearer " + os.environ.get("JWT")
    ...
  4. 如上所述,可通过多种方法将环境变量传递给容器。为了保持一致,我们继续使用 Docker Compose。显示 Docker Compose YAML 文件的内容,该文件使用 environment key 来设置 JWT 环境变量:

    cat docker-compose.env.yml
    ---
    version: "3.9"
    services:
      apiclient:
        build: .
        image: apiclient
        extra_hosts:
          - "host.docker.internal:host-gateway"
        environment:
          - JWT
  5. 在不设置环境变量的情况下运行该应用。输出结果中倒数第二行401 Unauthorized 状态码证实身份验证失败,因为 API 客户端应用没有传递 JWT:

    docker compose -f docker-compose.env.yml up --build
    ...
    apiclient-apiclient-1  | 401  Unauthorized
    apiclient-apiclient-1 exited with code 0
  6. 为了简单起见,在本地设置环境变量。此时可以这样做,因为这个安全问题并不是我们目前所关注的:

    export JWT=`cat token1.jwt`
  7. 再次运行容器。现在测试成功了,系统显示了与挑战 1 相同的消息。

    docker compose -f docker-compose.env.yml up --build
    ... 
    apiclient-apiclient-1  | 200  Success wtang
    apiclient-apiclient-1 exited with code 0

至少现在基础镜像中不含 Secrets,我们可以在运行时更安全地传递它,但还是存在问题。

检查容器

  1. 显示有关容器镜像的信息,以获取 API 客户端应用的容器 ID(为方便阅读,输出结果分成了两行):

    docker ps -a --format "table
     {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
    CONTAINER ID   NAMES                   IMAGE      ...
    6b20c75830df   apiclient-apiclient-1   apiclient  ...
    ad9bdc05b07c   exciting_clarke         apiserver  ...
    
    
        ... CREATED             STATUS
        ... 6 minutes ago       Exited (0) 6 minutes ago
        ... About an hour ago   Up About an hour
  2. 检查 API 客户端应用的容器。对于 <container_ID>,用上述输出结果中 CONTAINER ID 字段的值(此处为 6b20c75830df)代替。

    您可以使用 docker inspect 命令检查所有启动的容器,无论它们是否正在运行。但问题是,即使容器没有在运行,输出也会在 Env 阵列中暴露 JWT,将其不安全地保存在容器配置中。

    docker inspect <container_ID>
    ...
    "Env": [
    
    "JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA...",
      
    "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "LANG=C.UTF-8",
      "GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D",
      "PYTHON_VERSION=3.11.2",
      "PYTHON_PIP_VERSION=22.3.1",
      "PYTHON_SETUPTOOLS_VERSION=65.5.1",
      "PYTHON_GET_PIP_URL=https://github.com/pypa/get-
    pip/raw/1a96dc5acd0303c4700e026...",
      
    "PYTHON_GET_PIP_SHA256=d1d09b0f9e745610657a528689ba3ea44a73bd19c60f4c954271b790c..."
    ]

 

挑战 3:使用本地 Secrets

现在您已经知道,硬编码 Secrets 和使用环境变量并不能满足您(或您的安全团队)的安全需求。

为了提高安全防护,您可以尝试使用本地 Docker Secrets 来存储敏感信息。同样地,虽然这不是黄金标准方法,但可以了解一下其工作原理。即使您在生产环境中不使用 Docker,也要知道如何加大从容器中提取 Secrets 的难度。

在 Docker 中,Secrets 通过文件系统 mount/run/secrets/ 暴露给容器,其中有个单独文件包含了每个 Secret 的值。

在这个挑战中,您使用 Docker Compose 将本地存储的 Secret 传递给容器,然后验证在使用这个方法时,该 Secret 在容器中是否不可见

将本地存储的 Secret 传递给容器

  1. 如您所料,首先切换到 apiclient 目录:

    cd ~/microservices-june/microservices-june-2023-auth/apiclient
  2. 将该挑战会用到的应用(使用容器内密钥的应用)复制到工作目录下,覆盖挑战 2 中的 app.py 文件:

    cp ./app_versions/better_secrets.py ./app.py
  3. 检查 Python 代码,它从 /run/secrets/jot 文件中读取 JWT 值。

    cat app.py
    ...
    jotfile = "/run/secrets/jot"
    jwt = ""
    if os.path.isfile(jotfile):
        with open(jotfile) as jwtfile:
            for line in jwtfile:
                jwt = "Bearer " + line
    ...

    这里我们要确保 jwt 文件只有一行,所以我们需要删除隐藏在行尾的换行符。您可以使用以下这个命令:

    echo -n $(cat token1.jwt) > token1.jwt
    

    最好 cat 一下确保没有换行符。

    我们将如何创建这个 Secret 呢?答案就在 docker-compose.secrets.yml 文件中。

  4. 检查 Docker Compose 文件,其中 Secret 文件在 secrets 部分中进行定义,然后被 apiclient 服务引用:

    cat docker-compose.secrets.yml
    ---
    version: "3.9"
    secrets:
      jot:
        file: token1.jwt
    services:
      apiclient:
        build: .
        extra_hosts:
          - "host.docker.internal:host-gateway"
        secrets:
          - jot

验证 Secret 是否在容器中不可见

  1. 运行该应用。因为我们已将 JWT 设为可在容器中访问,现在身份验证成功,并显示了一条熟悉的消息:

    docker compose -f docker-compose.secrets.yml up --build
    ...
    apiclient-apiclient-1  | 200 Success wtang
    apiclient-apiclient-1 exited with code 0
  2. 显示有关容器镜像的信息,注意 API 客户端应用的容器 ID(有关输出示例,请参见挑战 2 中“检查容器”的第一步):

    docker ps -a --format "table 
    {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
  3. 检查面向 API 客户端应用的容器。对于 <container_ID>,用上一步输出中 CONTAINER ID 字段的值代替。不同于“检查容器”中第二步的输出,Env 部分的开头没有 JWT= 行:

    docker inspect <container_ID>
    "Env": [
    
    "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "LANG=C.UTF-8",
      "GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D",
      "PYTHON_VERSION=3.11.2",
      "PYTHON_PIP_VERSION=22.3.1",
      "PYTHON_SETUPTOOLS_VERSION=65.5.1",
      "PYTHON_GET_PIP_URL=https://github.com/pypa/get-
    pip/raw/1a96dc5acd0303c4700e026...",
      
    "PYTHON_GET_PIP_SHA256=d1d09b0f9e745610657a528689ba3ea44a73bd19c60f4c954271b790c..."
    ]

    目前一切进展顺利,但我们的 Secret 位于容器文件系统中的 /run/secrets/jot。也许我们可以使用与挑战 1 中“从容器镜像检索密钥”相同的方法从该文件系统提取密钥。

  4. 切换到提取 extract 目录(您在挑战 1 中创建的),并将容器导出到 tar 归档文件:

    cd extract
    docker export -o api2.tar <container_ID>
  5. Look for the secret in the tar file 查找 tar 文件中包含的 Secret:

    tar tvf api2.tar | grep jot
    -rwxr-xr-x  0 0      0           0 Mon DD hh:mm run/secrets/jot

    哎呀,包含 JWT 的文件可见。我们不是说将 Secret 嵌入容器中是“安全的”吗?情况和挑战 1 中一样糟糕吗?

  6. 让我们来看看。从 tar 文件中提取 Secret 文件,并检查其内容:

    tar --extract --file=api2.tar run/secrets/jot
    cat run/secrets/jot

    好消息!cat 命令没有输出,这意味着容器文件系统中的 run/secrets/jot 文件是空的,在那里看不到 Secret!即使我们的容器中有 Secrets 工件,Docker 也不会在容器中存储任何敏感数据。

然而,虽然这种容器配置是安全的,但也有一个缺点。那就是当您运行容器时,本地文件系统中必须有一个名为 token1.jwt 的文件。如果您重命名该文件,则无法重新启动容器。(您可以亲自试试:重命名[而不是删除!]token1.jwt,然后再从第一步运行 docker compose 命令。)

现在我们已经成功了一半:容器在使用 Secrets 时可确保 Secrets 不会被轻易窃取,但Secrets 在主机上仍然不受保护。您肯定不希望 Secrets 以未加密的方式存储在纯文本文件中。现在是时候引入 Secrets 管理工具了。

 

挑战 4:使用 Secrets Manager

Secrets Manager 可帮助您在整个生命周期内管理、检索和轮换 Secrets。现有很多 Secrets Manager 可供选择并能够实现类似的目的。

  • 安全地存储 Secrets
  • 控制访问
  • 在运行时分发 Secrets
  • 支持 Secrets 轮换

您可以使用以下 Secrets 管理选项:

为了简单起见,本挑战使用了 Docker Swarm,对于许多 Secrets Manager 来说,其工作原理相同。

在这个挑战中,您需要在 Docker 中创建 Secrets复制 Secrets 和 API 客户端代码部署容器,然后查看您能否提取轮换 Secrets

配置 Docker 密钥

  1. 同样切换到 apiclient 目录:

    cd ~/microservices-june/microservices-june-2023-auth/apiclient
  2. 初始化 Docker Swarm:

    docker swarm init
    Swarm initialized: current node (t0o4eix09qpxf4ma1rrs9omrm) is now a 
    manager.
    ...
  3. token1.jwt 文件创建 Secret:

    docker secret create jot ./token1.jwt
    qe26h73nhb35bak5fr5east27
  4. 显示有关该 Secret 的信息。注意 Secret 值 (JWT) 本身不显示:

    docker secret inspect jot
    [
      {
        "ID": "qe26h73nhb35bak5fr5east27",
        "Version": {
          "Index": 11
        },
        "CreatedAt": "YYYY-MM-DDThh:mm:ss.msZ",
        "UpdatedAt": "YYYY-MM-DDThh:mm:ss.msZ",
        "Spec": {
          "Name": "jot",
          "Labels": {}
        }
      }
    ]

使用 Docker Secret

在 API 客户端应用代码中使用 Docker Secret 的方式与使用本地创建的 Secret 完全相同——您可以从 /run/secrets/ 文件系统中读取该 Secret,只需更改 Docker Compose YAML 文件中的 Secret 限定符即可。

  1. 检查 Docker Compose YAML 文件。注意 external 字段中的值为 true,表明我们正在使用 Docker Swarm Secret:

    cat docker-compose.secretmgr.yml
    ---
    version: "3.9"
    secrets:
      jot:
        external: true
    services:
      apiclient:
        build: .
        image: apiclient
        extra_hosts:
          - "host.docker.internal:host-gateway"
        secrets:
          - jot

    这样,该 Compose 文件应可以与我们现有的 API 客户端应用代码配合使用了。虽然 Docker Swarm(或任何其他容器编排平台)带来了许多额外的好处,但也加剧了复杂性。

    由于 docker compose 不能与外部 Secret 一起使用,因此我们必须使用一些 Docker Swarm 命令,特别是 docker stack deploy。Docker Stack 隐藏了控制台输出,所以我们必须把输出写入日志,然后检查日志。

    为了简化操作,我们还使用了一个连续的 while True 循环来确保容器持续运行。

  2. 将该挑战的应用(使用 Secrets Manager 的应用)复制到工作目录下,覆盖挑战 3 中的 app.py 文件。显示 app.py 的内容,我们可以看到代码与挑战 3 中的代码几乎相同。唯一的区别是添加了 while True 循环:

    cp ./app_versions/best_secretmgr.py ./app.py
    cat ./app.py
    ...
    while True:
        time.sleep(5)
        try:
            with urllib.request.urlopen(req) as response:
                the_page = response.read()
                message = response.getheader("X-MESSAGE")
                print("200 " + message, file=sys.stderr)
        except urllib.error.URLError as e:
            print(str(e.code) + " " + e.msg, file=sys.stderr)

部署容器并检查日志

  1. 构建容器(在上述挑战中使用 Docker Compose 进行构建):

    docker build -t apiclient .
  2. 部署容器:

    docker stack deploy --compose-file docker-compose.secretmgr.yml secretstack
    Creating network secretstack_default
    Creating service secretstack_apiclient
  3. 列出运行容器,注意 secretstack_apiclient 的容器 ID(同上,为方便阅读,输出结果分成了多行)。

    docker ps --format "table 
    {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
    CONTAINER ID  ...  
    20d0c83a8b86  ... 
    ad9bdc05b07c  ... 
    
        ... NAMES                                             ...  
        ... secretstack_apiclient.1.0e9s4mag5tadvxs6op6lk8vmo ...  
        ... exciting_clarke                                   ...                                 
    
        ... IMAGE              CREATED          STATUS
        ... apiclient:latest   31 seconds ago   Up 30 seconds
        ... apiserver          2 hours ago      Up 2 hours
  4. 显示 Docker 日志文件;对于 <container_ID>,用上一步输出中 CONTAINER ID 字段的值(此处为 20d0c83a8b86)代替。日志文件显示了一系列的成功消息,因为我们为应用代码中添加了 while True 循环。按下 Ctrl+c 退出命令。

    docker logs -f <container_ID>
    200 Success wtang
    200 Success wtang
    200 Success wtang
    200 Success wtang
    200 Success wtang
    200 Success wtang
    ...
    ^c

尝试访问 Secret

可以发现没有设置敏感的环境变量(但您可以像在挑战 2 中“检查容器”的第二步那样,使用 docker inspect 命令进行检查)。

从挑战 3 中我们还知道,/run/secrets/jot 文件为空,但您可以检查:

cd extract
docker export -o api3.tar <container_ID>

tar --extract --file=api3.tar run/secrets/jot
cat run/secrets/jot

成功!您无法从容器中获取 Secret,也无法直接从 Docker 密钥中读取 Secret。

轮换Secret

当然,如果拥有合适的权限,我们还可以创建服务,并将其配置为将 Secret 读入日志或将其设置为环境变量。此外,您可能已经注意到,我们的 API 客户端和服务器之间的通信没有加密(纯文本)。

由此可见,无论使用何种 Secrets 管理系统,都有可能发生 Secrets 泄露。降低 Secrets 泄露几率的一种方法是定期轮换(更换)Secrets。

如果使用 Docker Swarm,则只能删除然后重新创建 Secrets(Kubernetes 允许动态更新 Secrets)。但您无法删除附加到运行中服务的 Secrets。

  1. 列出正在运行的服务:

    docker service ls
    ID             NAME                    MODE         ... 
    sl4mvv48vgjz   secretstack_apiclient   replicated   ... 
    
    
        ... REPLICAS   IMAGE              PORTS
        ... 1/1        apiclient:latest
  2. 删除 secretstack_apiclient 服务。

    docker service rm secretstack_apiclient
  3. 删除该 Secret 并使用新令牌重新创建密钥(重新生成一个 jwt 并保存为 token2.jwt,注意使用不同的 Subject,最简单的方法就是加个 2):

    docker secret rm jot
    docker secret create jot ./token2.jwt
  4. 重新创建服务:

    docker stack deploy --compose-file docker-compose.secretmgr.yml 
    secretstack
  5. 查找 apiclient 的容器 ID(关于输出示例,请见“部署容器并检查日志”中的第三步):

    docker ps --format "table 
    {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
  6. 显示 Docker 日志文件,该文件显示了一系列的成功消息。对于 <container_ID>,用上一步输出中 CONTAINER ID 字段的值代替。按下 Ctrl+c 退出命令。

    docker logs -f <container_ID>
    200 Success wtang2
    200 Success wtang2
    200 Success wtang2
    200 Success wtang2
    ...
    ^c

看到从 wtang1 变成了 wtang2 吗?您已成功轮换 Secret。

在本教程中,API 服务器仍会同时接受这两个 JWT,但在生产环境中,您可以通过要求 JWT 中的声明具有某些值或检查 JWT 的到期日期来弃用旧 JWT。

还请注意,如果您使用的 Secrets 系统允许 Secrets 更新,那么您的代码就需要频繁地重新读取 Secrets,以提取新的 Secrets 值。

 

清理

清理您在本教程中所创建的对象:

  1. 删除 secretstack_apiclient 服务。

    docker service rm secretstack_apiclient
  2. 删除 Secrets。

    docker secret rm jot
  3. 终止 swarm(假设您只是为本教程创建了一个 swarm)。

    docker swarm leave --force
  4. 关闭正在运行的 apiserver 容器。

    docker ps -a | grep "apiserver" | awk {'print $1'} |xargs docker 
    kill
  5. 列出并删除不需要的容器。

    docker ps -a --format "table 
    {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
    docker rm <container_ID>
  6. 列出并删除任何不需要的容器镜像。

    docker image list   
    docker image rm <image_ID>

 

后续步骤

您可以参考本文在您自己的环境中完成所有实验。如欲进一步了解暴露 Kubernetes service 这一主题,请继续查看 Microservices June 2023 中的其他活动。

如欲详细了解 NGINX Plus 生产级 JWT 身份验证,请查看我们的文档并阅读博文《借助 JWT 和 NGINX Plus 验证 API 客户端身份》。

 

实验验收标准

请同学们在动手实验的时候,注意按照此验收标准进行截图并存在一个 word 文档中,在参加单元小测时上传文档以供验收,谢谢!

实验验收标准

  1. 将 JWT 证书生成界面截图,需要看到 Subject 字段和最终生成的 Token 值。

  2. 完成“测试身份验证服务器”部分后,将结果截图,X-MESSAGE 显示的应该是您的名字

挑战 1

将 docker compose 命令的输出截图,输出结果倒数第二行上应该有 200 Success 加上您的名字

挑战 2

将 docker inspect 的输出截图,截取 ENV 部分即可,其中应该有您之前生成的 JWT

挑战 3

  1. 将 docker inspect 的输出截图,截取 ENV 部分即可,其中应该看不到任何 JWT相关字段

  2. 将“验证 Secret 是否在容器中不可见”缓解的步骤 4-6 的输出截图,应该看不到 JWT 值

挑战 4

  1. 将第一次 docker logs 的输出截图,其中应该能看到多次(至少 3 次)成功的响应,而响应中应该有您的名字

  2. 将第二次 docker logs 的输出截图,其中应该能看到多次(至少 3 次)成功的响应,而响应中应该有您名字的第二版

以上就是实验验收所需的所有截图,请按要求截取并在单元小测时上传。

Hero image
免费 O'Reilly 电子书:
《NGINX 完全指南》

更新于 2022 年,一本书了解关于 NGINX 的一切

关于作者

Robert Haynes

技术营销经理

关于作者

汤炜涛

解决方案顾问

关于 F5 NGINX

F5, Inc. 是备受欢迎的开源软件 NGINX 背后的商业公司。我们为现代应用的开发和交付提供一整套技术。我们的联合解决方案弥合了 NetOps 和 DevOps 之间的横沟,提供从代码到用户的多云应用服务。访问 nginx-cn.net 了解更多相关信息。