0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Google Colab での Tailscale 利用時に発生するマシン名の衝突問題を回避する

Last updated at Posted at 2025-12-02

はじめに

2025年12月01日分のアドベントカレンダーの記事になります。

こんにちは、マーズフラッグの川嶋です。
社内では主にバックエンドの開発をしています。

最近、AIのモデル確認でGoogle Colaboを利用する機会が増えました。
その際にtailscaleを利用してリモート接続する上でちょっと便利になるよう工夫したので共有できればと思います~。

  • 本記事で得られること
    • Google Colaboへtailscaleを利用してSSH接続する方法
    • tailscale APIを利用して、マシン名からマシンを削除する方法

背景と問題の整理

Colabは一時的に生成される環境であるため、マシン名を指定してtailscaleに登録時、同一マシン名で既に管理されていると、違うマシン名で登録されてしまいます。
この結果、VSCodeなどからのリモート接続がうまくいかなくなったり、毎回手動でtailscaleコンソールからマシンを削除したりする必要があり、地味に手間です。

解決方法

そこで、環境作成時にtailscaleから同名のホスト名をスクリプトから削除することで、この問題を解決します。
本記事では、ホスト名を指定してtailscale管理マシン上から削除できるスクリプトの作成手順を紹介します。Google colabo上の開発スクリプトに組み込んでおけば、わざわざコンソールから作業する必要もなくなります。

実践

OAuthクレデンシャル作成

tailscaleコンソールでスクリプト実行用のOAuthクレデンシャルを作成します。
これを利用してAPIを実行します。

credential.png

マシンに付加するタグの設定

tailscaleのコンソールの画面上段のAccess-controlsから表示します。
タグに対して権限を設定します。

Access-controls-Tailscale-2.png

tailscalceからマシン名を指定してマシンを削除するスクリプト

前述したOAuthクレデンシャルを利用してマシンを削除します。
削除スクリプトとしては以下のようになります。

import json
import urllib.request
import urllib.parse
import sys

# ==== 設定部分 ====
# 前述のOAuth設定を参照
CLIENT_ID = "{クライアントID}"

# 前述のOAuth設定を参照
CLIENT_SECRET = "{クライアントシークレット}" 

# tailnet 名(管理画面のドメイン部分)
TAILNET_NAME = "{tailenetのFQDN}"

# 削除したいデバイス名(完全一致 or 部分一致でもOK)
DEVICE_NAME = "sample-server"

TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2"
# ===================

def http_request(method, url, headers=None, data=None):
    """標準ライブラリでHTTPリクエストを実行"""
    if data is not None and not isinstance(data, bytes):
        data = json.dumps(data).encode("utf-8")

    req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)

    try:
        with urllib.request.urlopen(req) as res:
            body = res.read()
            if body:
                return res.status, json.loads(body.decode("utf-8"))
            return res.status, None
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="ignore")
        print(f"HTTPエラー: {e.code} {err_body}")
        sys.exit(1)


def get_access_token():
    """OAuthトークン取得"""
    url = f"{TAILSCALE_API_BASE}/oauth/token"
    data = {
        "grant_type": "client_credentials",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    }
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    encoded = urllib.parse.urlencode(data).encode("utf-8")

    req = urllib.request.Request(url, data=encoded, headers=headers, method="POST")

    try:
        with urllib.request.urlopen(req) as res:
            token_info = json.loads(res.read().decode("utf-8"))
            return token_info["access_token"]
    except urllib.error.HTTPError as e:
        print(f"トークン取得失敗: {e.code} {e.read().decode('utf-8', errors='ignore')}")
        sys.exit(1)


def find_device_by_name(tailnet, token, name):
    """デバイス一覧を取得して名前で検索"""
    url = f"{TAILSCALE_API_BASE}/tailnet/{tailnet}/devices"
    headers = {"Authorization": f"Bearer {token}"}

    status, data = http_request("GET", url, headers)
    devices = data.get("devices", [])
    matches = [d for d in devices if name.lower() in d["name"].lower()]

    if not matches:
        print(f"'{name}' に一致するデバイスは見つかりません。")
        return None
    elif len(matches) > 1:
        print("⚠️ 複数一致しました。候補一覧:")
        for d in matches:
            print(f"  - {d['name']} (id={d['id']})")
        print("最初の一致デバイスを削除します。")
    return matches[0]


def remove_device(device_id, token):
    """指定デバイスを削除"""
    url = f"{TAILSCALE_API_BASE}/device/{device_id}"
    headers = {"Authorization": f"Bearer {token}"}
    status, _ = http_request("DELETE", url, headers)
    if status in (200, 204):
        print(f"✅ デバイス {device_id} を削除しました。")
    else:
        print(f"❌ 削除失敗: status={status}")


def main():
    token = get_access_token()
    device = find_device_by_name(TAILNET_NAME, token, DEVICE_NAME)
    if device:
        remove_device(device["id"], token)


if __name__ == "__main__":
    main()

環境構築用スクリプトの用意

他の記事でも多く紹介されている内容ですので、詳細の説明は省きますが、以下のようなスクリプトを準備します。これにより、この環境(マシン)がtailscaleに登録され外部から接続できるようになります。

注意すべきは、スクリプト最後で、SSHのみ許可でtailscaleを設定している箇所について前述したタグを指定して、環境(マシン)にタグを付加しています。これを指定しないと権原不足でAPIによる削除ができません。

# ホスト名を変更して、tailscaleの管理マシン名を以下でアクセスできるように設定
# この名前でVSCode等、外部からアクセスできるようになる
!hostname sample-server

# tailscaleの状態ファイル用ディレクトリを作成
!mkdir -p /var/lib/tailscale

# tailscaleをインストール
!curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
!curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list
!apt-get update
!apt-get install tailscale

# tailscaleデーモンをバックグラウンドで起動し、tailscaleネットワークに参加
!nohup tailscaled --tun=userspace-networking --socket=/run/tailscale/tailscaled.sock --port 41641  >/dev/null 2>&1 &

# SSHのみ許可でtailscaleを設定
# コマンド実行後にコンソールにリンクが表示されるのでそのリンクをブラウザにて表示し、アクセスを許可
# 権限を付加したタグを指定しておく
!tailscale up --ssh --advertise-tags=tag:tag11111,tag:tag22222

SSH接続する為の設定例

以下をsshクライアントの設定に追加して実行すれば接続できるます。

Host {サーバー名を設定でこの記事ではsample-server.tailZZZZ.ts.netみたい感じになる}
    HostName {サーバー名を設定でこの記事ではsample-server.tailZZZZ.ts.netみたい感じになる}
    User root
    IdentityFile ~/.ssh/秘密鍵
    ForwardAgent yes
    # これがないとマシンの再構築等でエラーになる
    StrictHostKeyChecking no
    UserKnownHostsFile /dev/null    

まとめ

Google Colaboや、Runpod等、こうした低コストで自由に試せる環境があるのは本当にありがたいです。
調べて ‘分かった気’ になるだけで終わらないよう、今後も手を動かして試しながら学んでいきたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?