GitLabとCloud Buildでアプリケーション(Streamlit)のデプロイを完全自動化してみた


GitLabへPushするだけでCloud Runへ自動デプロイされる、Streamlitアプリ用CI/CDパイプラインを構築してみました。

1. はじめに

前回の記事では、GoogleフォームとGAS、Terraformを組み合わせて「Google Cloudプロジェクト作成」のセルフサービス化を紹介しました。今回はその続編として、実際にプロジェクトが払い出された後の「アプリケーションのデプロイ」を全自動化する仕組みを構築してみました。

Google Cloudでは、アプリケーションの実行基盤としてCompute Engine(GCE)、Google Kubernetes Engine(GKE)、Cloud Runなどを選択できます。今回は、コンテナをフルマネージド環境で実行できるCloud Runをデプロイ先として採用しました。

具体的には、ローカルで開発したStreamlitアプリをGitLabにPushするだけで、コンテナのビルドからGoogle CloudのCloud Runへのデプロイまでが自動的に実行されるCI/CD環境です。本記事では、採用したパイプライン方式の選び方、リポジトリ設計の考え方、そしてGitLab連携特有のハマりポイントについて紹介します。

2. CI/CDパイプラインの全体像

今回構築したCI/CDパイプラインの全体像は以下の通りです。

GitLabへのPushからCloud Runへのデプロイまでの自動化フロー図

図1 CI/CDパイプラインの全体像

開発者がローカルでコードを修正し、GitLabリポジトリへPushすると、Cloud Buildのトリガーが起動します。Cloud Buildはコンテナイメージをビルドし、Artifact Registryへプッシュしたのち、そのイメージを使ってCloud Runへ自動的にデプロイします。開発者が意識するのは「コードを書いてPushする」ことだけで、あとはビルドからデプロイまでが自動で完結する仕組みです。

3. 2つのデプロイ方式の比較検討

Streamlitアプリの自動デプロイを実現するにあたり、大きく2つのアプローチを検討しました。

アプローチ1:Cloud Build連携(Google Cloud主導)

ソースコードの管理はGitLabで行いつつ、コンテナのビルドとデプロイはGoogle Cloud側のCloud Buildで実行する方式です。GitLabへのPushをきっかけに、Cloud Build側が自動でビルド・デプロイのパイプラインを実行します。

アプローチ2:GitLab CI/CDフル活用(GitLab主導)

GitLab Runnerを利用し、ビルドからGoogle CloudへのデプロイまでをすべてGitLab側で完結させる方式です。ビルドの定義は「.gitlab-ci.yml」に記述します。

比較と結論

それぞれの特徴を整理すると、以下のようになります。

観点 アプローチ1:Cloud Build連携 アプローチ2:GitLab CI/CDフル活用
実行場所 Google Cloud(Cloud Build) GitLab(GitLab Runner)
定義ファイル cloudbuild.yaml .gitlab-ci.yml
メリット Cloud Buildの無料枠(1日120分)が強力で、設定は画面操作が中心 ログ確認がGitLabの1画面で完結し、開発者体験が良い
デメリット エラー発生時にGoogle Cloudコンソール側の確認が必要 無料枠(月間400分)を使い切るリスクがあり、Workload Identityの構築も複雑

今回は、「まずはサクッとStreamlitアプリを動かしたい」「ビルドコストを確実にゼロに抑えたい」「既存のcloudbuild.yamlの資産を流用したい」という理由から、手軽で確実なアプローチ1(Cloud Build連携)を採用しました。

4. リポジトリ構成のベストプラクティス

アプリケーションのリポジトリ構成については、インフラコード(Terraform)を管理しているリポジトリとは別に、「アプリ専用の新しいリポジトリ」を作成するマルチリポジトリ構成を採用しました。1つのリポジトリにインフラとアプリのコードを同居させる「モノレポ構成」には、次のようなリスクがあります。

  • ・意図しないパイプラインの起動:アプリコードの修正だけで、Terraformのデプロイパイプラインが誤って起動してしまう可能性がある
  • ・爆発半径の拡大:何らかのトラブルが起きた際に、インフラ破壊にまで影響が及ぶ範囲が広がってしまう
GitLabのプロジェクト一覧画面。インフラ管理用のgctechlabとアプリ用のstreamlit-app-demoが別プロジェクトになっている

図2 GitLab上で分離されたインフラ用リポジトリとアプリ用リポジトリ

実際にGitLabのプロジェクト一覧を見ると、Terraformで管理しているインフラ用リポジトリ「gctechlab」と、今回作成したアプリ用リポジトリ「streamlit-app-demo」が、それぞれ独立したプロジェクトとして分かれていることが分かります。

