RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

AWS Summit 2024 で登壇・出展をしてきました!

暑くなってきましたね、CTOの @jumboOrNot です。 6/20, 21 で幕張メッセで行われた AWS Summit 2024 で登壇・出展してきたので少しだけ感想をば。

今回の出展・登壇内容

aws.amazon.com

弊社では生成AIの活用を社内向けやお客様向けでさまざま検証しており、一部お客様向けに実証実験をしています。 その中で Amazon Bedrock を活用したAIレッスンレポートという機能を提供していまして、その事例紹介として今回は機会をいただけました。

昨年に続き、2年連続でのブース・登壇ができている会社さんはあまりないと聞いていましてとてもありがたい機会をいただけたと思っています。

ミニステージ

ミニステージでは、現在弊社のEdTechLabにて R&D 中のプロダクト開発に参加している yuma さんから検証中の生成AIを活用したデモについて話していただきました。デモを含めて多くの方が足を止めて見てくれていました。やはり実際に動くものがあると気にしてたくさんの方が目を止めてくれますね。

セッション

1日目のyumaさんのステージに負けないように私も2日目のセッションでは気合を入れて臨みました。 資料を事前に提出する都合で、原稿などを用意しきれず・・・練習を重ねて臨みました。 発表内容や資料は後日公開されるようなので、ぜひご利用ください。エンジニア以外の方も多くくると伺っていたので、 私からは少し文脈を変えて、これから生成AIを使ってみる・使った開発にチャレンジする人向けに我々が悩んだことや工夫したことを話しました。 500名の前で話すのは緊張しましたが、VPoEからも「Think of the audiences as potatoes.」というアドバイスをいただき気合いで乗り越えました。 🍟

登壇後は多くの方にブースにもきていただき、質問やフィードバックをいただけたので充実した2日間でした。

終わってみて

今年の AWS Summit ではやはり生成AIへの取り組みが多くブースやセッションで話されていました。 また AWS からも Claude 3 の東京リージョン に向けた発表など、今後も力を入れていくであろう発表が多くされました。 ここしばらくは目まぐるしく生成AIに関する取り組みが発表され続けており、キャッチアップは大変ですが楽しみつつ、ユーザーさんやプロダクト運営に価値を作っていければと思います。

AWS Lambda から GCP CloudFunctions/Cloud Run を呼び出す (クライアントライブラリ編)

はじめに

こんにちは、DevOps グループの中島です。
前回 は REST API を自力で叩いて認証を行い、取得した ID Token で Cloud Functions のエンドポイントを呼び出しました。 今回はクライアントライブラリを使用する方法を見ていきたいと思います。 といってもコード自体は数行しかないので、そのコードにたどり着くまでの過程も書いてみます。

google.oauth2.id_token.fetch_id_token を使う

Cloud Functions を呼び出すためには ID Token が必要となるので、まずはこの ID Token を一発で取得できる方法を探していきます。 gcp id token python などで検索すると google-auth というライブラリの ユーザガイドがヒットし以下のような記述があります。

If your application runs on App Engine, Cloud Run, Compute Engine, or has application default credentials set via GOOGLE_APPLICATION_CREDENTIALS environment variable, you can also use google.oauth2.id_token.fetch_id_token to obtain an ID token from your current running environment. The following is an example

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

request = google.auth.transport.requests.Request()
target_audience = "https://pubsub.googleapis.com"

id_token = google.oauth2.id_token.fetch_id_token(request, target_audience)

書いてある内容を見ると上記のコードでいけそうな気がするので GOOGLE_APPLICATION_CREDENTIALS に、サービスアカウントの 認証情報構成ファイル のファイルパスを指定して試してみます。 補足ですが、Lambda にデプロイしたファイルは /var/task/ に配備されます。

// 実行結果
{
  "errorMessage": "Neither metadata server or valid service account credentials are found.",
  "errorType": "DefaultCredentialsError",
  "requestId": "e01c15bd-50e6-4c5c-913b-02e09d2aada0",
  "stackTrace": [
    ...
    "  File \"/var/task/google/oauth2/id_token.py\", line 295, in fetch_id_token_credentials\n    raise exceptions.DefaultCredentialsError(\n"
  ]
}

Function Logs
START RequestId: e01c15bd-50e6-4c5c-913b-02e09d2aada0 Version: $LATEST
[WARNING]   2024-04-11T06:19:36.615Z    e01c15bd-50e6-4c5c-913b-02e09d2aada0    Compute Engine Metadata server unavailable on attempt 1 of 3. Reason: HTTPConnectionPool(host='169.254.169.254', port=80): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x7f5294d9bd50>: Failed to establish a new connection: [Errno 111] Connection refused'))
...

