在 OCI Nomad 集群中为 Flask 应用实现基于 IAM 动态组的无凭证化服务认证


一个看似简单的需求摆在面前:部署在 OCI (Oracle Cloud Infrastructure) Nomad 集群上的一个 Flask 应用,需要读取特定 Object Storage Bucket 中的文件。最直接的做法,也是最不安全的做法,是将用户的 API 密钥或配置文件 ~/.oci/config 打包进容器镜像,或者通过环境变量注入。这两种方式都意味着长期有效的静态凭证暴露在应用环境中,一旦容器被攻破,凭证泄露的风险极高。在真实项目中,这种硬编码凭证的做法是绝对无法接受的。

我们需要一个动态的、临时的、遵循最小权限原则的认证机制。目标是让应用实例在启动时能自动获取一个仅能访问其所需资源的临时身份,且这个身份随着实例销毁而失效。这个过程不应涉及任何手动配置或分发长期有效的密钥。OCI 的 IAM (Identity and Access Management) 体系中的实例主账户 (Instance Principals) 和动态组 (Dynamic Groups) 正是为解决这类问题而设计的。

整个方案的核心思路是:

  1. 为 Nomad Client 节点所在的 Compute Instance 赋予一个可被 IAM 识别的身份。
  2. 创建一个动态组,其成员规则能自动包含所有这些 Nomad Client 节点。
  3. 授予这个动态组一个精确的、最小化的 IAM 策略,例如,仅允许读取某个特定 Bucket。
  4. Flask 应用内部署的 OCI SDK 将自动利用该身份,无需任何外部凭证即可向 OCI API 发出签名请求。

我们将使用 Terraform 来完整地、可重复地编排整个基础设施,包括网络、计算实例、IAM 规则以及 Nomad 的部署。

第一步:通过 Terraform 构筑基础设施

在生产环境中,基础设施的任何变更都应该是代码化的。我们使用 Terraform 来定义所有 OCI 资源。首先是网络和计算实例。这里的关键点在于为 Nomad Client 节点的实例打上特定的标签,这个标签将成为它们加入动态组的依据。

# main.tf

provider "oci" {
  tenancy_ocid     = var.tenancy_ocid
  user_ocid        = var.user_ocid
  fingerprint      = var.fingerprint
  private_key_path = var.private_key_path
  region           = var.region
}

# 网络资源定义 (VCN, Subnet, Security List)
# ...此处省略标准网络设置代码...

# 定义 Nomad Client 节点的计算实例
resource "oci_core_instance" "nomad_client" {
  count               = 3
  availability_domain = data.oci_identity_availability_domain.ad.name
  compartment_id      = var.compartment_ocid
  shape               = "VM.Standard.E4.Flex"
  
  shape_config {
    ocpus         = 2
    memory_in_gbs = 16
  }

  source_details {
    source_type = "image"
    source_id   = var.instance_image_ocid # Oracle Linux 8
  }

  create_vnic_details {
    subnet_id        = oci_core_subnet.private_subnet.id
    assign_public_ip = false
  }

  metadata = {
    ssh_authorized_keys = file(var.ssh_public_key_path)
    user_data           = base64encode(file("cloud-init/nomad-client.yaml"))
  }

  // 这里的 defined_tags 是整个方案的关键连接点
  // 我们使用一个预先定义好的标签命名空间 "NomadCluster" 和键 "Role"
  defined_tags = {
    "NomadCluster.Role" = "client"
  }

  preserve_boot_volume = false
}

在上述代码中,defined_tags 是核心。我们为所有 Nomad Client 实例都打上了 NomadCluster.Role = client 的标签。与 freeform_tags 不同,defined_tags 需要预先在 OCI 控制台或通过 Terraform 定义命名空间和键,这提供了更强的治理能力,是生产环境中的最佳实践。这个标签是实例的静态元数据,IAM 服务可以读取它。

第二步:定义 IAM 动态组与策略

有了可识别的实例,我们现在可以定义一个动态组,自动将所有符合条件的实例纳为成员。

# iam.tf