5. 環境構築のステップ

実際にどのように構築したのか、順を追って紹介します。

GitLabで新しいプロジェクトを作成

まずはGitLab上に、アプリ専用の新しいプロジェクトを作成します。設定内容は以下の通りです。

  • ・プロジェクト名:streamlit-app-demo
  • ・プロジェクトslug:streamlit-app-demo
  • ・表示レベル:非公開
  • ・リポジトリ初期化とREADME生成:チェックなし(ローカルからPushするため)
GitLabの新規プロジェクト作成メニュー画面(空のプロジェクトの作成/テンプレートから作成/プロジェクトのインポート/外部リポジトリのCI/CDを実行)

図3 GitLabでの新規プロジェクト作成画面

アプリケーションのソースコードを作成

今回は、Webページにアクセスした際の送信元情報を表示し、pingや名前解決も行える簡単なネットワーク診断アプリを作成しました。ローカルに開発用フォルダを作成し、以下の4ファイルを用意します。

  • ・app.py:Streamlitアプリ本体
  • ・requirements.txt:Pythonの外部ライブラリ(依存関係)の指定
  • ・Dockerfile:python:3.14.2-slimをベースに、pingなどのOSコマンドをapt-getで追加
  • ・cloudbuild.yaml:Cloud Buildの指示書(コンテナビルドからCloud Runへのデプロイ手順)

各ファイルの中身は、本記事末尾の「9. 参考:使用したソースコード全文」にまとめて掲載していますので、あわせてご参照ください。

GitLabへソースコードをPush

作成したフォルダで、以下のコマンドを実行してGitLabへPushします。

# Gitの初期設定とGitLabへの紐付け
git init --initial-branch=main
git remote add origin https://gitlab.com/hippi7/streamlit-app-demo.git

# ファイルの追加とコミット
git add .
git commit -m "Initial commit for Network Tools App"

# GitLabへプッシュ
git push -u origin main

Cloud Buildトリガーの作成

Google Cloudコンソールの「Cloud Build」→「トリガー」から、「トリガーを作成」をクリックします。設定内容は以下の通りです。

  • ・名前:deploy-streamlit-app-demo
  • ・イベント:ブランチにpushする

ソースの選択では「Developer Connect」機能を使ってGitLabリポジトリをリンクします。「新しいリポジトリをリンク」から接続プロバイダに「GitLab」を選び(接続名:GitLab-Connection)、GitLab側のパーソナルアクセストークンを使って認証します。

Cloud Buildのトリガー作成画面でソースにDeveloper Connectを選択した状態

図4 Cloud Buildトリガーの作成(Developer Connect連携)

GitLabのアクセストークンは、GitLabの「ユーザー設定」→「アクセス」→「パーソナルアクセストークン」から発行します。スコープは、api・read_apiの両方にチェックを入れて生成し、「APIアクセストークン」と「読み取りAPIアクセストークン」のそれぞれに貼り付けます。リポジトリのリンクで対象リポジトリ(hippi7/streamlit-app-demo)を選択し、内容を確認して「作成」をクリックすればトリガーの設定は完了です。

Artifact Registryの作成

ビルドしたコンテナイメージの保存先として、Artifact Registryにリポジトリを作成しておきます。設定内容は以下の通りです。

  • ・名前:streamlit-apps-automation
  • ・形式:Docker
  • ・ロケーションタイプ:リージョン
  • ・リージョン:asia-northeast1(東京)

Cloud Buildの実行とCloud Runでの動作確認

トリガーの一覧から「実行」ボタンをクリックすると、ビルドが開始されます。ビルドの履歴画面から、各ステップの実行状況を確認できます。

Cloud Buildのビルドが全ステップ成功した画面

図5 Cloud Buildのビルド成功画面

ビルドが成功すると、Cloud Runに自動でサービスが作成されます。Cloud Runのコンソールでstreamlit-app-demoのサービスを開くと、URLやリクエスト数などのメトリクスが表示されたサービス詳細画面を確認できます。

Cloud Runのサービス詳細画面(streamlit-app-demoのURLとメトリクス)

図6 Cloud Runにデプロイされたサービスの詳細画面

画面上部に表示されているURLをクリックすると、実際にStreamlitアプリが起動していることを確認できました。

2回目以降の更新は、開発用ブランチを作成してコードを修正し、コミット・Push・マージリクエスト作成・マージ、という流れになります。

# 開発用ブランチを作成
git checkout -b "update01"