しかし、これはうまくいきません。 なぜか Lambda の実行環境には存在しない Metadata server (169.254.169.254) につなぎに行こうとしています。

Lambda で動作していることを認識できていないのかと思い、以下のように aws.Credentials.from_file を使って明示的に AWS の認証情報を与えてもうまくいきません。

from google.auth import aws
from google.auth.transport.requests import AuthorizedSession
import google.auth.transport.requests
import google.oauth2.credentials
import google.oauth2.id_token

def get_id_token(service_account, audience):
    credentials = aws.Credentials.from_file(
        os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
    )
    session = AuthorizedSession(credentials)
    request = google.auth.transport.requests.Request(session)
    id_token = google.oauth2.id_token.fetch_id_token(request, audience)

    return  id_token

それもそのはずで、実装 を見に行くと以下のように書かれており、

  1. If the application is running in Compute Engine, App Engine or Cloud Run, then the ID token are obtained from the metadata server.
  2. If the environment variable GOOGLE_APPLICATION_CREDENTIALS is set to the path of a valid service account JSON file, then ID token is acquired using this service account credentials.
  3. If metadata server doesn't exist and no valid service account credentials are found, :class:~google.auth.exceptions.DefaultCredentialsError will be raised.
  1. は GCP 上で動作する場合のみ対象で、頼みの 2. は以下のように読み込んだサービスアカウントの構成ファイルが type = service_account の場合しか対応していません。
with open(credentials_filename, "r") as f:
    info = json.load(f)
    credentials_content = (
        (info.get("type") == "service_account") and info or None
    )

    from google.oauth2 import service_account

    credentials = service_account.IDTokenCredentials.from_service_account_info(
        credentials_content, target_audience=audience
    )

今回ダウンロードした認証情報構成ファイルは type = external_account です。

{
    "type": "external_account",
    "audience": "//iam.googleapis.com/projects/.../locations/global/workloadIdentityPools/.../providers/...",
    "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
    "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/...@....iam.gserviceaccount.com:generateAccessToken",
    "token_url": "https://sts.googleapis.com/v1/token",
    "credential_source": {
        "environment_id": "aws1",
        "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
        "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
        "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
    }
}

このため、google.oauth2.id_token.fetch_id_tokenを使うのは無理筋です。

IAM credentials の ID Token を取得する API を呼ぶ

google-auth 単体ではダメなのかもしれないと思い、公式の API クライアントを使用してみます。 以下のライブラリを使えるようにした上でコードを実行してみます。

  • google-api-python-client
  • google-cloud-iam
from google.auth import aws
from google.cloud import iam_credentials_v1

def get_id_token(service_account, audience):
    credentials = aws.Credentials.from_file(
        os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
    )
    client = iam_credentials_v1.IAMCredentialsClient(credentials=credentials)
    request = iam_credentials_v1.GenerateIdTokenRequest(
        name="projects/-/serviceAccounts/{}".format(service_account),
        audience=audience,
    )
    response = client.generate_id_token(request=request)
    return response.token

これはうまく動作します。ただし、こちら にある通り、このライブラリはメンテナンスモードとなっており、代わりに google-cloud-python を使うように書かれています。 しかし、リファレンス を見ると ID Token を取得する API は実装されていません! メンテナンスモードになったライブラリを使うのは気が引けるのと、余計な依存ライブラリが増えてサイズも大きくなってしまうので、また別の方法を探すことにします。

google-auth 再訪

どうにかして google-auth 単体でできないかと思い、ID Token 取得エンドポイントの generateIdToken で google-auth のコードを grep していると、 このような実装 があるのを見つけました。

_IAM_ENDPOINT = (
    "https://iamcredentials.googleapis.com/v1/projects/-"
    + "/serviceAccounts/{}:generateAccessToken"
)

エンドポイントの URL が書いてあるということは、それを呼んで ID Token を取得しているはずです。 この実装のあるファイル名は impersonated_credentials.py で、どこかで聞き覚えがある単語だと思ったら Workload Identity 連携を調べていたときに 偶然英語で開かれたドキュメントでみたものでした。 GCP ドキュメント上での日本語訳は"借用"です。サービスアカウントを借用して ID Token を生成する の"借用"です!

たぶんこれだということで、うまくいくことを祈りながら ドキュメント を参考に実装してみます。

import google.auth.transport.requests
from google.auth import aws
from google.auth import impersonated_credentials

