GitLabとCloud Buildでアプリケーション(Streamlit)のデプロイを完全自動化してみた
GitLabへPushするだけでCloud Runへ自動デプロイされる、Streamlitアプリ用CI/CDパイプラインを構築してみました。
- 1. はじめに
- 2. CI/CDパイプラインの全体像
- 3. 2つのデプロイ方式の比較検討
- 4. リポジトリ構成のベストプラクティス
- 5. 環境構築のステップ
- 6. 構築時にハマったポイント
- 7. Tips:Pushと同時にマージリクエスト(MR)を自動作成する
- 8. 最後に
- 9. 参考:使用したソースコード全文
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パイプラインの全体像は以下の通りです。
図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のデプロイパイプラインが誤って起動してしまう可能性がある
- ・爆発半径の拡大:何らかのトラブルが起きた際に、インフラ破壊にまで影響が及ぶ範囲が広がってしまう
図2 GitLab上で分離されたインフラ用リポジトリとアプリ用リポジトリ
実際にGitLabのプロジェクト一覧を見ると、Terraformで管理しているインフラ用リポジトリ「gctechlab」と、今回作成したアプリ用リポジトリ「streamlit-app-demo」が、それぞれ独立したプロジェクトとして分かれていることが分かります。
5. 環境構築のステップ
実際にどのように構築したのか、順を追って紹介します。
GitLabで新しいプロジェクトを作成
まずはGitLab上に、アプリ専用の新しいプロジェクトを作成します。設定内容は以下の通りです。
- ・プロジェクト名:streamlit-app-demo
- ・プロジェクトslug:streamlit-app-demo
- ・表示レベル:非公開
- ・リポジトリ初期化とREADME生成:チェックなし(ローカルからPushするため)
図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側のパーソナルアクセストークンを使って認証します。
図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での動作確認
トリガーの一覧から「実行」ボタンをクリックすると、ビルドが開始されます。ビルドの履歴画面から、各ステップの実行状況を確認できます。
図5 Cloud Buildのビルド成功画面
ビルドが成功すると、Cloud Runに自動でサービスが作成されます。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]」というエラーが発生しました。
図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