# (ソースコードを修正して保存)

# ファイルの追加とコミット
git add .
git commit -m "add01"

# Pushと同時にマージリクエストを作成
git push -o merge_request.create -u origin update01

GitLab上でマージリクエストの内容を確認してマージすると、Cloud Buildが変更を検知し、ビルドからデプロイまでが自動で実行されます。

6. 構築時にハマったポイント

便利な仕組みが完成した一方で、構築の過程では2つの落とし穴にはまりました。同じような構成を検討されている方の参考になればと思い、まとめておきます。

トークン検証エラー「required scope [read_api]」

Developer Connectの設定画面で、Push用のGitLabトークンを入力した際、「failed verifying read token: token does not have required scope [read_api]」というエラーが発生しました。

Developer Connectでのトークン検証エラー画面

図7 トークンのスコープ不足エラー

原因は、GitLabのトークンでread_apiのスコープにチェックが入っていなかったことでした。Developer Connectは、read_apiスコープの有無を厳格にチェックします。GitLabのトークン再発行画面で、api・read_api(念のためread_repositoryも)の3つすべてにチェックを入れてトークンを再作成し、これを設定することで解決しました。

Artifact Registryのリポジトリが存在しないエラー

コンテナのビルド自体は成功したものの、Artifact Registryへのpush処理でエラーが発生し、ビルドが失敗することがありました。ログを確認すると、「name unknown: Repository "streamlit-apps-automation" not found」と表示されていました。

原因は、Google Cloudプロジェクト内にArtifact Registryのリポジトリ(streamlit-apps-automation)をまだ作成していなかったことでした。Artifact Registryの画面から対象のリポジトリを手動で作成し、Cloud Buildの画面から「ビルドを再試行」をクリックすることで、無事にビルドが完走しました。

7. Tips:Pushと同時にマージリクエスト(MR)を自動作成する

インフラ自動化のようにGASでMRを作成する方法以外にも、GitLabの標準機能である「Git Push Options」を使えば、コマンド一発でPushと同時にマージリクエストまで自動作成できます。安全なGitOpsを実現するための運用フローは次の通りです。

# 1. 作業用ブランチを作成
git switch -c feature/update-app

# 2. ファイルを修正してコミット
git add .
git commit -m "UIの改善"

# 3. Pushと同時にMRを作成するオプションを指定
git push -o merge_request.create -u origin feature/update-app

Pushが完了すると、ターミナル上にマージリクエストのURLが出力されます。GitLab上で内容を確認してマージすれば、Cloud Buildが検知して全自動でデプロイが実行されます。

毎回オプションを入力するのが面倒な場合は、以下のようにGitのエイリアスを設定しておくと便利です。

git config --global alias.mr "push -o merge_request.create -u origin HEAD"

これにより、以後は「git push」の代わりに「git mr」と打つだけで、Pushとマージリクエスト作成を同時に実行できるようになります。

8. 最後に

Google Cloudの仕様やローカル環境の認証設定など、いくつかのポイントでつまずきましたが、原因を一つずつ切り分けることで、快適なデプロイ環境を構築できました。このCI/CDパイプラインが完成したことで、「ローカルでコードを書いてPush(またはマージ)するだけ」で本番環境が自動的に更新されるようになりました。

前回紹介したTerraformによるインフラ自動化と組み合わせることで、インフラ管理者からアプリ開発者への環境提供が、プロジェクト申請から実際に動くアプリのデプロイまで一気通貫でセルフサービス化されたことになります。同じような構成を検討されている方の参考になれば幸いです。

9. 参考:使用したソースコード全文

今回使用した4つのファイルの全文を掲載します。ご自身の環境で試される場合は、リポジトリのURLやプロジェクトID、リージョンなどを適宜置き換えてご利用ください。

app.py

Streamlitアプリ本体です。クライアント情報の表示、ping・nslookupの実行、Web接続確認の3つの機能をタブで切り替えられるようにしています。

import streamlit as st
import subprocess
import platform
import requests
import os
from user_agents import parse

# ページの設定
st.set_page_config(page_title="Network & Client Info App", layout="wide")

def get_client_info():
    """リクエストヘッダーからクライアントのIPとUser-Agentを取得する"""
    try:
        headers = st.context.headers

        if "X-Forwarded-For" in headers:
            ip = headers["X-Forwarded-For"].split(",")[0].strip()
        elif "X-Real-Ip" in headers:
            ip = headers["X-Real-Ip"]
        else:
            ip = "IPを取得できませんでした"

        user_agent = headers.get("User-Agent", "情報なし")

        return ip, user_agent
    except Exception as e:
        return f"エラー: {e}", "情報なし"