# 定义一个动态组,匹配所有带有特定标签的实例
resource "oci_identity_dynamic_group" "nomad_clients_group" {
  compartment_id = var.tenancy_ocid # 动态组必须在租户级别定义
  name           = "NomadClientsDynamicGroup"
  description    = "Dynamic group for all Nomad client nodes"

  # 匹配规则是这里的灵魂
  # `tag.NomadCluster.Role` 对应我们之前设置的 defined_tags
  matching_rule = "ALL {tag.NomadCluster.Role = 'client'}"
}

# 定义一个 IAM 策略,授予上述动态组访问 Object Storage 的权限
resource "oci_identity_policy" "nomad_object_storage_policy" {
  compartment_id = var.compartment_ocid
  name           = "NomadObjectStoragePolicy"
  description    = "Allow Nomad clients to read a specific bucket"
  statements = [
    # 策略语句必须尽可能精确,遵循最小权限原则
    # 'Allow dynamic-group <group_name> to read objects in compartment <comp_name> where target.bucket.name = '<bucket_name>''
    "Allow dynamic-group ${oci_identity_dynamic_group.nomad_clients_group.name} to read objects in compartment id ${var.compartment_ocid} where target.bucket.name = 'my-app-data-bucket'"
  ]
}

这段 Terraform 代码做了两件至关重要的事:

  1. oci_identity_dynamic_group: 创建了一个名为 NomadClientsDynamicGroup 的动态组。它的 matching_rule 会持续扫描租户下的所有计算实例,任何实例只要拥有 NomadCluster.Role = 'client' 这个已定义标签,就会自动成为该组的成员。实例被销毁,则自动移出该组。
  2. oci_identity_policy: 创建了一条策略,将权限授予了 NomadClientsDynamicGroup。这条策略非常具体:只允许读取(read objects)指定区间(compartment id ...)中名为 my-app-data-bucket 的存储桶。任何其他操作(如写入、删除)或其他存储桶,都将被拒绝。这就是最小权限原则的体现。

至此,OCI 基础设施层面已经准备就绪。任何运行在这些 Nomad Client 节点上的进程,只要使用支持实例主账户的 OCI SDK,就天然继承了这项权限。

第三步:Nomad 任务定义

现在我们来看应用层。Nomad 的任务定义文件(Jobspec)非常标准,因为认证的魔法发生在应用代码与 OCI IAM 之间,Nomad 对此是无感的。这恰恰是这种模式的优雅之处:它对调度系统是透明的。

# flask_app.nomad

job "flask-oci-app" {
  datacenters = ["dc1"]
  type        = "service"

  group "api" {
    count = 2

    network {
      port "http" {
        to = 5000
      }
    }

    task "server" {
      driver = "docker"

      config {
        image = "my-registry/flask-oci-demo:1.0.0"
        ports = ["http"]
      }

      resources {
        cpu    = 256 # MHz
        memory = 256 # MB
      }

      service {
        name = "flask-api"
        port = "http"
        
        check {
          type     = "http"
          path     = "/health"
          interval = "10s"
          timeout  = "2s"
        }
      }
    }
  }
}

这里没有任何与凭证相关的配置,没有 env 块来注入 OCI_USER_OCID,也没有 template 块来渲染配置文件。应用容器被调度到任何一个 Nomad Client 节点上,它所在的底层环境就已经具备了访问 OCI 的能力。

第四步:Flask 应用与 OCI SDK 集成

这是将所有环节串联起来的最后一环。Flask 应用需要使用 OCI Python SDK 来与 Object Storage 通信。关键在于如何初始化 SDK 的客户端。

一个常见的错误是直接使用默认的 oci.config.from_file(),这会尝试从 ~/.oci/config 加载配置,在我们的无凭证化容器环境中必然会失败。正确的做法是使用 oci.signer.InstancePrincipalsSecurityTokenSigner

# app/main.py

import os
import oci
from flask import Flask, jsonify, abort
import logging
from logging.handlers import RotatingFileHandler