def get_id_token(service_account, audience):

    # 実行環境 (Lambda) の認証情報を取得
    credentials = aws.Credentials.from_file(
        os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
    )

    # サービスアカウントを借用
    target_credentials = impersonated_credentials.Credentials(
      source_credentials=credentials,
      target_principal=service_account,
      target_scopes = ["https://www.googleapis.com/auth/cloud-platform"],
      lifetime=500)

    # ID トークンを取得
    imp_cred = impersonated_credentials.IDTokenCredentials(target_credentials, target_audience=audience)
    imp_cred.refresh(google.auth.transport.requests.Request())

    return imp_cred.token

これで無事 ID Token が取得できました。

ところで弊社には ChatGPT, Gemini, Bedrock を使用できる環境があるので、以下のようなお願いを形を変えて何度かしてみましたが、架空の API を作り上げてしまったりしてうまくいきませんでした。いつか上手に使えるようになりたいものです。

AWS Lambda で動作する Python アプリケーションにおいて、認証が必要な Cloud Functions を呼び出すコードを書いてください。Cloud Functions を呼び出す際のサービスアカウント認証情報は Workload Identity 連携を使用して取得するものとします。

おわりに

今回紹介したコードは、認証が必要な Cloud Functions を Lambda から呼び出す構成において AWS と GCP の設定が問題ないかを検証するために書いたものです。 実際のアプリケーションは Go で実装されこのコードは使われなかったので、ここで供養することにしました。
この記事がどなたかのお役に立てば幸いです。

生成AI のガイドラインを Guardrails for Amazon Bedrock でサポートする

ChatGPT を皮切りにAIチャットボットを導入する企業が増えてきました。Amazon Bedrockは、大規模言語モデル(LLM)を使用したアプリケーション開発のためのプラットフォームであり、最近Guardrailsという機能が追加されました。申し遅れました、CTO室の塚田です。

生成AIを活用する際、適切なガイドラインの設定は欠かせません。Amazon Bedrockの新機能であるGuardrailsは、このガイドラインの設定と実施をサポートするツールです。

Guardrailsでは以下の3つの機能が利用可能です:

  1. トピックの拒否
  2. コンテンツフィルター
  3. 個人情報の再編集

これらの機能は、以下のような問題を回避するために設計されています:

  • ユーザー入力に有害な言葉・内容が含まれている
  • 生成AIの応答に意図せず有害な言葉・内容が含まれてしまう
  • 悪意のあるユーザーによる生成AIへの攻撃により、不適切な応答や情報漏洩が起こる
  • 機密情報や個人情報がユーザー入力や生成AI応答に含まれてしまう
  • 業務上都合の悪い言葉・内容がユーザーと生成AIの会話に含まれる

名前の通り、あらかじめガードレールを設置することで、これらのリスクを軽減できます。

具体的な使用方法は、Amazonの公式ブログ をご参照ください。 公式ブログでは、GuardrailsをBedrockに作成し、"CompetitorY"という単語をNGワードに指定する例が紹介されています。

Pythonを使ってBedrockを実行する際のGuardrailsの使用方法は、ブログには詳しく説明されていませんので Guardrailsを活用したサンプルコードは以下の通りです。

import boto3
import json
import os

client = boto3.client('bedrock', region_name='us-east-1')
bedrock_runtime = boto3.client('bedrock-runtime', region_name='us-east-1')

guardrail_identifier = os.environ['GUARDRAIL_IDENTIFIER']
guardrail = client.list_guardrails(guardrailIdentifier=guardrail_identifier)['guardrails'][-1]

def invoke_model(client, prompt, model,
    accept = 'application/json', content_type = 'application/json',
    max_tokens  = 512, temperature = 1.0, top_p = 1.0, top_k = 200, stop_sequences = [],
    guardrailIdentifier=guardrail['id'],
    guardrailVersion=guardrail['version'],
    trace="ENABLED"):

    input = {
        'max_tokens': max_tokens,
        'stop_sequences': stop_sequences,
        'temperature': temperature,
        'top_p': top_p,
        'top_k': top_k,
        "anthropic_version": "bedrock-2023-05-31",
        "messages": [{"role": "user", "content": prompt}]
    }
    body=json.dumps(input)
    response = client.invoke_model(body=body, modelId=model, accept=accept, contentType=content_type,
                             guardrailIdentifier=guardrailIdentifier,guardrailVersion=guardrailVersion,trace=trace)
    response_body = json.loads(response.get('body').read())
    output = response_body.get('content')[0]['text']
    return output, response_body

def get_output(prompt, model, max_tokens, temperature, top_p):
    output, response_body = invoke_model(
        client=bedrock_runtime,
        prompt=prompt,
        model=model,
        temperature=temperature,
        top_p=top_p,
        max_tokens=max_tokens,
    )
    print("Guardrail Trace:")
    print(json.dumps(response_body, indent=2))

    print("Response:")
    print(output)