@st.cache_data(ttl=3600)
def fetch_ip_details(ip):
    """IPアドレスからプロバイダや地域情報を取得する"""
    if not ip or "エラー" in ip or "取得できませんでした" in ip:
        return None

    try:
        url = f"http://ip-api.com/json/{ip}?lang=ja"
        response = requests.get(url, timeout=5)
        if response.status_code == 200:
            data = response.json()
            if data.get("status") == "success":
                return data
    except Exception:
        pass
    return None

def get_server_info():
    """サーバー(Cloud Runコンテナ)のサービス名とアウトバウンド(送信元)IPを取得する"""
    # Cloud Run環境で自動設定される環境変数からサービス名を取得
    # ローカル実行時など環境変数がない場合は 'Local/Container' とする
    service_name = os.environ.get('K_SERVICE', 'Local/Container')

    try:
        # 外部API (ipify) を叩いて、コンテナ自身の現在のグローバルIPを取得
        response = requests.get('https://api.ipify.org', timeout=5)
        outbound_ip = response.text
    except Exception:
        outbound_ip = "IPを取得できませんでした"

    return service_name, outbound_ip

def run_command(command):
    """OSコマンドを実行し、結果を返す"""
    try:
        result = subprocess.run(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=10
        )
        return result.stdout if result.stdout else result.stderr
    except subprocess.TimeoutExpired:
        return "コマンドがタイムアウトしました。"
    except FileNotFoundError:
        return f"コマンドが見つかりません: {command[0]}"
    except Exception as e:
        return f"予期せぬエラーが発生しました: {e}"

st.title(" Network & Client Info App")

# タブでUIを分割
tab1, tab2, tab3 = st.tabs(["あなたのアクセス情報", "ネットワークツール", "Web接続確認"])

# --- タブ1: クライアント情報の表示 ---
with tab1:
    st.header("あなたのアクセス情報")
    st.write("現在このページにアクセスしている環境の情報を表示しています。")

    client_ip, user_agent_str = get_client_info()

    st.metric(label="あなたの送信元IPアドレス", value=client_ip)

    ip_details = fetch_ip_details(client_ip)

    col1, col2 = st.columns(2)

    with col1:
        st.subheader(" ネットワーク情報")
        if ip_details:
            st.info(f"**プロバイダ (ISP):** {ip_details.get('isp', '不明')}")
            st.info(f"**組織 (Org):** {ip_details.get('org', '不明')}")
            st.info(f"**国:** {ip_details.get('country', '不明')}")
            st.info(f"**地域:** {ip_details.get('regionName', '不明')} {ip_details.get('city', '不明')}")
        else:
            st.warning("詳細情報を取得できませんでした。")

    with col2:
        st.subheader(" ブラウザ・端末情報")
        if user_agent_str != "情報なし":
            ua = parse(user_agent_str)
            device_type = "PC " if ua.is_pc else "モバイル " if ua.is_mobile else "タブレット " if ua.is_tablet else "その他"

            # Windows 10 / 11 の判定ロジック
            os_display_name = f"{ua.os.family} {ua.os.version_string}"

            if ua.os.family == "Windows" and ua.os.version_string == "10":
                headers = st.context.headers
                sec_platform_version = headers.get("Sec-Ch-Ua-Platform-Version", "").strip('"')

                if sec_platform_version:
                    try:
                        major_version = int(sec_platform_version.split('.')[0])
                        if major_version >= 13:
                            os_display_name = "Windows 11"
                    except ValueError:
                        pass
                else:
                    os_display_name = "Windows 10 (または 11)"

            st.success(f"**OS:** {os_display_name}")
            st.success(f"**ブラウザ:** {ua.browser.family} {ua.browser.version_string}")
            st.success(f"**デバイス:** {device_type}")

            st.caption("生データ (Raw User-Agent):")
            st.code(user_agent_str, language="text")
        else:
            st.warning("User-Agent情報を取得できませんでした。")

