GitLab CI/CDをLambdaから実行してみた

GitLab CI/CDをLambdaから実行してみた

GitLab CI/CDをLambdaから動かしてみました
Clock Icon2025.01.23

お疲れさまです。とーちです。

みなさんはGitLab CI/CDはご存知でしょうか?このGitLab CI/CDをAPI経由で外部から動かすということをやってみたのでその方法を共有したいと思います。

GitLab CI/CDとは?

GitLab CI/CDは、GitLabが提供する継続的インテグレーション/継続的デリバリー(CI/CD)のためのツールです。GitHub Actionsをご利用の方であれば、その GitLab版というイメージを持っていただけると分かりやすいかと思います。
GitLab CI/CDの概要については以下をご参照ください。

https://gitlab-docs.creationline.com/ee/ci/

今回やりたいこと

通常、GitLab CI/CDはGitLabのリポジトリに変更があった際に自動実行されますが、「外部システムから任意のタイミングでパイプラインを実行したい」というケースもあるのではないでしょうか。例えばAWS上でのイベントをトリガーにGitLab CI/CDを動かしたい、等です。
本記事ではLambdaからGitLab CI/CDを実行する方法を説明してみようと思います。

前提

  • セルフマネージドなGitLabサーバをEC2で立てて、そこに対してAPIを実行します
  • GitLabサーバはCommunity Editionを使用しています
  • GitLab Runnerはセルフマネージドランナーで構成は「Autoscaling GitLab Runner on AWS EC2」の構成を取っています
  • GitLabサーバ及び、GitLab Runnerは既に用意されている状態とします

全体の構成

構成図はこんな感じです

image.png

GitLab CI/CD APIの基本

GitLabではREST APIを標準で提供しています。
そのためGitLabサーバにアクセスするときに使用するドメインを使ってAPIを実行することができます。この記事では例として "example.com" というドメインでGitLabサーバがホストされていることとします。
上記のドメインの場合、以下のようなURLでAPIリクエストを送ることができます。

https://example.com/api/v4/projects

2025年1月現時点ではAPIのパスは /api/v4 で必ず始まるようになっています。パイプラインをトリガーするためのURLの場合、以下のようになります。

https://example.com/api/v4/projects/{PROJECT_ID}/trigger/pipeline

PROJECT_IDはプロジェクト(GitHubでいうところのリポジトリ)ごとに付与されるIDで、プロジェクトの一般設定から確認できます。

image.png

上記のURLに対してcurlなどを実行すればAPIが実行できるわけですが、誰にでも実行できてしまうとセキュリティ面等で問題です。そのため、APIを実行するためには認証が必要になります。(認証が不要なAPIも一部あります)

認証方法(Personal Access Token等)

GitLab REST APIはいくつかの方法で認証できるようになっています。(REST API authentication | GitLabより)

  • OAuth 2.0 トークン
  • 個人/プロジェクト/グループアクセストークン
  • セッションクッキー
  • GitLab CI/CD ジョブ トークン

最初は個人アクセストークンでの実装を検討しましたが、個人アクセストークンにはこちらのドキュメントにも記載がある通り、必ず有効期限を設定する必要があります。Lambda等からトークンを参照してAPI実行する場合、1年ごとにトークンを変えるとなると運用の手間やそもそもトークンの更新忘れが発生しそうなことが気になりました。(もちろんセキュリティ的には定期的にローテーションするのがベストだとは思いますが)

そこで他にいい方法がないか調べていたところ、パイプライントリガートークンというパイプライン専用のトークンがあることがわかりました。

https://archives.docs.gitlab.com/17.5/ee/ci/triggers/index.html

このトークンは上記のドキュメントにも有効期限の記載はありませんし、実際に設定した際にも有効期限を決めるような設定項目はありませんでした。そのため今回はこちらのトークンを使用することにしました。

パイプライントリガートークンの発行方法についてはこの後、ご説明します。

それでは、実際の実装に移っていきましょう。

試してみた

それではさっそく試してみようと思います。
構築の流れは以下の通りです。

  1. GitLabサーバにプロジェクト(GitHubでいうところのリポジトリ)を作成
  2. GitLabサーバのプロジェクトに.gitlab-ci.ymlを作成
  3. パイプライントリガートークンを作成
  4. Secrets Managerを作成し値としてパイプライントリガートークンを設定
  5. Lambda関数の作成

GitLabサーバにプロジェクト(GitHubでいうところのリポジトリ)を作成

まずはプロジェクトを作成します。GitLabサーバにアクセスして以下のようなプロジェクトを作成しました。

image.png

GitLabサーバのプロジェクトに.gitlab-ci.ymlを作成

作成したプロジェクトの直下に.gitlab-ci.ymlを配置します。このファイルはGitHubでいうところの.github/workflows ディレクトリに配置されるymlファイルになります。つまり動かしたいGitLab CI/CDワークフローの処理が書かれたファイルです。

今回は以下のような簡素なワークフローを作成しました。

# ワークフロー全体の実行制御
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "trigger"
      when: always
    - when: never  # その他のトリガーでは実行しない

