背景#

这台 Jenkins(2.580.2)是非生产环境的构建机,控制器在办公网里,出公网只走办公室的出口地址,AWS 那边访问不到它。上面几十个流水线推镜像到 ECR 时,都绑定凭据库里同一把长期 access key。这把 key 背后的 IAM 用户有 ECR、ECS、S3、Lambda 的全权限,还是两个 EKS 集群的管理员,所以任何一个作业,以及任何能改动某个作业的人,都能用上这些权限。key 轮换过,但只要还是长期 key,它从构建日志、备份、workspace 或者控制器上共享的 ~/.docker/config.json 里漏出去一次,就又是长期有效的。

这次的目标是让 Jenkins 里不再存 AWS key:每次构建拿一份只属于这个作业的角色、最长 1 小时的临时凭据。动手前定了几条约束,后面的做法基本都是从这里推出来的:

  • 没切换的作业行为一点不变。同一个 Jenkinsfile 往往同时服务 qa、staging 和生产分支,生产分支要等搬离这台 Jenkins 之后再处理,在那之前继续用原来的 key。
  • AWS 访问不到 Jenkins,签发方(issuer)的公开文件要放在别处。
  • 一个作业一个作业地切,每切一个都能单独验证和回退。

第一批只处理只用 AWS 登录 ECR 和推镜像的 QA、STG 作业,最后切了 59 个。还用到 ECS、EKS 的作业以及生产构建另外处理,不在这篇里。

一次构建怎么换到临时凭据#

Jenkins 这边用 oidc-provider 插件(这里装的是 234.vca_b_26b_f4b_dda_)。它提供一种凭据类型「OpenID Connect id token as file」:流水线绑定这个凭据时,插件用自己保管的私钥现签一个 JWT,写进一个临时文件。AWS CLI 读到 AWS_ROLE_ARN 和 AWS_WEB_IDENTITY_TOKEN_FILE 这两个环境变量,就会自己调 sts:AssumeRoleWithWebIdentity,整个过程不需要任何 key。

sequenceDiagram autonumber participant B as 构建 participant J as oidc-provider(控制器) participant S as AWS STS participant K as S3 上的 issuer 文件 B->>J: 绑定凭据 J-->>B: 用私钥现签 token,写进临时文件 B->>S: AssumeRoleWithWebIdentity(角色 ARN + token) S->>K: 取 openid-configuration 和 jwks S->>S: 验签名,按信任策略查 aud、sub、来源地址 S-->>B: 临时凭据,最长 1 小时 Note over B: 离开绑定块时 token 文件被删除

token 里和这件事有关的字段:

字段 值 AWS 怎么用
iss issuer 地址,这里是一个 S3 桶的 HTTPS 地址 必须是 IAM 里登记过的 OIDC provider
aud sts.amazonaws.com,在凭据上配置 信任策略要求它,别处用的 token 拿来换不了
sub 作业的 URL,插件默认就是这个 信任策略列出精确的作业 URL,只有这些作业能换
build_number 构建号 不参与判断,方便对日志
iat、exp 签发和过期时间 插件默认 3600 秒;全局设置里改成了 600,测试构建里 exp - iat 确实是 600

STS 换凭据时检查签名、aud、sub 和过期时间。信任策略里我还加了 aws:SourceIp,只接受办公室出口地址。我没在 AWS 文档里找到 STS 在这一步会不会评估这个条件的明确说法,就实测了一次:建一个只比测试角色多了「只接受 192.0.2.1」这一条的角色,从办公室去换,被拒绝了。所以这个条件在换凭据时是生效的。角色的权限策略里另有一条「来源不是办公室出口就拒绝」,凭据被带出办公网也用不了。

issuer 的两份文件放在 S3 上#

AWS 要能通过公网 HTTPS 读到 issuer 的 /.well-known/openid-configuration 和 jwks。Jenkins 不对外开放,插件的 README 也写了另一条路:凭据里填一个外部 issuer 地址,然后自己托管「two small, static JSON files」,Jenkins 照旧签名。于是建了一个 S3 桶,issuer 就是这个桶的地址。好处是以后 Jenkins 重建或者换主机名,AWS 侧的信任不用动。

密钥对是插件在创建凭据时自己生成的,私钥加密存在 Jenkins 的凭据库里,界面和 API 都不显示。公钥可以从插件的一个端点按 JWKS 格式取到:

https://jenkins.example.com/descriptorByName/io.jenkins.plugins.oidc_provider.IdTokenFileCredentials/jwks?id=<凭据 ID>&issuer=<issuer 地址>

凭据上的 issuer 和参数对不上时它返回 404,所以能取到也顺带证明 issuer 配对了。取回的 JWKS 原样放进 Terraform 仓库,apply 时传到桶里。

IAM 的 OIDC provider 没有填 thumbprint。按 AWS 的说明,它用自己的受信根 CA 列表去校验 JWKS 地址的 TLS 证书,证书不是这些 CA 签的才退回 thumbprint;S3 的证书是公共 CA 签的,不用维护 thumbprint。

桶策略允许匿名读这两个对象,不能列目录;能改这两个文件的只有专门跑这套 Terraform 的管理角色。原因很直接:谁能改 jwks,谁就能放进自己的公钥,签出 AWS 认可的 token。原来那把 key 背后的用户有 S3 全权限,不在桶策略里拦住的话,它也能改。

这里踩到过一个坑。换密钥演练时,绕过 Terraform 往 jwks 里加了一把 key,之后 terraform plan 照样报 No changes:aws_s3_object 不会把对象内容读回来比较。修法是加 etag(桶用 SSE-S3 加密,ETag 就是内容的 MD5),再加一个 precondition,检查文件里有没有当前凭据那把 key:

resource "aws_s3_object" "jwks" {
  bucket       = aws_s3_bucket.issuer.id
  key          = "jwks"
  content      = file(local.jwks_file)
  content_type = "application/json"
  etag         = filemd5(local.jwks_file)

  lifecycle {
    precondition {
      condition = length([
        for k in try(local.jwks.keys, []) : k
        if try(k.kid, "") == var.credential_id && try(k.alg, "") == "RS256"
      ]) > 0
      error_message = "issuer/jwks.json has no RS256 key with kid \"${var.credential_id}\"."
    }
  }
}

每个 ECR 仓库一个角色#

角色由一个 Terraform 模块生成,信任策略长这样(地址和账号都是示意):

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::123456789012:oidc-provider/jenkins-oidc-example.s3.ap-northeast-1.amazonaws.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "jenkins-oidc-example.s3.ap-northeast-1.amazonaws.com:aud": "sts.amazonaws.com",
      "jenkins-oidc-example.s3.ap-northeast-1.amazonaws.com:sub": [
        "https://jenkins.example.com/job/apps/job/order-service/job/qa/",
        "https://jenkins.example.com/job/apps/job/order-service/job/staging/"
      ]
    },
    "IpAddress": { "aws:SourceIp": "198.51.100.10/32" }
  }
}

sub 用 StringEquals 列精确的 URL,不用通配。IAM 的条件运算符说明里,StringLike 的 * 可以出现在任何位置、匹配任意多个字符,自然也匹配 /。多分支项目的子作业按 Git 分支名生成,能推分支的人就能造出一个 URL,用通配就等于信任所有能建分支的人。模块里干脆加了校验,URL 里出现 * 或 ? 直接报错。

URL 最好从 Jenkins API 的 url 字段或真实 token 复制,不要手写。分支名里的 / 在作业名里已经是 %2F,到 URL 里就成了 %252F(release%252F1.2.3);名字里的空格只编码一次,是 %20。

权限只有两部分:ecr:GetAuthorizationToken,加上对自己仓库的推送动作(BatchCheckLayerAvailability、InitiateLayerUpload、UploadLayerPart、CompleteLayerUpload、PutImage、BatchGetImage)。不给 CreateRepository,也不碰 ECS。所有 CI 角色都挂同一个 permissions boundary 作为权限上限,角色自己的策略写得再宽,也超不过它。用 iam simulate-principal-policy 核对权限时,GetAuthorizationToken 要用资源 * 来模拟。这个动作不支持资源级权限,写成仓库 ARN 会得到一个误导人的 implicitDeny。

为什么不是整个 Jenkins 共用一个角色:

  • 共用一个角色,任何被信任的作业都能覆盖所有仓库的镜像 tag,QA、STG 部署的正是这些镜像。按仓库分开以后,日志里漏出去的 token 或临时凭据只影响一个仓库。
  • 把 59 个精确 URL 写进一个信任策略大约 5,700 个字符,超过默认配额 2,048 字符。配额可以申请提到 8,192,但作业还会继续加。
  • 共用角色加 session tag 做 ABAC 在技术上可行:AWS 接受平铺成字符串的 tag claim,插件的 claim 模板也能产出字符串。问题在 tag 的值只能用构建变量拼出来,而有的作业推的仓库和作业名对不上,拼不出仓库名。