prompt = os.environ['MSG']
model = "anthropic.claude-3-haiku-20240307-v1:0"

max_tokens = 1024
temperature = 0.1
top_p = 0.9

get_output(prompt, model, max_tokens=max_tokens, temperature=temperature, top_p=top_p)

この例では anthropic.claude-3-haiku を使うようにしていますが、ここは柔軟に変更が可能です。

GUARDRAIL_IDENTIFIER には Guardrails の ID をブラウザで確認した上で設定してください

guardrails

pip install boto3 --target .
export GUARDRAIL_IDENTIFIER='your-guardrail-identifier'
export MSG='CompetitorY という会社について教えてください'
python guardrails.py

ガードレールが有効な場合、フィルタリングに設定した"CompetitorY"がブロックされ、以下のような結果が返ってきます:

Guardrail Trace:
{
  "type": "message",
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "Sorry, the model cannot answer this question."
    }
  ],
  "amazon-bedrock-trace": {
    "guardrail": {
      "input": {
        "xxxxxxxx": {
          "wordPolicy": {
            "customWords": [
              {
                "match": "CompetitorY",
                "action": "BLOCKED"
              }
            ]
          }
        }
      }
    }
  },
  "amazon-bedrock-guardrailAction": "INTERVENED"
}
Response:
Sorry, the model cannot answer this question.

今回の例はわかりやすいように、NGワードを用いたブロックの例ですが、マスキングすることも可能です。

Guardrailsは、事前に設定したルールに基づいてユーザー入力や生成AIの応答をフィルタリングする機能です。 NGワードや禁止トピックを設定することで、不適切なコンテンツを排除、必要であれば名前、住所、電話番号、Eメールアドレスなどの情報を自動的にマスキングできます。

LLMを使用する際は、有害な言葉や内容、機密情報、個人情報などが含まれるリスクがあるため、Guardrailsのような機能を活用し、適切なガイドラインを設定することが重要です。

Guardrailsを使うことで、生成AIの活用における様々なリスクを軽減し、安全で適切な運用が可能となります。企業におけるAIチャットボットの導入が進む中、Guardrailsは生成AIガイドラインの設定と実施に大きく貢献してくれると考えております。

AWS Lambda から GCP CloudFunctions/Cloud Run を呼び出す (REST API 編)

はじめに

こんにちは、DevOps グループの中島です。
最近タイトルのような Lambda から、認証が必要な CloudFunction エンドポイントを呼び出すアーキテクチャを実装する機会がありました。 認証を行うには、クライアントライブラリを利用する方法 (推奨) と REST API を叩いて自力でやる方法がありますが、 内部の動作を理解してこそライブラリを使う権利が与えられると思いますので、今回は REST API を叩く方法を紹介します。

概要

以下のような流れで認証を行った上で、最終的に Cloud Functions を呼び出します。

引用元: https://cloud.google.com/iam/docs/tutorial-cloud-run-workload-id-federation

  1. Lambda の認証情報を用いて、IdP Token を生成する
  2. IdP Token を用いて、GCP STS から Federated Token を取得する
  3. Federated Token を用いて GCP IAM Credentials から ID Token を取得する
  4. ID Token を用いて、Cloud Functions を呼び出す
用語 説明
IdP Token Workload Identity 連携を使用すると、事前に GCP に Lambda を実行するロールの ARN 等が登録されているため、Lambda の認証情報を用いて GCP STS トークン (Federated Token) と交換できます。 この交換に必要となるトークンです。(参考)
(Workload Identity 連携の詳しい説明や必要なリソース、設定等については他所に譲ります)
Federated Token サービスアカウントの権限を借用 (Impersonation)するためのトークンです。この権限を用いて OAuth 2.0 アクセストークン (ID Token) を取得できます。(参考)
ID Token 認証を要求する Cloud Functions の呼び出しに必要なトークンです。(参考)

1. Lambda の認証情報を用いて、 IdP Token を生成する

IdP Token のフォーマットは こちら の subjectToken に記載があります。

If subjectToken is for AWS, it must be a serialized GetCallerIdentity token. This token contains the same information as a request to the AWS GetCallerIdentity() method, as well as the AWS signature for the request information. Use Signature Version 4. Format the request as URL-encoded JSON, and ...

つまり、GetCallerIdentity の呼び出し情報に SigV4 署名を付与したものが、IdP Token となります。

IdP Token は 仕様 に記載の通り以下の情報を含める必要があります。