# --- タブ2: ネットワークコマンド (ping, nslookup) ---
with tab2:
    st.header("ネットワークツール")

    # 修正箇所: アウトバウンドIPとサービス名を表示
    server_host, server_ip = get_server_info()
    st.write("#####  コマンド送信元情報 (このアプリが稼働しているサーバー)")
    st.info(f"**サービス名:** {server_host} | **送信元グローバルIP:** {server_ip}")
    st.caption("※ PingやNslookupは上記のサーバー(IP)から実行されます。あなたの端末から直接実行されるわけではありません。")
    st.caption("※ Cloud Runの仕様上、このIPアドレスはリクエストごとに変動する可能性があります。")

    st.divider()

    target = st.text_input(" ターゲット (ドメイン名 または IPアドレス)", "google.com")

    col1, col2 = st.columns(2)
    os_name = platform.system().lower()

    with col1:
        if st.button("Ping 実行", use_container_width=True):
            with st.spinner('Pingを実行中...'):
                param = '-n' if os_name == 'windows' else '-c'
                command = ['ping', param, '4', target]
                output = run_command(command)
                st.text_area("Ping 結果", output, height=300)

    with col2:
        if st.button("Nslookup 実行", use_container_width=True):
            with st.spinner('Nslookupを実行中...'):
                command = ['nslookup', target]
                output = run_command(command)
                st.text_area("Nslookup 結果", output, height=300)

# --- タブ3: Webサイトへの接続確認 ---
with tab3:
    st.header("Webサイト接続確認")
    st.write("指定したURLに対してHTTP(S)リクエストを送信し、稼働状況を確認します。")

    url = st.text_input("URL", "https://www.google.com")

    if st.button("接続チェック", type="primary"):
        if not url.startswith("http"):
            st.warning("URLは 'http://' または 'https://' から始めてください。")
        else:
            with st.spinner('接続を確認中...'):
                try:
                    response = requests.get(url, timeout=5)
                    if response.status_code < 400:
                        st.success(f"接続成功! ステータスコード: {response.status_code} ({response.reason})")
                    else:
                        st.error(f"接続はできましたが、エラーが返されました。ステータスコード: {response.status_code} ({response.reason})")
                    st.metric(label="応答時間", value=f"{response.elapsed.total_seconds():.3f} 秒")

                except requests.exceptions.Timeout:
                    st.error("接続がタイムアウトしました。")
                except requests.exceptions.ConnectionError:
                    st.error("接続エラー: 名前解決ができないか、サーバーが存在しません。")
                except requests.exceptions.RequestException as e:
                    st.error(f"エラーが発生しました: {e}")

cloudbuild.yaml

コンテナイメージのビルド、Artifact Registryへのプッシュ、Cloud Runへのデプロイという3つのステップを定義しています。

steps:
  # ステップ1: Dockerfileを元にコンテナイメージをビルド
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - 'asia-northeast1-docker.pkg.dev/${PROJECT_ID}/streamlit-apps-automation/streamlit-app-demo:$SHORT_SHA'
      - '.'

  # ステップ2: ビルドしたイメージをArtifact Registryにプッシュする
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'push'
      - 'asia-northeast1-docker.pkg.dev/${PROJECT_ID}/streamlit-apps-automation/streamlit-app-demo:$SHORT_SHA'

  # ステップ3: Artifact Registryのイメージを使ってCloud Runにデプロイする
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: 'gcloud'
    args:
      - 'run'
      - 'deploy'
      - 'streamlit-app-demo' # ← 新しいCloud Runのサービス名
      - '--image'
      - 'asia-northeast1-docker.pkg.dev/${PROJECT_ID}/streamlit-apps-automation/streamlit-app-demo:$SHORT_SHA'
      - '--region'
      - 'asia-northeast1'
      - '--platform'
      - 'managed'
      - '--allow-unauthenticated'

images:
  - 'asia-northeast1-docker.pkg.dev/${PROJECT_ID}/streamlit-apps-automation/streamlit-app-demo:$SHORT_SHA'

options:
  logging: CLOUD_LOGGING_ONLY

Dockerfile

Python 3.14.2のslimイメージをベースに、ping・traceroute・nslookupなどのOSコマンドを追加しています。

# 使用する Python 3.14.2 ベースイメージ(軽量版のslim)を使用
FROM python:3.14.2-slim

# 【重要】コンテナ内でネットワークコマンドを実行するために必要なOSツールをインストール
RUN apt-get update && apt-get install -y \
    iputils-ping \
    traceroute \
    dnsutils \
&& rm -rf /var/lib/apt/lists/*

# 作業ディレクトリを設定
WORKDIR /app

# 必要なライブラリをインストール
COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションのコードをコピー
COPY . .

# Cloud Runがコンテナをリッスンするポートを指定
ENV PORT 8080

# アプリケーションの起動コマンド
CMD exec streamlit run app.py --server.port=$PORT --server.address=0.0.0.0 --server.enableCORS=false

requirements.txt

アプリケーションが依存するPythonライブラリです。

streamlit
requests
user-agents