最后是 30 个角色对应 59 个作业,在 Terraform 里用一个 map 加 for_each 生成。有的仓库由两个流水线推(一个老的多分支项目和一个新模板作业推同一个仓库),就把两边的作业 URL 都放进这个仓库的角色。少数 Dockerfile 的基础镜像也在 ECR 里,这几个角色另给了那个仓库的只读权限。

共享库里的 withAwsOidc#

流水线原来用 pipeline-aws 插件的 withAWS(credentials: ...) 和 ecrLogin()。按它的 README,withAWS 支持保存在 Jenkins 里的凭据、profile、AssumeRole、SAML,没有 web identity 这一项,所以迁过来的步骤一律改用 AWS CLI。为此在共享库里加了一个 step,下面是它的主体(删掉了参数校验和日志;stsSessionName 把会话名里 STS 不接受的字符换掉,并截到 64 个字符以内,DockerUtils 是库里已有的工具类):

def call(Map config = [:], Closure body) {
    String roleArn = DockerUtils.trimToEmpty(config.get('roleArn', env.AWS_ROLE_ARN ?: ''))
    String region = DockerUtils.trimToEmpty(config.get('region', env.AWS_REGION ?: 'ap-northeast-1'))
    String credentialsId = DockerUtils.trimToEmpty(config.get('credentialsId', 'aws-oidc-signing-key'))
    String sessionName = stsSessionName(config.get('sessionName', "${env.JOB_NAME}-${env.BUILD_NUMBER}"))
    Boolean isolateDocker = config.get('isolateDocker', true) as Boolean
    Boolean isolateHome = config.get('isolateHome', true) as Boolean

    // An empty value removes the variable inside the block.
    List<String> awsEnv = [
        "AWS_ROLE_ARN=${roleArn}",
        "AWS_ROLE_SESSION_NAME=${sessionName}",
        "AWS_REGION=${region}",
        "AWS_DEFAULT_REGION=${region}",
        'AWS_ACCESS_KEY_ID=',
        'AWS_SECRET_ACCESS_KEY=',
        'AWS_SESSION_TOKEN=',
        'AWS_PROFILE=',
        'AWS_SHARED_CREDENTIALS_FILE=/dev/null',
        'AWS_CONFIG_FILE=/dev/null',
    ]

    String scratchDir = (isolateDocker || isolateHome) ? "${pwd(tmp: true)}/aws-oidc-${env.BUILD_NUMBER}" : ''
    if (isolateHome) {
        awsEnv << "HOME=${scratchDir}/home"
    }
    if (isolateDocker) {
        awsEnv << "DOCKER_CONFIG=${scratchDir}/docker"
    }

    withCredentials([file(credentialsId: credentialsId, variable: 'AWS_WEB_IDENTITY_TOKEN_FILE')]) {
        withEnv(awsEnv) {
            try {
                if (scratchDir) {
                    String dir = DockerUtils.shellQuote(scratchDir)
                    sh("#!/bin/sh -e\nmkdir -p ${dir}/home ${dir}/docker\nchmod -R 700 ${dir}")
                }
                body()
            } finally {
                if (scratchDir) {
                    sh("#!/bin/sh\nrm -rf ${DockerUtils.shellQuote(scratchDir)}")
                }
            }
        }
    }
}

几处写法各有原因:

  • 静态 key、profile 和两个配置文件都清掉或指向 /dev/null。AWS CLI 的凭据链里它们排在 web identity 前面,环境里残留一个,就会悄悄用上别的身份。
  • HOME 指到这次构建的临时目录。这台控制器上的 AWS CLI(2.18.9)会把换来的临时凭据缓存在 ~/.aws/cli/cache,每次一个文件,过期也不清理:5 次测试构建之后那里有 13 个文件,之后的每个构建都读得到。
  • DOCKER_CONFIG 同理。docker login 默认写进 jenkins 用户共享的 ~/.docker/config.json,ECR 的登录在那里留 12 小时。
  • 流水线不传 credentialsId。换签名密钥时改的就是这个默认值,所有流水线一起切过去。

