サブスクリプション費用を最適化する管理手法
業界の複数レポートでは、企業が保有するSaaSアプリは平均で100〜300に達し、総ライセンスのうち未利用・低利用が25〜40%に上ると報告されています。 エンジニア組織に視点を戻すと、席の過剰コミットや重複ツール、オフボーディング漏れが積み重なり、四半期ベースで数百万円規模の浪費に至るケースは珍しくありません。結論は単純で、Excelによる単発の棚卸しだけではコストの重力には勝てないということです。SaaSコストは「見えない所で増え続ける」傾向があるため、ログとIDを核にした継続的な自動化が不可欠になります。統制のための門番を増やすよりも、経路を整える土木工事のほうが効く。つまり、ID基盤を信頼ソースに、利用ログと請求データをひとつの事実にまとめ、可視化→自動回収→交渉のループを回すことが肝要です。¹²³
メトリクス設計で「どこが無駄か」を数式に落とす
感覚ではなく数式で無駄を定義すると、議論は早く実装は正確になります。まず、未利用率は「アクティビティがゼロの有償席の割合」とし、低利用率は「しきい値(閾値)に満たない有償席の割合」と捉えます。例えば過去30日でイベントゼロを未利用、イベント5回未満を低利用と置けば、プロダクトごとに未利用と低利用の分布が見えてきます。さらに、人事台帳に存在しないアカウントに紐づく席は孤児席(在籍外の有償席)、同一用途に複数プロダクトが走る状態を重複度と定義すると、重複による支出を積み上げられます。最後に、席あたり単価を基準化したユニットエコノミクス(席単位の収支指標)に落とし込めば、右サイズの目標席数が明文化されます。ポイントは、ID基盤・利用ログ・請求明細の三点セットを統一キーで結び、ダッシュボードの数値をそのまま運用判断に使える粒度にすることです。
データ基盤の要点は「唯一の信頼できるID」から始める
Google WorkspaceやOktaなどのIDプロバイダを唯一の信頼ソースとし、そこに人事システムの雇用ステータスを突合して在籍判定を確定させます。SaaS側の管理APIからは、ライセンス付与情報と直近アクティビティを収集します。請求データはベンダーポータルのCSV、または財務システムからの支出データを取り込み、製品・プラン・通貨を正規化します。取り込みの粒度は日次か週次が妥当で、組織規模1,000名・主要アプリ30種なら、API呼び出しは概算で数千〜1万リクエスト程度に収まり、指数バックオフを適切に実装すれば15分程度のETL(抽出・変換・ロード)ジョブで完了します。
スキーマと可視化のためのSQL実装
BigQueryを例に、統合ビューを生成して未利用・低利用・孤児席を一度に可視化します。雇用・ID・SaaS・請求の4系統をキーで結合するイメージです。
-- Code Sample 1: 未利用/低利用/孤児席の集計ビュー
CREATE OR REPLACE TABLE finops.view_seat_utilization AS
WITH activity AS (
SELECT user_email, app, COUNT(1) AS events_30d
FROM saas.audit_logs
WHERE event_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY user_email, app
),
licenses AS (
SELECT user_email, app, license_id, assigned_at
FROM saas.licenses
WHERE is_paid = TRUE
),
hr AS (
SELECT email AS user_email, employment_status
FROM hr.directory
),
pricing AS (
SELECT app, plan, seat_price_usd
FROM finance.pricing_catalog
)
SELECT l.app,
l.user_email,
COALESCE(a.events_30d, 0) AS events_30d,
CASE WHEN COALESCE(a.events_30d,0) = 0 THEN 1 ELSE 0 END AS is_unused,
CASE WHEN COALESCE(a.events_30d,0) < 5 THEN 1 ELSE 0 END AS is_underutil,
CASE WHEN h.employment_status NOT IN ('active') OR h.employment_status IS NULL THEN 1 ELSE 0 END AS is_orphan,
p.seat_price_usd,
(CASE WHEN COALESCE(a.events_30d,0) = 0 THEN p.seat_price_usd ELSE 0 END) AS monthly_waste_usd
FROM licenses l
LEFT JOIN activity a USING (user_email, app)
LEFT JOIN hr h USING (user_email)
LEFT JOIN pricing p USING (app);
ここで得られるビューをベースに、アプリ別の未利用率、損失金額、孤児席の件数を集計すれば、撤退・縮小・維持の優先順位が明確になります。しきい値5回は利用形態によって変えるべきで、API中心のツールはイベント種別を正規化して比較可能にしておくと誤検知を減らせます。
影のサブスクをあぶり出す自動発見と棚卸し
管理下にないサブスクの検出は、SSO(シングルサインオン)ログ、ブラウザ拡張の検知、そして経費精算のカード明細の三方向から挟み撃ちにすると精度が上がります。最初の投資として効果が高いのは、Google Workspaceのトークン一覧やOAuth同意画面の承認アプリを洗い出し、ドメイン外のSaaSアクセスを棚卸しする手法です。続いてSlackやGitHubのようにライセンス単価の高いアプリから、非アクティブユーザーの自動特定と回収に着手します。
Google WorkspaceのOAuthトークン列挙で未知のSaaSを把握する
ドメイン全体で付与済みのOAuthトークンを列挙し、スコープと発行元アプリを集約すれば、社内に流通する外部SaaSの下地が見えます。API制限に備えた指数バックオフと、監査目的のCSV出力を含めます。⁴
# Code Sample 2: Google Admin SDKでOAuthトークンを棚卸し
import csv
import time
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPES = ['https://www.googleapis.com/auth/admin.directory.user.security.readonly']
DELEGATED_ADMIN = 'admin@yourdomain.com'
SA_PATH = 'service_account.json'
def admin_service():
creds = service_account.Credentials.from_service_account_file(SA_PATH, scopes=SCOPES)
delegated = creds.with_subject(DELEGATED_ADMIN)
return build('admin', 'directory_v1', credentials=delegated, cache_discovery=False)
def list_tokens():
svc = admin_service()
users = svc.users().list(domain='yourdomain.com').execute().get('users', [])
rows = []
for u in users:
user_key = u['primaryEmail']
page_token = None
while True:
try:
resp = svc.tokens().list(userKey=user_key, pageToken=page_token).execute()
for t in resp.get('items', []):
rows.append({
'user': user_key,
'client_id': t.get('clientId'),
'display_text': t.get('displayText'),
'scopes': ' '.join(t.get('scopes', [])),
'native_app': t.get('nativeApp', False)
})
page_token = resp.get('nextPageToken')
if not page_token:
break
except HttpError as e:
if e.resp.status in [429, 500, 503]:
time.sleep(2)
continue
raise
with open('oauth_tokens.csv', 'w', newline='') as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys())
w.writeheader()
w.writerows(rows)
print(f'exported {len(rows)} rows')
if __name__ == '__main__':
list_tokens()
この出力をドメイン内メールとアプリ名で集計すると、承認アプリの上位と未知のベンダー領域が把握できます。社外コラボの許可方針と突き合わせると、許容すべき自由度と購買統制の落とし所が見つかります。
Slackの非アクティブ有償ユーザーを自動検出する
SlackのSCIM(プロビジョニング標準仕様)とAdmin APIを併用すると、課金対象のユーザーで一定期間アクティビティが無いアカウントを抽出できます。⁵⁶ トライアルでは30日未活動を基準に未利用候補とし、さらに人事台帳で在籍外であれば孤児席として即時回収対象にします。
# Code Sample 3: Slackの未活動ユーザー検出と通知
import os
import time
import requests
from datetime import datetime, timedelta
SLACK_TOKEN = os.getenv('SLACK_TOKEN')
ADMIN_BASE = 'https://slack.com/api'
HEADERS = {'Authorization': f'Bearer {SLACK_TOKEN}'}
THRESHOLD = datetime.utcnow() - timedelta(days=30)
def list_users():
url = f'{ADMIN_BASE}/users.list'
users, cursor = [], None
while True:
resp = requests.get(url, headers=HEADERS, params={'limit': 200, 'cursor': cursor}).json()
users.extend(resp.get('members', []))
cursor = resp.get('response_metadata', {}).get('next_cursor')
if not cursor:
break
time.sleep(0.3)
return [u for u in users if not u.get('is_bot') and not u.get('deleted')]
def last_active(user_id):
url = f'{ADMIN_BASE}/users.getPresence'
# Fallback: presence APIは近似。詳細はaudit logsと併用が望ましい
resp = requests.get(url, headers=HEADERS, params={'user': user_id}).json()
if resp.get('ok'):
# 疑似的にactive/awayのみ。実運用ではaudit_logs.exportで最終イベント時刻を参照
return datetime.utcnow() if resp.get('presence') == 'active' else datetime(1970,1,1)
return datetime(1970,1,1)
def notify(channel, text):
requests.post(f'{ADMIN_BASE}/chat.postMessage', headers=HEADERS, data={'channel': channel, 'text': text})
if __name__ == '__main__':
candidates = []
for u in list_users():
uid = u['id']
l = last_active(uid)
if l < THRESHOLD:
candidates.append(f"@{u.get('name')} ({u.get('profile',{}).get('email','n/a')})")
notify('#license-ops', f'未活動30日超の有償候補: {len(candidates)}名\n' + '\n'.join(candidates[:20]))
実運用ではSlackのAudit Logs APIでイベント時刻を使用し、SCIMでユーザーのactive状態、エンタープライズグリッドでは組織単位の計測を推奨します。閾値を45日や60日に伸ばすと誤検知は減りますが、回収速度も下がるため、最初は厳しめに設定して通知のみ行い、2サイクル目から自動回収に移行すると現場の抵抗が小さくなります。
GitHub Enterpriseの未使用席を正確に洗い出す
GitHubは単価が高く、未利用席の回収効果が大きい領域です。組織ごとの最終アクティビティを参照し、N日以上イベントが無い有償メンバーを候補にします。レート制限と失敗時の再試行を備えた実装例を示します。
# Code Sample 4: GitHub Enterpriseの未使用席抽出
import os
import time
import requests
from datetime import datetime, timedelta
TOKEN = os.getenv('GITHUB_TOKEN')
ORG = os.getenv('GITHUB_ORG')
HEADERS = {'Authorization': f'token {TOKEN}', 'Accept': 'application/vnd.github+json'}
THRESHOLD_DAYS = 45
def gh_get(url, params=None):
while True:
r = requests.get(url, headers=HEADERS, params=params)
if r.status_code in (429, 502, 503):
time.sleep(2)
continue
r.raise_for_status()
return r
def list_members():
members, page = [], 1
while True:
r = gh_get(f'https://api.github.com/orgs/{ORG}/members', params={'per_page': 100, 'page': page})
data = r.json()
if not data:
break
members.extend(data)
page += 1
time.sleep(0.3)
return members
def last_event(login):
r = gh_get(f'https://api.github.com/users/{login}/events/public', params={'per_page': 1})
ev = r.json()
if ev:
return datetime.strptime(ev[0]['created_at'], '%Y-%m-%dT%H:%M:%SZ')
return datetime(1970,1,1)
if __name__ == '__main__':
cutoff = datetime.utcnow() - timedelta(days=THRESHOLD_DAYS)
inactive = []
for m in list_members():
login = m['login']
le = last_event(login)
if le < cutoff:
inactive.append(login)
print(f'Inactive >= {THRESHOLD_DAYS}d: {len(inactive)} users')
アクティビティの定義は組織によって異なるため、コードプッシュやPRレビュー、Issue操作のいずれを基準にするかを先に合意しておくと後工程がスムーズになります。ここで得た候補を人事在籍情報と突合して孤児席を優先回収すると、初月から目に見える効果につながりやすくなります。
可視化で終わらせない。自動回収と契約最適化を回す
棚卸しの次は、継続運用の設計です。高価なアプリほど回収の自動化と座席の権限ダウングレードを整備すると、月次で効果が積み上がります。ここでは、在籍外や未活動が一定期間続いたユーザーに対し、ライセンスを段階的に縮小・撤去するフローをAPIで実装します。注意点は、利用再開時のリプロビジョニングを迅速にすることと、影響の大きいプロジェクトを除外リストで保護することです。
Okta連携で未活動ユーザーの段階的ダウングレードを自動化
Oktaで最終サインインと人事ステータスを確認し、一定期間の未活動を検知したら、まず有償プランから無料プランへ降格し、その後も未活動が続けば解除する、といった二段階の制御を行います。通知、承認、実行を一つのジョブにまとめ、失敗時のロールバックを設けておくと安心です。
# Code Sample 5: 未活動ユーザーの段階的ダウングレード(擬似SaaS)
import os
import time
import requests
from datetime import datetime, timedelta
OKTA_TOKEN = os.getenv('OKTA_TOKEN')
OKTA_ORG = os.getenv('OKTA_ORG')
HEADERS_OKTA = {'Authorization': f'SSWS {OKTA_TOKEN}', 'Accept': 'application/json'}
SAAS_BASE = os.getenv('SAAS_BASE')
SAAS_TOKEN = os.getenv('SAAS_TOKEN')
HEADERS_SAAS = {'Authorization': f'Bearer {SAAS_TOKEN}'}
INACTIVE_1 = timedelta(days=30)
INACTIVE_2 = timedelta(days=60)
def okta_users():
url = f'https://{OKTA_ORG}/api/v1/users?limit=200'
users = []
while True:
r = requests.get(url, headers=HEADERS_OKTA)
r.raise_for_status()
users.extend(r.json())
nxt = r.links.get('next', {}).get('url')
if not nxt:
break
url = nxt
time.sleep(0.3)
return users
def saas_set_plan(email, plan):
r = requests.post(f'{SAAS_BASE}/api/users/plan', headers=HEADERS_SAAS, json={'email': email, 'plan': plan})
if r.status_code not in (200, 204):
raise RuntimeError(f'plan change failed: {r.status_code} {r.text}')
def saas_deprovision(email):
r = requests.post(f'{SAAS_BASE}/api/users/deprovision', headers=HEADERS_SAAS, json={'email': email})
if r.status_code not in (200, 204):
raise RuntimeError(f'deprovision failed: {r.status_code} {r.text}')
if __name__ == '__main__':
now = datetime.utcnow()
for u in okta_users():
email = u['profile'].get('email')
status = u.get('status')
last_login = u.get('lastLogin')
last_login_dt = datetime.strptime(last_login, '%Y-%m-%dT%H:%M:%S.%fZ') if last_login else datetime(1970,1,1)
if status != 'ACTIVE':
saas_deprovision(email)
continue
delta = now - last_login_dt
try:
if delta >= INACTIVE_2:
saas_deprovision(email)
elif delta >= INACTIVE_1:
saas_set_plan(email, 'free')
except Exception as e:
# 失敗時は次回に再試行。エラーは監視に送る
print(f'error: {email} {e}')
time.sleep(0.5)
このフローを適用すると、初月で未活動席の一定割合が回収候補になりやすく、単価の高いアプリでは即月から数十万円規模の削減が生じうることが示唆されています。²³ 影響監視として、実行後1週間のチケット件数や復帰申請率をKPIに含めると、しきい値のチューニングが進めやすくなります。
契約交渉をデータで武装する。コミット曲線とベンチマーク
席の最適化と並行して、年間契約のコミット席数と単価を見直します。実利用に対するピークと平均のギャップを示すと、コミットを分割したり、段階的な単価を引き下げたりする余地が見えてきます。例えば、過去6か月の最大同時アクティブが900、平均が720であれば、コミットを750に下げ、150を従量で吸収する設計が現実的です。交渉材料として、重複アプリの統合計画や利用促進のロードマップも併記し、ベンダーにとっての拡張余地を見せると単価譲歩が得やすくなります。定量的な目安として、可視化と回収を組み合わせると90日以内に総SaaS支出の10〜20%削減を狙える可能性があり、交渉を加えると年率で15〜25%の恒常的削減が報告されています。¹²³
成果の測定、運用の定着、そして拡張
運用を定着させるには、ダッシュボード上で「未利用率」「孤児席件数」「月次削減金額」「回収からの復帰率」「サポートチケット増加率」を並べ、隔週のレビューでしきい値と除外ルールを調整します。重要なのは、削減だけを評価すると現場が防衛的になることです。利用促進の施策とセットで、例えば主要ツールのトレーニングやテンプレート整備を同時に走らせると、低利用が真の不要か習熟不足かを切り分けられます。技術的には、ジョブの実行時間、API失敗率、レート制限の発生数をSLO(サービス目標)として監視し、バックオフとキャッシュで改善します。規模1,000名程度であれば、夜間バッチは15分以内、API失敗率は1%以下、ダッシュボードの更新遅延は最大1時間という目標が現実的です。
運用が安定したら対象を広げます。まずは高単価の設計・開発系ツールから着手し、次にコラボレーション系、その後ニッチツールの整理へと進めると費用対効果が高くなります。最後に、購買プロセスとSSO必須化(新規SaaSはSSO連携を前提)を結び、社外ドメインとの共有ルールを明文化することで、影のサブスクが再発しにくい統治が実現します。削減のゴールを「節約」ではなく「価値への再投資」と定義すれば、現場は協力者になります。データで測り、コードで運び、対話で仕上げる。この三拍子がSaaSコスト最適化の中核です。
まとめ:削るための可視化、続けるための自動化
サブスクリプション費用の最適化は、一度の棚卸しで終わる仕事ではありません。IDとログを鍵に、未利用・低利用・孤児席を定義し、可視化・自動回収・交渉を一つのループに束ねることで、数字は継続的に改善します。今日始められる一歩は、小さなETLからで十分です。Google Workspaceのトークン列挙で未知のSaaSを炙り出し、SlackやGitHubの未活動席を候補化し、Okta連携で段階的ダウングレードを仕掛ける。これだけで90日で10〜20%のコスト削減を狙いにいける現実味が増します。²³ あなたの組織では、未利用の定義をどう置きますか。まずは一つ決めて、計測を始めましょう。メトリクスが決まれば、コードはすぐに書けますし、削減で生まれた余力はプロダクトの価値向上に振り向けられます。
参考文献
- Productiv. Less than half of company SaaS applications are regularly used by employees. https://productiv.com/blog/less-than-half-of-company-saas-applications-are-regularly-used-by-employees/
- CIO Dive. Software spend waste: SaaS waste reaches into the billions as AI adoption rises (reporting on Zylo). https://www.ciodive.com/news/software-spend-waste-saas-billions-zylo-ai-adoption/708548/
- Generation CFO. The true cost of SaaS. https://generationcfo.com/articles/tech-news/the-true-cost-of-saas
- Google Developers. Admin SDK Directory API: Method tokens.list. https://developers.google.com/admin-sdk/directory/reference/rest/v1/tokens/list
- Slack API. SCIM API reference. https://docs.slack.dev/reference/scim-api
- Slack API. users.getPresence method. https://docs.slack.dev/reference/methods/users.getPresence