項目名 設定値
url AWS STS のエンドポイント: https://sts.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15
method POST
headers リクエストヘッダ
authorization SigV4 署名
host sts.amazonaws.com
x-amz-date ISO 8601 形式の現在日時
x-goog-cloud-target-resource //iam.googleapis.com/projects/<project-number>/locations/global/workloadIdentityPools/<pool-id>/providers/<provider-id>
x-amz-security-token Lambda の実行環境で取得可能な AWS_SESSION_TOKEN

上記を参考に IdP トークンを生成するコードは以下のようになります。

def create_aws_token(project_number, pool_id, provider_id):
    # Request を利用して SigV4 で署名
    request = AWSRequest(
        method="POST",
        url="https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15",
        headers={
            "Host": "sts.amazonaws.com",
            "x-goog-cloud-target-resource": f"//iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/providers/{provider_id}",
        },
    )
    SigV4Auth(boto3.Session().get_credentials(), "sts", "us-east-1").add_auth(request)

    # Request の情報をもとに Token を生成
    token = {"url": request.url, "method": request.method, "headers": []}
    for key, value in request.headers.items():
        token["headers"].append({"key": key, "value": value})

    print(json.dumps(token, indent=2, sort_keys=True))

    return urllib.parse.quote(json.dumps(token))

print の出力は以下のようになり、仕様通りに作れていることが分かると思います。

{
  "headers": [
    {
      "key": "Host",
      "value": "sts.amazonaws.com"
    },
    {
      "key": "x-goog-cloud-target-resource",
      "value": "//iam.googleapis.com/projects/.../locations/global/workloadIdentityPools/.../providers/..."
    },
    {
      "key": "X-Amz-Date",
      "value": "20240410T064224Z"
    },
    {
      "key": "X-Amz-Security-Token",
      "value": "..."
    },
    {
      "key": "Authorization",
      "value": "AWS4-HMAC-SHA256 Credential=.../20240410/us-east-1/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token;x-goog-cloud-target-resource, Signature=..."
    }
  ],
  "method": "POST",
  "url": "https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15"
}

SigV4 署名の詳細は以下の画像のようになるのですが、こちら に詳しい説明がありますので、今回はライブラリの力を借りて詳細は割愛します。

https://docs.aws.amazon.com/images/IAM/latest/UserGuide/images/sigV4-using-auth-header.png

2. IdP Token を用いて、GCP STS から Federated Token を取得する

Federated Token は GCP STS の token エンドポイントから取得できます。API 仕様は こちら です。 audience に Workload Identity の情報を入力し、subject_token に 1. で作成したトークンを使用します。それ以外は規定の値です。

def get_sts_token(aws_token, project_number, pool_id, provider_id):
    url = "https://sts.googleapis.com/v1/token"
    data = {
        "audience": f"//iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/providers/{provider_id}",
        "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
        "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
        "scope": "https://www.googleapis.com/auth/cloud-platform",
        "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
        "subject_token": aws_token
    }
    response = requests.post(url, data=data)

    return response.json()['access_token']

3. Federated Token を用いて GCP IAM Credentials から ID Token を取得する

ID Token は GCP IAM Credentials, projects.serviceAccounts リソースの generateIdToken エンドポイントから取得できます。API 仕様は こちら です。 Authentication ヘッダに 2. で取得した Federated Token を認証情報として設定し、audience に呼び出したい CloudFunctions のエンドポイントを指定します。

def get_id_token(sts_token, service_account):
    url = f"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{service_account}:generateIdToken"
    headers = {
        "Authorization": f"Bearer {sts_token}",
        "Content-Type": "application/json; charset=utf-8"
    }
    data = {
        "audience": "https://asia-northeast1-....cloudfunctions.net/...",
        "includeEmail": "true"
    }
    response = requests.post(url, headers=headers, json=data)
    id_token = response.json()['token']
    
    return id_token

4. ID Token を用いて、Cloud Functions を呼び出す

あとは Authorization ヘッダに ID Token を設定して呼び出すだけです。

def call_cloud_functions(id_token):
    url = "https://asia-northeast1-....cloudfunctions.net/..."
    data = {
        "name": "Hello World"
    }
    headers = {
        "Authorization": f"Bearer {id_token}",
        "Content-Type": "application/json"
    }
    response = requests.post(url, headers=headers, json=data)

おわりに

各所に散らばるドキュメントの情報をつなぎ合わせる過程で、認証方式の理解が進んだように思います。 次回はクライアントライブラリ編を執筆する予定です。お楽しみに。

We're hiring!
弊社では、一緒に働いてくださるエンジニアを募集しています。
rarejob-tech.co.jp

#2 スクラムマスターを雇う時に聞いてみるとよい38個の質問に答えてみる(全5回)

まえがき