代价是块里只适合跑 AWS 和 Docker 命令,其他读家目录配置的工具(比如 Maven 找 ~/.m2)在块里找不到自己的配置。并发没有问题:两个作业各两次构建同时跑,每个构建拿自己的 token 和会话,互不影响。

流水线里的开关:默认路径不变#

切换靠作业所在分支的配置文件(Config File Provider 管的 qa_config、staging_config)里两行:

AWS_AUTH=oidc
AWS_ROLE_ARN=arn:aws:iam::123456789012:role/ci-ecr-push-order-service

不写进各分支共用的 common_config,生产分支也读那个文件。模板里的推送阶段变成这样:

String ecrCred = (env.DOCKER_DEPLOY_CREDENTIALS_ID ?: 'shared-aws-key').trim()
String awsAuth = (env.AWS_AUTH ?: '').trim()
String push = """
    aws ecr get-login-password --region ${ecrRegion} | docker login --username AWS --password-stdin ${ecrRegistry}
    docker tag ${localImage} ${ecrRegistry}/${ecrRepo}:${tag}
    docker push ${ecrRegistry}/${ecrRepo}:${tag}
"""
if (awsAuth == 'oidc') {
    withAwsOidc(roleArn: (env.AWS_ROLE_ARN ?: '').trim(), region: ecrRegion) {
        sh push
    }
} else if (awsAuth) {
    error("AWS_AUTH must be empty or oidc, got '${awsAuth}'")
} else {
    withAWS(credentials: ecrCred, region: ecrRegion) {
        sh push
    }
}

AWS_AUTH 写错时直接失败,不会悄悄退回存着的 key。每个服务各自一份的老 Jenkinsfile 改法类似:把 withAWS(...) 和 ecrLogin() 换成两个本地函数 withEcrAws(region, credentialsId) { } 和 ecrLoginCommand(),没设开关时它们原样调用 withAWS 和 ecrLogin()。设了开关时,withEcrAws 才动态加载共享库再调 withAwsOidc,没切换的分支连库都不加载。

改这些文件时又遇到几个和隔离有关的问题:

  • 有三个 Jenkinsfile 在推送块里用 mvn help:evaluate 算版本号,后面跟着 || true,失败时回落成 0.0.0。块里换了 HOME,Maven 找不到 ~/.m2,按这个写法会悄悄打出错的 tag。于是把算版本号的几行挪到了块外面。
  • 几个 Dockerfile 的基础镜像在 ECR 里。切换后第一次构建推送走的是角色,但 CloudTrail 里同一时间还有一条原来那把 key 的 BatchGetImage:拉基础镜像用的是别的作业留在共享 config.json 里的 ECR 登录。补法是给这几个角色基础镜像仓库的只读权限,构建前先经角色登录 ECR。
  • 多分支项目的配置文件要加 qa_config 时,不要重写整个项目的 config.xml,保存项目配置会触发一次 branch indexing,有新提交的分支会被自动构建、部署。用 Config File Provider 按单个文件保存的接口(<项目>/configfiles/saveConfig)就不会。

合并前用 Jenkins 的 pipeline-model-converter/validate 接口把改过的 Jenkinsfile 都校验了一遍。它只检查声明式的语法结构,判断不了某一步运行时会不会出错,所以真正的验证还是靠下一节的试跑。

逐个作业切换和核对#

顺序是先挑一个服务试点,QA 跑通再 STG,然后按服务成批推进。每个作业都是同一套动作:在分支配置文件里加两行,手动跑一次,然后核对。

构建日志看三样:有 withAwsOidc: role ... 这一行,而且角色是这个服务的;没有 [Pipeline] withAWS;没有长得像 token 或 key 的字符串。CloudTrail 再看两样:AssumeRoleWithWebIdentity 事件里,responseElements.subjectFromWebIdentityToken 正好是这个作业的 URL,来源地址是办公室出口;随后的 PutImage 由这个角色的会话完成。

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
  --start-time 2026-11-14T03:00:00Z --max-items 500 \
  --query 'Events[].CloudTrailEvent' --output json \
