2分で読了連携

GitLab CIのURL短縮サービス:すべてのパイプラインからショートリンクを作成

GitLab CIをURL短縮サービスとして使い、マージリクエストごとにレビューアプリのショートリンクを作成し、タグでは安定したlatestリンクの遷移先を、マスクして保護したキーで付け替えます。

Marius Voß
DevRel · edge infra
GitLab CIのURL短縮サービスのパイプラインをピクセル風のステージで描いた図。ビルド、テスト、デプロイの最後にリンクジョブがあり、各マージリクエストのショートリンクを書き込み、タグではlatestリンクの遷移先を付け替えます

はい、GitLab CIはURL短縮サービスとして使えます。curlと1つのAPIキーを使うジョブがあれば、マージリクエストごとにショートリンクを作成してレビューアプリへ向け、タグをプッシュしたときに安定したlatestリンクを新しいリリースへ移せます。YAMLは約40行です。今すぐ動作します。

多くの人が間違えるのはHTTPリクエストではありません。キーの扱いです。どこに置くか、どのパイプラインが読めるか、ブランチのジョブから漏れた場合にどれほどの被害が出るかが問題になります。そのためこのガイドでは、.gitlab-ci.ymlそのものと同じくらい変数とスコープに時間をかけます。長期間使うリンクを宣言的に管理したい場合は、ショートリンクのTerraform方式の方が適しています。パイプラインは、コードとともに作成・廃止されるリンクに向いています。

最初にステータスを1点お伝えします。ElidoのGitLabネイティブ統合は準備中で、まだ稼働しておらず、以下の内容は何も依存していません。管理されたバージョンを使いたいですか?GitLab統合ページにウェイトリストがあります。

GitLab CIのURL短縮サービスジョブの役割

パイプラインの短縮サービスが行うことは3つだけです。スラッグが存在しないときにリンクを作成し、存在するときに遷移先を更新し、リンク先の対象がなくなったときにリンクを無効にします。アナリティクスとQRコードはElido側に残ります。

APIの範囲は小さなものです。リンクは/v1/workspaces/{workspace_id}/linksの下にあり、POSTで作成するにはdomain_iddestination_urlが必要です。PATCH /links/{link_id}は既存リンクのフィールドを変更し、GET /links?q=はスラッグ、遷移先、タイトルで検索します。認証は1つのヘッダーだけです:Authorization: Bearer elido_...。キーはダッシュボードのAPI keysページから取得します。

契約はこれですべてです。APIとSDKの概要には残りのエンドポイントも載っていますが、パイプラインで必要になるのは通常この3つを超えません。

キーをマスクして保護された変数として保存する

ここで重要になるスイッチはGitLabに2つあり、それぞれ役割が異なります。マスクはジョブログ内の値を隠します。保護は、そもそもどのパイプラインが値を受け取れるかを制御します。

変数を作成するときは、Settings、CI/CD、VariablesでMasked and hiddenを選びます。HiddenはGitLab 17.6以降で一般提供されている機能で、後から設定ページで誰も値を表示できないことを意味します。認証情報にはこの設定が必要です。GitLab CI/CD変数のドキュメントには、マスクした値の要件として、1行であること、空白がないこと、8文字以上であることが記載されています。Elidoのキーはelido_にbase32が続く形式なので条件を満たします。

同じページには制限も率直に書かれています。マスクは「悪意のあるユーザーによる変数値へのアクセスを防ぐ保証された方法ではありません」。変数をbase64でエンコードして表示するジョブなら、マスクをそのまま通り抜けます。マスクはアクセス制御ではなく、ログを衛生的に保つためのものと考えてください。

アクセス制御に当たるのが保護です。保護された変数は保護されたブランチまたはタグのパイプラインにだけ届きます。そのため、どのチームも最初の週に同じ問題に遭遇します。マージリクエストのパイプラインは機能ブランチで実行されるため、保護されたキーが空文字列で届き、ジョブが入力ミスのように見える401で失敗するのです。

私は1つのキーを弱める代わりに、2つのキーで解決します。私なら次のように設定します。

変数表示保護読み取り元
ELIDO_PREVIEW_KEYMasked and hiddenNoマージリクエストのパイプライン
ELIDO_RELEASE_KEYMasked and hiddenYes保護されたタグ上のパイプライン
ELIDO_PREVIEW_WS, ELIDO_RELEASE_WSVisibleNo任意のジョブ(IDは秘密ではありません)
ELIDO_DOMAIN_ID, SHORT_HOSTVisibleNo任意のジョブ