# --- Production-Grade Logging Setup ---
# 在生产环境中,日志应该结构化并且输出到标准输出,由容器运行时管理
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s'
)

app = Flask(__name__)

# --- OCI Client Initialization ---
# 这部分是核心,它决定了应用如何进行认证
def get_object_storage_client():
    """
    Initializes and returns an OCI Object Storage client using
    Instance Principals for authentication.
    """
    try:
        # signer 会自动处理与 OCI 元数据服务的通信,获取临时令牌
        signer = oci.signer.InstancePrincipalsSecurityTokenSigner()
        
        # 将 signer 传递给客户端配置
        # 无需任何 API Key 或配置文件
        client = oci.object_storage.ObjectStorageClient(config={}, signer=signer)
        return client
    except Exception as e:
        # 如果应用没有运行在 OCI 实例上,或者元数据服务不可达,这里会抛出异常
        logging.error(f"Failed to initialize OCI client with Instance Principals: {e}")
        return None

# --- Application Logic ---
TARGET_BUCKET = "my-app-data-bucket"
NAMESPACE = os.environ.get("OCI_NAMESPACE") # 推荐从环境变量获取 namespace

@app.route("/health", methods=["GET"])
def health_check():
    return jsonify({"status": "ok"}), 200

@app.route("/list-objects", methods=["GET"])
def list_objects():
    if not NAMESPACE:
        logging.error("OCI_NAMESPACE environment variable not set.")
        abort(500, "Server configuration error: OCI namespace is missing.")

    client = get_object_storage_client()
    if not client:
        abort(503, "Service unavailable: Could not connect to OCI services.")

    try:
        # 使用客户端列出存储桶中的对象
        response = client.list_objects(NAMESPACE, TARGET_BUCKET)
        
        # 只返回对象名称列表
        object_names = [obj.name for obj in response.data.objects]
        
        logging.info(f"Successfully listed {len(object_names)} objects from bucket {TARGET_BUCKET}.")
        return jsonify({"bucket": TARGET_BUCKET, "objects": object_names})

    except oci.exceptions.ServiceError as e:
        # 这是关键的错误处理部分
        # 如果 IAM 策略不正确,会在这里捕获到 404 (Not Found) 或 403 (Forbidden) 错误
        logging.error(f"OCI Service Error: Status {e.status} - {e.message}")
        if e.status == 404:
            abort(404, f"Bucket '{TARGET_BUCKET}' not found or not accessible.")
        else:
            abort(e.status, f"Permission denied or OCI service error: {e.code}")
    except Exception as e:
        logging.error(f"An unexpected error occurred: {e}")
        abort(500, "An internal server error occurred.")

if __name__ == "__main__":
    # 在生产中应使用 Gunicorn 或 uWSGI
    app.run(host="0.0.0.0", port=5000)