レアジョブテクノロジーズの三上です。

2回目の投稿です。前回から1年と4ヶ月振りの投稿です。お待たせし過ぎたかもしれません。

前回の記事 -> #1 スクラムマスターを雇う時に聞いてみるとよい38個の質問に答えてみる(全5回) - RareJob Tech Blog

早速ですが、今回もインタビューを受ける気持ちで直感で答えてゆきます。

スプリントプランニングについて

チームがもっとも価値のあるストーリーに取り組めるようにするためにどのようにスプリントプランニングにスクラムマスターとして貢献するか?

前提としてプロダクトバックログは優先度の高いもの、価値のあるバックログアイテムから順に並んでいるはずです。もしもそうなっていないのなら、まずはその議論からはじめます。

また「価値のあるストーリー」は組織の状況、市場の状況によって変動するものという前提です。各ストーリーの先には組織またはチームが達成したいゴールがあるはずです。毎日忙しいとゴールは見失いがちです。何度でも問いかけて良いと思います。

目指す先があって、チームの目線が揃ってこそ建設的なプランニングができると思うのでそこを意識してファシリテーションします。

ユーザーストーリーの価値をどんなメトリクスに基いて判断するか?どんなメトリクスは受け入れがたいものか?

ユーザーストーリーのWHOに依存すると思いました。例えばエンドユーザーであればCVRやLTVは成果として分かりやすく適切に思いますし、社内の人であればコスト削減の結果の数値だったり可能な限り定量的な目標にしたいです。

リファクタリングや負債解消を目的としたストーリーの場合、前提としてDORAなどのメトリクスを計測できているとプロダクトオーナーと優先度の会話もしやすくなると思います。

そんな我々もDORAメトリクスの計測をはじめました!!(大事)

受け入れがたいものは前述の逆で、抽象度の高いものや誰がどう判断できるのか説明できないものは避けるべきと思いました。

チームのコミットメントの権限を侵犯することなくどのようにもっとも価値のあるユーザーストーリーを選べるようにファシリテーションするか?

コミットメントの権限 の解釈が難しく思いましたが、チームとステークホルダー(PO)の間でデリバリーの確約に対するズレが生じた状況を想像しました。

ここでお互いの達成したいことは何だろうと考えた時に「価値のあるユーザーストーリーを選べる」ように支援することだと思いました。双方の「ズレ」が何なのか観察して問いかけ、解決に導くようにします。このような状況では、あくまで第三者的な観点を持ち、冷静でいることを意識しています。

どれくらいの時間をリファクタリングや重要なバグの修正や新しい技術やアイデアの調査につかうのが適切と考えるか?

正直なところ状況による と思いますが、経験上はリファクタリングが後回しになりがちな状況があるあるだと思いました。この状況の場合は、「ベロシティの3割程度はあえてプランニングに含めずに改善の時間として使う」ということを試したことがあります。この時間がなぜ必要で重要なのかはチーム全体で納得いくように支援します。

チームの個人にストーリーやタスクを割り当てようとするプロダクトオーナーをどう扱うか?

面白い質問ですね。笑

幸いこの状況になったことがありませんが、あるとしたらチームへの信頼がないのだと思いました。チームはあなたが思っているより優秀だし、マイクロマネジメントは必要ないと伝えます。逆にチームが未成熟なのであれば、自律思考になるようにチーム側を支援すると思います。

チームメンバーによるタスクのつまみ食いをどのように扱うか?

絶対にやってはいけない!とは強くは言わないですね。

なぜつまみ食いしたのかを確認して、それが必要なものだったとしたら透明性やベロシティの重要性を伝えます。自分の担当すべきアイテムが完了していて、スプリントゴールも達成しているって前提だったとしたらチームに共有した上でつまみ食いも良いんじゃないかと個人的には思います。

ユーザーストーリーが最終的に確定していないがスプリントの2日目には確定する状況で、プロダクトオーナーはそれをスプリントバッグログに入れようとしている。どのように行動するか?

完成の定義が決まっていないアイテムを計画に入れた場合、意図していないもの=価値の低いものになってしまう可能性があるのでそれを伝えます。また、ストーリーとして確定していないのでポイント見積もりも出来ていないので、他のストーリーが完成できないリスクもあるかもしれない。それでも追加したいですか?とまずは問いかけますね。

どうしても、というならあえて失敗して学ぶのもアリと思う。それがスクラムの強みでもありますし。

スクラムチームのメンバーがスプリントプランニングに参加したがらないだけでなく、時間の無駄だと考えている。このような態度をどう扱うか?