プレビューキーは、レビューリンクだけを保持する別のワークスペースに置きます。ブランチをプッシュできる人なら、原理上は保護されていない変数を持ち出せます。そのため、到達できる最悪の範囲が使い捨てのmr-142リンクの山に収まるようにし、リリースキーは実際のワークスペースに置いて、保護したタグでのみ実行されるようにしてください。

両方のキーにEditorロールと有効期限を設定します。プレビュー用は90日が適しています。Editorはリンクを書き込める最も低いプリセットで、削除もできます。APIキーはプリセットのロールから1つを選ぶ仕組みで、この用途にぴったりの作成・更新専用プリセットが欲しいところですが、まだありません。実際に影響範囲を制限するのはワークスペースの分離です。

ショートリンクを作成する動作する.gitlab-ci.ymlジョブ

共有する部分はこれです。スラッグを検索し、なければリンクを作成し、あればパッチするupsertです。非表示ジョブに入れて、そこからextendします。

.elido_upsert:
  image: alpine:3.20
  before_script:
    - apk add --no-cache curl jq
  script:
    - API="https://api.elido.app/v1/workspaces/${ELIDO_WS}"
    - AUTH="Authorization: Bearer ${ELIDO_KEY}"
    - |
      find_id() {
        curl -sS --fail-with-body -H "$AUTH" "$API/links?q=${SLUG}&limit=50" |
          jq -r --arg s "$SLUG" --argjson d "$ELIDO_DOMAIN_ID" \
            '.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1
      }
      ID="$(find_id)"
      if [ -z "$ID" ]; then
        CODE=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/links" \
          -H "$AUTH" -H "Content-Type: application/json" \
          -H "Idempotency-Key: ${CI_PIPELINE_ID}-${SLUG}" \
          -d "$(jq -n --arg s "$SLUG" --arg u "$TARGET" --argjson d "$ELIDO_DOMAIN_ID" \
                '{domain_id: $d, slug: $s, destination_url: $u, tags: ["ci"]}')")
        case "$CODE" in
          201) ;;
          409) ID="$(find_id)" ;;   # another pipeline created it first
          *) cat resp.json; exit 1 ;;
        esac
      fi
      if [ -n "$ID" ]; then
        curl -sS --fail-with-body -X PATCH "$API/links/$ID" \
          -H "$AUTH" -H "Content-Type: application/json" \
          -d "$(jq -n --arg u "$TARGET" '{destination_url: $u, status: "active"}')"
      fi
    - echo "SHORT_URL=https://${SHORT_HOST}/${SLUG}" >> link.env
  artifacts:
    reports:
      dotenv: link.env

q検索は部分一致なので、jqフィルターで完全に一致するスラッグとドメインに絞り込んでいます。これがないと、web-mr-14を検索したときにweb-mr-142が何食わぬ顔で返ります。GET /v1/workspaces/{id}/domainsで一度domain_idを取得し、通常の変数として保存してください。カスタムドメインで設定したブランドホストの方が、マージリクエストでは汎用ホストより見栄えがします。

GitLabのレビューアプリ用ショートリンクのライフサイクル。マージリクエストのパイプラインがスラッグをupsertし、環境URLとして使うdotenvレポートにSHORT_URLを書き込み、マージリクエストが閉じると停止ジョブがリンクを無効にします

マージリクエストごとのレビューアプリ用ショートリンク

レビューアプリは、ブランチまたはマージリクエストごとの一時環境を指すGitLabの呼び名で、レビューアプリのドキュメントでは動的環境を使って構築します。URLはハッシュ、名前空間、クラウドプロバイダーのホスト名などで、見栄えがしないことが多いものです。go.example.com/web-mr-142のようなショートリンクなら、スタンドアップで口に出して伝えられます。

review_link:
  extends: .elido_upsert
  stage: deploy
  needs: [deploy_review]
  variables:
    ELIDO_KEY: $ELIDO_PREVIEW_KEY
    ELIDO_WS: $ELIDO_PREVIEW_WS
    SLUG: "${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}"
    TARGET: "https://${CI_ENVIRONMENT_SLUG}.review.example.com"
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: $SHORT_URL
    on_stop: stop_review_link
    auto_stop_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