| jq -r '.[] | fromjson | [.eventTime, .sourceIPAddress, .requestParameters.roleArn,
          .responseElements.subjectFromWebIdentityToken, (.errorCode // "")] | @tsv'

还有一件和 OIDC 无关、但试跑前必须查的事:这些 QA、STG 作业跑完会把新镜像的 tag 写回 GitOps 仓库,试跑一次就等于一次部署。所以每个作业先比一下分支头的提交和环境里正在跑的 tag,一致的跑了也不会发布新东西;不一致的先确认差了什么(这次最多的一种是只改名了一个没用的 Jenkinsfile)。确实不能部署的那个作业,试跑时临时关掉了部署阶段。

59 个作业都切完以后,CloudTrail 里这些作业不再出现原来那把 key 的调用。最后一步是把模板里回落到存着的 key 的分支删掉:没设 AWS_ROLE_ARN 的作业直接失败,而不是悄悄用回 key。生产分支和还用 ECS、EKS 的作业仍然绑定那把 key,所以 key 本身还不能删,要等它们都迁走。

换签名密钥和紧急撤销#

插件 README 写着,要换密钥就「Update and re-Save」凭据,也就是重新保存一次就会生成新的密钥对。实际看到的 kid 就是凭据 ID,重新保存后 kid 不变、公钥变了,AWS 侧会立刻验不过。所以平时不要再保存这个凭据。换密钥的做法是新建一个 ID 不同的凭据,按这个顺序:

  1. 新建凭据,类型、issuer、audience 都一样;
  2. 把新公钥加进 jwks,和旧的并列,apply。AWS 立刻接受新 key,演练里 apply 之后 4 秒的那次构建就成功了;
  3. 改共享库 step 的默认凭据 ID,合并;
  4. 从 jwks 删掉旧 key,apply。之后第一次用旧 key 的换凭据就被拒,演练里没看到 STS 缓存 key 集的迹象,不过 AWS 没有文档说明这一点;
  5. 删掉旧凭据。

紧急撤销是一条命令:直接删 IAM 里的 OIDC provider。别用 terraform destroy -target,所有角色都依赖这个 provider,Terraform 会把它们一起删掉。演练里删掉之后,下一次构建在换凭据时失败:

InvalidIdentityToken: The web identity token provided could not be validated

已经发出去的临时凭据还会活到过期,最长 1 小时;要提前作废,给角色挂一条按 aws:TokenIssueTime 拒绝旧会话的内联策略(控制台上的「Revoke active sessions」做的就是这件事)。恢复时 terraform apply 会用同一个 ARN 重建 provider,角色的信任策略不用改,演练里 20 秒后的那次构建就成功了。

挡不住什么#

签名私钥在控制器的凭据库里。Jenkins 管理员(Script Console 能解密凭据库)、控制器主机的 root,以及在控制器上跑的构建代码都拿得到它,拿到就能给任意作业签 token、换任意角色。这台 Jenkins 的构建目前就跑在控制器上,所以「一个仓库一个角色」现在防的是泄漏和配置错误,防不住存心作恶的构建代码;构建搬到独立的 agent 之后,agent 只拿得到本次构建的 token,按仓库分角色才算真正隔离。

同样的道理也适用于流水线代码本身。共享库是作为受信任代码加载的,能改共享库或者共用 Jenkinsfile 仓库的人,就能让任何作业去换任何角色。所以在删掉那把长期 key 之前,还要先保护好这两个仓库(main 不能直接推,要评审),让 Jenkins 按 tag 加载共享库,并关掉全局库的「Allow default version to be overridden」。

和原来比,能确定的改进有三点:没有长期 key 可漏;漏出去的 token 600 秒内过期;临时凭据最长 1 小时,而且离开办公室出口就用不了。

小结#

这套做法的组成是:插件签 token、issuer 文件放在 S3、IAM 里登记 provider,加上每个 ECR 仓库一个只信任精确作业 URL 的角色。流水线一侧,共享库的 withAwsOidc 负责清环境和隔离 HOME、DOCKER_CONFIG,开关放在分支配置文件里,没切换的作业连代码路径都不变。59 个作业是一个一个切的,每个都对着构建日志和 CloudTrail 核对过。换密钥和紧急撤销各演练过一次,步骤写进了 Terraform 仓库的 README。剩下的是生产构建和用 ECS、EKS 的作业,以及最后删掉那把 key;在那之前,签名私钥和共享库仍然是要守住的两个点。

相关文章#

  • /posts/jenkins-vm-push-to-pull-deploy/:控制器上集中了所有凭据这件事,以及 CI 身份往短期凭据走的趋势

参考资料#