時間の無駄と感じる理由から聞きたいですが、実際にありそうなのは「プランニングが直接自分に関係のない話だ」と感じるとしたら、本当に関係ないか?と問いたい。チームが分業のようになってしまうのはよくあるパターンだと思うので

スクラムチーム内には、サブチームや階層は存在しない。これは、⼀度にひとつの⽬的(プロダクトゴール)に集中している専⾨家が集まった単位である。

というスクラムガイドにある記述を引用しつつ、チーム開発をするメリットを理解してもらえるよう会話します。多くのメンバーがそう感じるとしたらスクラム自体の採用を見直す議論も必要だと思いました。

終わりに

いくつかは回答に詰まるものもあり、改めて考え直す良いきかっけになりました。レアジョブテクノロジーズでの開発に少しでも興味を持っていただけたら嬉しいです。

We're hiring!
弊社では、一緒に働いてくださるエンジニアを募集しています。
rarejob-tech.co.jp

Buildkit でキャッシュを ECR に保存する

はじめに

こんにちは。DevOps グループの中島です。

弊社では CI ジョブ実行のために EC2 でホストした GitLab Runner (Docker Executor) を利用しており、Docker コンテナ上で CI ジョブを実行しています。 それらのジョブのうちコンテナイメージをビルドするジョブにおいて、時間がかかっている問題がありました。 今回はその問題に対して比較的新しい機能を用いた解決策の紹介と、処理の概要について説明したいと思います。

背景

ビルドに時間がかかっている理由としては、レイヤーキャッシュが利用されていないのが 1 つの要因としてあげられました。 コンテナビルド用の Docker は dood (Docker outside of docker) を採用しており、レイヤーキャッシュは EC2 上に保存されますが、Docker のキャッシュは EC2 の容量の問題から毎日削除しています。 もし削除していないにしても、SaaS のような実行環境ではどのサーバで CI ジョブが実行されるかは定まらず、ローカルのキャッシュは利用できないと考えるのは妥当といえます。

この問題の解決策として、AWS の記事 で紹介されていた ECR にキャッシュを保存する方法*1 を利用しビルド時間の削減を達成することができました。

実装

以下に実装のポイントを説明します。

gitlab-ci.yml

build:
  image: docker:25-rc
  script: ...

ECR へのキャッシュの保存に対応しているのが Docker のバージョン 25 からのため、CI ジョブを実行するコンテナには docker:25-rc を指定します。(現状では正式リリースされていないため rc を利用しています)

script

# キャッシュの格納先 ECR
REPO_URI=<account-id>.dkr.ecr.<my-region>.amazonaws.com/<repository_name>

# 1. Buildkit コンテナの作成
docker buildx create --use --name <buildkit_container_name>

# 2. キャッシュの格納先を指定してビルド
docker buildx build -t ${REPO_URI}:<image_tag> \
  -t ${REPO_URI}:latest \
  --load \
  --cache-to mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${REPO_URI}:cache \
  --cache-from type=registry,ref=${REPO_URI}:cache .

# 3. Buildkit コンテナの削除
docker buildx rm <buildkit_container_name>

script 内のポイントは以下のとおりです。

  1. docker buildx create --use で Builder の driver として、デフォルトの docker ではなく、docker_container を作成して利用する指定をします。(docker driver では インラインキャッシュ しか対応していません)

  2. docker buildx build でキャッシュの格納先を指定してビルドします。

オプション 説明
--load ローカルに保存する指定 (通常の docker build と異なりデフォルトではどこにも出力しないため、これを指定しないと warn が出ます)
--cache-to mode=max 最終レイヤだけでなく、全てのレイヤをキャッシュの対象とする
image-manifest=true,
oci-mediatypes=true
ECR に格納できる形式の指定
type=registry,ref=${REPO_URI}:cache キャッシュ格納先(書き込み)の指定
--cache-from type=registry,ref=${REPO_URI}:cache キャッシュ格納先(読み込み)の指定

以上の実装で、一度ビルドしたレイヤーが ECR に保存され、次回以降のビルドではそのキャッシュが利用されるようになります。

ビルドの全体像と処理の流れ

GitLab Runner 上では Docker コンテナで CI ジョブが動いていて、そのジョブが Buildkit 用の Docker コンテナを起動していたりします。 一応動作はしているものの、実際にはどのように各コンポーネントが連携して動いているのか全体像が把握できてないことから、どこの設定を変えれば何が変わるのかの確信が持てなかったため、少し詳しく調べてみました。 以下に図を用いてビルドの過程を示しています。

GitLab Runner Server 上では Docker が起動しています。gitlab-runner は CI ジョブ実行のため、Docker Engine API を用いて CI Job Container を起動します。