代码分析:

  1. get_object_storage_client(): 这个函数是认证逻辑的核心。oci.signer.InstancePrincipalsSecurityTokenSigner() 创建了一个签名器对象。当 SDK 使用这个签名器时,它不会查找本地配置文件。相反,它会向 OCI 实例的元数据服务 endpoint (http://169.254.169.254/opc/v2/...) 发送请求。
  2. 认证流程: 该元数据服务验证请求来自本机后,会代表该实例向 IAM 服务请求一个临时的、有时效性的安全令牌。IAM 服务检查该实例所属的动态组,并根据授予动态组的策略,生成一个包含相应权限的令牌,返回给 SDK。
  3. API 调用: SDK 使用这个临时令牌对发往 Object Storage 的 API 请求进行签名。Object Storage 服务收到请求后,会向 IAM 验证签名的有效性及其权限,验证通过后才执行操作。
  4. 错误处理: except oci.exceptions.ServiceError as e 是必不可少的。如果 Terraform 中的 IAM 策略写错了,或者动态组的匹配规则有误,应用虽然能启动,但在调用 OCI API 时会收到明确的权限错误(如 403 或 404)。详尽的日志记录对于调试这类权限问题至关重要。

架构与流程的可视化

为了更清晰地理解整个认证流程,我们可以用 Mermaid 图来描述。

sequenceDiagram
    participant User
    participant FlaskApp as Flask App (in Nomad Task)
    participant OCI_SDK as OCI Python SDK
    participant IMDS as OCI Instance Metadata Service
    participant OCI_IAM as OCI IAM Service
    participant OCI_ObjectStorage as OCI Object Storage

    User->>+FlaskApp: GET /list-objects
    FlaskApp->>+OCI_SDK: get_object_storage_client()
    Note over OCI_SDK: Initializes with InstancePrincipalsSecurityTokenSigner
    OCI_SDK->>+IMDS: Request temporary security token
    IMDS-->>-OCI_SDK: Returns instance certificate
    OCI_SDK->>+OCI_IAM: Request session token, presenting instance cert
    Note over OCI_IAM: Validates instance identity, checks Dynamic Group memberships & policies
    OCI_IAM-->>-OCI_SDK: Issues short-lived session token with specific permissions
    OCI_SDK-->>-FlaskApp: Returns initialized ObjectStorageClient
    
    FlaskApp->>+OCI_SDK: list_objects(bucket_name)
    Note over OCI_SDK: Signs the API request with the session token
    OCI_SDK->>+OCI_ObjectStorage: Signed API Request
    OCI_ObjectStorage->>OCI_IAM: Validate token & permissions
    OCI_IAM-->>OCI_ObjectStorage: Validation OK
    OCI_ObjectStorage-->>-OCI_SDK: Returns list of objects
    OCI_SDK-->>-FlaskApp: Returns response data
    FlaskApp-->>-User: 200 OK with JSON payload

这个流程图清晰地展示了应用是如何通过实例元数据服务作为跳板,从 IAM 获取临时身份,从而避免了任何形式的静态凭证管理。

当前方案的局限性与未来展望

这个方案优雅、安全,并且完全可以通过 IaC 进行管理,非常适合生产环境。但在真实项目中,我们必须清楚它的边界和局限性。

最主要的局限性在于权限粒度。当前模型的权限是授予计算实例(即 Nomad Client 节点),而非运行于其上的单个 Nomad 任务。这意味着,如果节点 A 属于 NomadClientsDynamicGroup,那么被调度到节点 A 上的所有任务(Task A, Task B, …)都共享相同的 OCI 权限。如果 Task B 本身并不需要访问 Object Storage,它依然拥有了这个权限,这在某种程度上违背了更严格的最小权限原则。

对于需要更精细化、任务级别权限控制的场景,可以探索以下路径:

  1. 多 Nomad Client 池: 创建多个 Nomad Client 节点池,每个池有不同的 OCI 标签,对应不同的动态组和 IAM 策略。例如,一个 storage-intensive 池和一个 compute-only 池。通过 Nomad 的节点属性和任务的 constraint,可以将需要特定权限的任务调度到特定的节点池。这增加了运维复杂性,但实现了更细的权限划分。
  2. Sidecar 代理与凭证注入: 引入一个特权的 Sidecar 容器,它与应用容器在同一个 Nomad 任务组中。这个 Sidecar 负责与一个外部密钥管理系统(如 HashiCorp Vault)交互,为应用动态获取凭证。Vault 可以配置 OCI Secrets Engine,它能基于 Vault 的角色动态生成临时的 OCI API 密钥,并将其注入到应用容器的共享内存卷中。这种方式能实现任务级别的权限控制,但架构也更复杂。
  3. 生态系统演进: 期待云服务商和调度系统之间更深度的集成。例如,如果 OCI 未来提供类似 AWS IAM Roles for Service Accounts (IRSA) 或 GCP Workload Identity 的机制,并且 Nomad 能原生支持它,那么就可以将 OCI IAM 角色直接绑定到 Nomad 的任务身份上,这将是最终极、最优雅的解决方案。

尽管存在粒度上的限制,但对于许多中小型项目或者内部信任度较高的环境,基于实例主账户和动态组的方案已经提供了远超静态凭证的安全性,同时保持了架构的简洁和运维的低成本。它是一个非常实用的起点。


  目录