build-job:
  stage: build
  script:
    - echo "Hello, $GITLAB_USER_LOGIN!"

test-job1:
  stage: test
  script:
    - echo "This job tests something"

ポイントは workflow: から始まる一連の箇所です。 workflow: はGitLab CI/CDのパイプライン全体の動作を制御する設定を記載する項目で、ここに条件を記載することでパイプライン全体を一定の条件の時のみ起動することができます。- if: $CI_PIPELINE_SOURCE == "trigger" でパイプライントリガートークンを使用してAPI実行したときのみ起動するといったことが実現できます。

パイプライントリガートークンを作成

  • 続いてパイプライントリガートークンを作成します。トークンは以下の画面から作成します。
    • image.png
  • CI/CDの設定画面に移ったら、パイプライントリガートークン新しいトークンを追加 ボタンを押します。
    • image.png
  • 説明欄は必ず何かしら入力する必要があるので適当な文字を入れて パイプライントリガートークンの作成 をクリックします
  • これでトークンが作成されるので以下のボタンを押してトークン文字列を控えておきましょう
    • image.png

Secrets Managerを作成し値としてパイプライントリガートークンを設定

SecretsManagerを作成し先ほど作成したパイプライントリガートークンを値として設定します。

image.png

Lambda関数の作成

続いてLambda関数を作成します。Lambdaの設定自体はVPC Lambdaであることを除けば(今回はプライベートサブネットに存在するGitLabサーバのAPIを実行するためにVPC Lambdaにしています)特筆すべきことはありません。Lambdaの実行ロールにSecretsManagerを参照できる権限を付与するのは忘れないようにしてください。今回はパイプライントリガートークンをSecretsManagerに保存しLambdaでそれを参照してAPI実行するような作りにしているためです。

Lambdaのコードは以下の通りです。処理としてはSecretsManagerからトークンを取得し、GitLabサーバのAPIを実行するだけのシンプルなものです。

GitLabの公式ドキュメントによると、APIリクエストを行う際に注意すべき点が2つありますので、コードも以下の形式でリクエストを送っています。

  • POSTでリクエスト
  • フォームデータとしてparamsを指定
    • paramsにはパイプライントリガートークンを値とする token と処理対象ブランチ(mainとか)を値とする ref を含む
import json
import os
import boto3
import urllib3
from botocore.exceptions import ClientError

import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# 環境変数
API_ENDPOINT = os.environ['API_ENDPOINT']
SECRET_NAME = os.environ['SECRET_NAME']
PROJECT_ID = os.environ['PROJECT_ID']
REF_NAME = os.environ['REF_NAME']

# Secrets Managerクライアントの初期化
secrets_manager = boto3.client('secretsmanager')

# urllib3 HTTPプールマネージャの初期化
http = urllib3.PoolManager(cert_reqs='CERT_NONE')

def get_secret():
    try:
        get_secret_value_response = secrets_manager.get_secret_value(SecretId=SECRET_NAME)
    except ClientError as e:
        raise e
    else:
        if 'SecretString' in get_secret_value_response:
            secret = json.loads(get_secret_value_response['SecretString'])
            return secret['token']
        else:
            raise ValueError("Secret not found in the expected format")

def lambda_handler(event, context):
    logger.info("Function started")
    logger.info(f"Event: {event}")
    try:
        logger.info("Getting secret")
        token = get_secret()
        logger.info("Secret retrieved")

        url = f"{API_ENDPOINT}/api/v4/projects/{PROJECT_ID}/trigger/pipeline"
        logger.info(f"URL: {url}")

        params = {
            'token': token,
            'ref': REF_NAME
        }
        logger.info(f"ref: {REF_NAME}")

        logger.info("Sending request")
        response = http.request(
            'POST',
            url,
            fields=params,
            retries=urllib3.Retry(3),
            timeout=urllib3.Timeout(connect=5.0, read=10.0)
        )
        logger.info(f"Response received: status {response.status}")
        logger.info(f"Response content: {response.data.decode('utf-8')}")

        # レスポンスの処理
        if response.status == 201:
            return {
                'statusCode': 200,
                'body': json.dumps({
                    'message': 'Pipeline triggered successfully',
                    'data': json.loads(response.data.decode('utf-8'))
                })
            }
        else:
            error_message = f"Failed to trigger pipeline. Status: {response.status}, Error: {response.data.decode('utf-8')}"
            logger.error(error_message)
            raise Exception(error_message)

    except Exception as e:
        logger.error(f"An error occurred: {str(e)}")
        raise

実行してみる

それでは実際にLambdaを実行してみます。
以下の通り、無事GitLab CI/CDパイプラインが起動し正常に終了しました。下図矢印の箇所で、パイプライントリガートークンから起動されたワークフローであることが確認できます。

image.png

まとめ

GitLab CI/CDをAPI経由で外部から動かしてみました。いかがでしたでしょうか?なかなか使う機会はないかもしれませんが、例えばECRにpushしたことを契機にGitLab CI/CDで脆弱性スキャンをするなどの処理が出来たりするかなと思います。外部から起動できるとできることも広がると思うので覚えておいて損はないかと思います。

以上、とーちでした。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.