ホストの /etc/gitlab-runner/config.toml で CI ジョブは /var/run/docker.sock をマウントするように設定しているため (dood)、コンテナ内のジョブはこのソケットを経由してホストの dockerd にアクセスすることができます。

docker buildx create で docker_container の Builder (Buildkit Container)を作成します。コンテナの起動は前述したとおりマウントしたソケットを経由し Docker Engine API を用いてホスト側で行われます。

Buildkit Container が作成されました。このコンテナは /var/lib/buildkit 配下にキャッシュなどを保存しますが、このパスはホスト側のファイルシステムにマウントされています。

docker buildx buildでビルドを実行します。ci-job 内で buildx がクライアントとして buildkitd と通信し、Buildkit Container 内でイメージのビルドが実際に行われます。ビルドの出力は CI Job Container に返却されます。

docker buildx build--loadを指定しているので、イメージを読み込む Docker Engine API を呼び出し dockerd にイメージを保存するよう依頼します。 その後、同様にdocker push で保存したイメージをリモートにプッシュするよう依頼します。

終わりに

CI ジョブでイメージをビルドする処理の流れを追うことで、もやもやしていた感じがスッキリしました。自信を持って Buildkit を使えるようになった気がします。 このポストが誰かのお役に立てば幸いです。

We're hiring!
弊社では、一緒に働いてくださるエンジニアを募集しています。
rarejob-tech.co.jp

*1:イメージにキャッシュを埋め込むインラインキャッシュを利用するという方法もありますが、マルチステージビルドでは利用に制限があるため採用しませんでした。

ジョブスケジューラの導入

はじめに

こんにちは、DevOps グループの中島です。
年末年始は前からやろうと思っていたエルデンリングをプレイしました。DLC もそのうち出るらしいので楽しみです。
今回はジョブスケジューラの導入をしたときの話をシェアします。

背景

エンタープライズ向けでは、サーバの監視とジョブの管理を行うツールとして、日○や富○通の製品が業界標準として選ばれることが多いように思います。 一方で OSS だと有名どころは Rundeck あたりが候補になるでしょうか。 弊社では AWS を利用しているためその中で見繕うことをまずは考えたのですが、それ専用に提供しているサービスはありません。

そこで、要求仕様を挙げ、最適なものを OSS から選定することにしました。

要求仕様

必要な要件としては以下で、cron を画面操作に置き換えられれば良いという簡単なものとなっています。

  • 社内サーバで動作している cron を置き換えたい
  • サーバにログインせず、GUI から実行や実行結果の確認がしたい
  • ジョブのグルーピングはしたい
  • ジョブ定義のバックアップができる
  • 実際のサービス提供では利用しないため、特段 SLA は高くない

比較

今回は OSS のジョブスケジューラである、Rundeck, Dkron, Cronicle を比較してみました。

Rundeck Dkron Cronicle
開発元 PagerDuty Distributed Works PixlCore
GitHub Stars 5.2k 4k 3k
WebUI あり あり あり
言語 Java Go NodeJS
グルーピング あり なし あり
ジョブ定義のエクスポート あり なし あり

Rundeck は開発元がメジャーなため長期のサポートが期待出来そうです。また、導入事例が多くありトラブルが発生した際の対応が比較的容易と考えられます。 ただ、導入してみた感想としては、とにかく機能が豊富で、ジョブの定期実行をしたいだけなのに設定する必要のある項目が多くあり、学習コストが高いなと感じました。 GUI のデザインも少々分かりにくく(個人の感想です)、動作もサクサクという感じではありませんでした。

Dkron については Go で作られている点や、This is the only job scheduler in the market with truly no SPOF と謳っている部分が魅力に感じました。 しかし、求めているグルーピングやバックアップの機能が、商用サポートを購入しなければ利用できなかったり、そもそも存在していなかったので今回は見送りました。 高 SLA を求められる場合には選択肢に入るかもしれません。

Cronicle は、Dkron と同様に開発元があまり知られていないという懸念はあるものの、今回の要求仕様を満たしています。 それに GUI もシンプルで美しく(個人の感想です)、直感的に操作ができ、必要十分な情報を表示してくれていました。 機能性や拡張性、保守性はそれほど高い要求がされていないことから、今回は利便性の高い Cronicle を採用することにしました。

Cronicle の UI

終わりに

Cronicle を導入してから数ヶ月経ちますが、特に問題なく動いています。 ジョブスケジューラって探してみると意外とこれってのが無いんですよね。 もし導入を検討していたら、Cronicle を選択肢に入れてみて下さい。

We're hiring!
弊社では、一緒に働いてくださるエンジニアを募集しています。
rarejob-tech.co.jp