ポイントはdotenvレポートです。upsertがlink.envSHORT_URLを書き込み、GitLabがそれを読み戻すことで、environment:urlがショートリンクになります。そのためマージリクエストのView appボタンを押すと、生のホスト名ではなくweb-mr-142が開きます。環境のドキュメントで、この動的URLパターンを説明しています。

CI_MERGE_REQUEST_IIDはプロジェクトごとに一意で、マージリクエストが存続する間は変わりません。そのため同じMRへのプッシュはすべて同じスラッグになり、upsertが重複作成ではなくパッチします。別のキーが必要なら、定義済み変数のリファレンスに全リストがあります。

クリーンアップはaction: stopを使うジョブで行います。GitLabが自動的に起動できるよう、開始ジョブと同じrulesを共有する必要があります。

stop_review_link:
  image: alpine:3.20
  stage: deploy
  variables:
    GIT_STRATEGY: none
    SLUG: "${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}"
  script:
    - apk add --no-cache curl jq
    - API="https://api.elido.app/v1/workspaces/${ELIDO_PREVIEW_WS}"
    - ID=$(curl -sS -H "Authorization:
        Bearer ${ELIDO_PREVIEW_KEY}" "$API/links?q=${SLUG}" |
        jq -r --arg s "$SLUG" --argjson d "$ELIDO_DOMAIN_ID" '.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1)
    - '[ -z "$ID" ] || curl -sS --fail-with-body -X PATCH "$API/links/$ID" -H "Authorization: Bearer ${ELIDO_PREVIEW_KEY}" -H "Content-Type: application/json" -d "{\"status\":\"disabled\"}"'
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  when: manual
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

私は削除ではなく無効化します。無効なリンクならクリック履歴が残り、誰かがMRを再オープンした場合も、次のパイプラインが同じupsertを通じてactiveに戻せます。GIT_STRATEGY: noneを指定しているのは、その時点でブランチがなくなっている可能性があるためです。

レビューアプリがリリースの10倍ある場合、ここでプランの上限が効いてきます。混雑したモノレポに組み込む前に料金ページでリンク許容量を確認し、テスト中はプレビュー用の無料ワークスペースを始めてください。

タグパイプラインで安定したlatestリンクの遷移先を付け替える

2つ目のパターンはタグで実行し、レビューリンクとは逆のことをします。変わらない1つのスラッグを使い、リリースごとに遷移先を先へ進めます。READMEはgo.example.com/cli-latestを永遠に指せます。

latest_link:
  extends: .elido_upsert
  stage: release
  variables:
    ELIDO_KEY: $ELIDO_RELEASE_KEY
    ELIDO_WS: $ELIDO_RELEASE_WS
    SLUG: "cli-latest"
    TARGET: "${CI_PROJECT_URL}/-/releases/${CI_COMMIT_TAG}"
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

このルールをv*のような保護されたタグパターンと組み合わせ、トリガーとなるタグを作成できるのはメンテナーだけにします。そうしないと保護されたキーが存在せず、ジョブはクローズドに失敗します。これは望ましい動作です。バージョンごとの恒久的なリンクも必要なら、SLUG: "cli-${CI_COMMIT_REF_SLUG}"として同じジョブを2回目に実行します。v1.4.0cli-v1-4-0になります。

latestリンクにredirect_statusを301で設定しないでください。301はブラウザーがキャッシュしてよい恒久的な約束であり、latestリンクはリリースのたびにその約束を破るからです。Elidoはフィールドを省略すると302をデフォルトにします。301と302のリダイレクトの記事では、この選択が実際に問題になるケースを説明しています。

正直な注意点が1つあります。変更した遷移先がすべてのエッジロケーションに届くまで数分かかることがあるため、直後の1秒でショートリンクにcurlするスモークテストでは、まだ前のリリースが返る可能性があります。代わりにAPIレスポンスを検証するか、Locationヘッダーを確認する前に待ってください。

GitLab CIのURL短縮サービスで最小権限を実現する構成。保護されていないプレビューキーはマージリクエストのパイプライン用プレビューワークスペースに限定し、保護されたリリースキーは保護されたタグのパイプラインだけが読み取ってlatestリンクの遷移先を付け替えます

冪等性、リトライ、レート制限

パイプラインはリトライされます。ジョブの途中でRunnerが停止したり、誰かが赤いジョブのRetryをクリックしたり、30秒間隔で2回のプッシュが到着して競合したりします。上のupsertはこの3つすべてに耐えます。その理由を知っておく価値があります。

Idempotency-Keyヘッダーによって、リトライされたPOSTが安全になります。APIは成功したレスポンスを24時間キャッシュし、同じキーにはそれをリプレイするため、同じパイプラインのリトライはエラーではなく元のリンクを受け取ります。CI_PIPELINE_IDとスラッグからキーを作ることで、パイプライン内のリトライはリプレイになり、新しいパイプラインでは新しい試行になります。409の分岐は異なる2つのパイプライン間の競合に対応し、検索してからパッチする経路によって2回目の実行は実質的に何もしません。

レート制限はキーごとに適用され、ワークスペース単位の制限もその上にあります。また、新しいワークスペースには評価実績が積み上がるまで、1日あたりのリンク作成上限が低く設定されます。少数のマージリクエストなら気づきません。レビューアプリを一度に40個起動するモノレポでは影響する可能性があるため、429はGitLabのretryキーワードでリトライ可能として扱い、402は一時的なエラーではなくプラン上限を意味するので明確に失敗させてください。短縮サービスAPIのレート制限と冪等性では、CIジョブに必要な範囲を超えてバックオフを詳しく説明しています。

完全一致のjqフィルターを省くと、MR 14のパイプラインがMR 142のリンクをひそかにパッチします。最初の兆候は、たいてい困惑したデザイナーです。フィルターは残してください。

YAML内のシェルが扱いにくくなったら、同じ呼び出しをリポジトリにコミットするスクリプトにきれいにまとめられます。URL短縮サービスのCLIガイドでその形を紹介しています。

コーナーストーンを読む → Terraformでショートリンクを管理する:リンクをコードとして扱う

ブログの関連記事

よくある質問

GitLab CIでショートリンクを作成できますか?

はい。curlを実行できるジョブなら、どのURL短縮サービスのREST APIでも呼び出せるため、GitLab CIのジョブでショートリンクの作成、遷移先の更新、無効化ができます。APIキーはマスクしたCI/CD変数に保存し、ジョブからBearerトークンとして送信します。このためにGitLabのネイティブ統合は必要ありません。

GitLab CIでAPIキーを安全に保存するにはどうすればよいですか?

Settings、CI/CD、Variablesで、VisibilityをMasked and hiddenにして追加します。保護されたブランチまたはタグだけが読み取れるようにする場合は、Protect variableにもチェックを入れてください。マスクすると値はジョブログに表示されなくなりますが、GitLab自身のドキュメントにも保証された防御ではないとあるため、キー自体の権限を必要最小限に絞ってください。

マージリクエストのパイプラインで保護された変数が空になるのはなぜですか?

保護された変数は、保護されたブランチまたは保護されたタグで実行されるパイプラインにだけ渡されます。機能ブランチからのマージリクエストパイプラインはデフォルトでは条件を満たさないため、変数は空で届きます。レビュー用ジョブには権限を下げた別の保護されていないキーを使うか、保護されたキーをタグパイプライン専用にしてください。

GitLabの各レビューアプリにショートリンクを割り当てるにはどうすればよいですか?

マージリクエストのパイプラインでジョブを実行し、プロジェクト名とCI_MERGE_REQUEST_IIDから作ったスラッグを、レビューアプリのURLに向けてupsertします。生成された短縮URLをdotenvレポートに書き込み、environment:urlとして使うと、マージリクエストのウィジェットから直接リンクできます。環境が停止したときは、停止ジョブでリンクを無効にします。

最新リリースのショートリンクには301と302のどちらのリダイレクトを使うべきですか?

302を使ってください。latestリンクはリリースのたびに遷移先が変わりますが、301は移動が恒久的だとブラウザーやキャッシュに伝えるため、一部のクライアントは古いバージョンへ送り続けます。redirect_statusを設定しない場合、Elidoは新しいリンクを302にするため、ここでは適切な選択です。

ElidoにはGitLabのネイティブ統合がありますか?

まだありません。GitLabのネイティブ統合は準備中で、GitLab統合ページからウェイトリストに登録できます。このガイドの内容はすべて、パイプラインジョブから公開REST APIを通じて今すぐ動作し、GitLab側でインストールするものはCI/CD変数以外にありません。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
gitlab ci url shortener
create short link gitlab pipeline
gitlab review app short link
gitlab ci masked variable api key
short link per merge request
latest release short link

続きを読む