このガイドは徹底的で滑らかなので、あなたが直接望むパーツにアクセスすることができます. There will be an accompanying GitHub repo (Coming soon) that you can use to go through this. インデックス: About Errata What This Guide Covers and Does Not. What about LLMs and tools? (Gemini, Claude, Claude Code, Cursor, etc.) Definitions Requirements What is Google Cloud Workload Identity Flotation? Architecture Overview Configure a custom OIDP (Open ID Provider) using Dex. How to configure a custom one using DEX, which is an identity provider. How to connect a Docker application to pull it. There's also going to be some code, some JavaScript code that we're going to use to connect directly to it and issue some tokens. Follow for more. について エンタープライズの Google Cloud Platform (GCP) アカウントを設定することは、いくつかの課題をもたらします(Vertex AI を介して Vercel/Digital Ocean/Hetzner のような外部サービスで Gemini にアクセスするなど)。 以前は、サービスの許可を保持するサービスアカウント(例えばVertex AI)を作成し、GCPに接続するために使用する長期(有効期限がない)キーを生成します。 これはお勧めしません。 サービスアカウントキーが損なわれ、回転が困難になる可能性があります。 Google は、Workload Identity Federation および Open ID Connect を通じて短命トークンを使用してサービスと通信することをお勧めします。 しかし、あなたは生み出す 代わりに __still use __a service account 短命トークン 短命トークン 警告は、この構成は単純ではありません。文書は希少で、それは混乱です。それはそれを正すために私に 1.5 パートタイムの月を要しました。 心配しないで、 このガイドは、このステップごとにどのように行うかを教えます。 トークンを発行するために使用されるオープンIDプロバイダー(Dex)を構成し、Dockerとアプリケーションを接続し、Artifact Registryから画像を抽出し、VPS(Hetzner)を通じてVertex AIに接続することができます。 エラー 私はこれを私の最善の能力でやりました. アクセスして改善を提案し、または間違いを修正することを自由に感じてください! このガイドは何をカバーし、何をしないのか。 カバー : Dex を使用して Google Cloud に対して認証する外部アイデンティティプロバイダーの設定 Docker と OpenID Connect/Workload Identity Federation を使用して、アーティファクトレジストリを通して画像を引っ張ります。 Google-auth-library を使用して NodeJS アプリケーションを通じて OIDC トークンを発行します。 覆いません: 外部VPSまたはGoogle Cloudでサーバーを初期化する方法 VPSにファイルをアップロードする方法 IaC (Infrastructure as Code) を使用して Google Cloud Services を設定する方法 GCPとコミュニケーションするためのGitHubアクションを設定する方法 このガイドでは、デックス(オープンソース)を使用してアイデンティティプロバイダーを設定し、それに対してOIDCトークンを生成する方法について説明します。 LLMやツールはどうですか? (Gemini、Claude、Claude Code、Cursorなど) 助けられた 彼らは、Google Workload Identity Federation と Dex に必要な論理を組み合わせる方法を知らなかった。 a lot LLMをこのページに指し、それを設定するのに役立ちます。 定義 Google クラウドプラットフォーム(GCP) 私たちがOIDCで認証するために使用するクラウドソリューション. Shortened as GCP for this article. ワークロードアイデンティティー連盟: これは、ワークロード(アプリ、サービス、CI/CDパイプライン、VM、コンテナなど)を実行する方法です。 クラウド プロバイダーから、そのプロバイダーの API に安全に認証する . 外部 without using long-lived service account keys あなたのアプリに静的なキーファイルを提供する代わりに(漏洩した場合の重大なセキュリティリスク)WIFは、クラウドプロバイダーが信頼できるようにします。 GitHub Actions、GitLab、Kubernetes、またはOIDC/SAML 互換性のあるプロバイダーなど。 external identity provider (IdP) 言い換えれば、特定の権限を持つトークンを生成し、パイロード(例えば、クラウドストレージの取得、リスト、アップロード)を通じて安全に送信することができます。 アイデンティティプロバイダー これは、ユーザーまたはマシン(私たちのコード)が「ログイン」または1つまたは複数のサービスに対して認証するために使用できる中央ハブです。 詳細にどのように機能するかは後でご覧いただけます。 アイデンティティプロバイダーの例 開発者プラットフォーム(OIDCネイティブ):これらは、GCPに転送し、必要なトークンを自動的に生成できる特定のエンドポイントを持っています。 GitHub (GitHub Actions OIDC トークン) GitLab(CI/CDパイプラインとOIDC) ビットバケット(Pipelines OIDC) その他の提供者: オクタ オーシャン Azure アクティブディレクトリ Google アイデンティティプラットフォーム Ping アイデンティティ AWS IAM Identity Center キーロック Dex (The One We Will Be Using) Dex https://dexidp.io/ ユーザー名とパスワードで設定し、RESTエンドポイントを通じて、選択したツール(cURL、wget、JavaScriptのfetch/xhr/axiosなど)を使用して呼び出します。 Why? オープンソースであり、自由にホストできます。 シンプルでシンプルなYAMLファイルが必要です。 Kubernetes を実行する必要はありません(その環境では人気がありますが)。 Google Cloud と VPS などの外部ソース間の接続を実装する方法、Google Cloud Workload Identity Federation、Dex (アイデンティティプロバイダー) を使用する方法などについて説明します。 ヘッツナー https://www.hetzner.com/ それはその印象的な価格 / パフォーマンス比のために選択されたクラウドプロバイダーです. Plus it has a very simple UI and IaC (Infrastructure as Code) plugins that make it a breeze to setup. ドップラー https://www.doppler.com/ HashiCorp Vault または AWS Secrets Manager に類似するもの。 Doppler により、さまざまなプロジェクトや支店に環境変数を格納することができ、チームのすべてのメンバーがそれらを共有することができます。 ソロで働く場合でも、あなたのすべてのニーズをカバーする非常に寛大な無料レベルがあります。私は昨年以来、私のすべてのサービスのすべてのパスワードを管理するためにそれを使用してきました。 完全にオプションですが、この投稿では、環境をロードするためにDopplerを使用していると仮定します(コマンドの隣に .env をロードすることができます。 要求事項 A Google Cloud Platform (GCP) account Have a server you can connect to (SSH): Virtual Machine - VPS, Bare Metal, etc. A domain name (.com, .ai, .app, .io etc.) pointing to your server. Basic Knowledge of: Docker Google CLI Bash I assume: You have a working GCP project. You have Artifact Registry enabled with a docker image Have installed Docker in your VPS. Google CLI をマシンにインストール オフィシャルダウンロードインストールリンク: https://cloud.google.com/sdk/docs/install オフィシャルダウンロードインストールリンク: https://cloud.google.com/sdk/docs/install 私たちの最初の仕事は、必要な許可を設定し、サービスを有効にすることです。 これらは、あなたがそれをインストールできる場所です。 (この記事から追加の手順が省略されていることに注意してください。 Windows : インストーラーをここからダウンロード マコス: ブリュウをインストールする(あなたが持っていない場合): /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" Install Google CLI: brew install --cask gcloud-cli Linux : (ファイルをダウンロード) curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-x86_64.tar.gz Open it: tar -xf google-cloud-cli-linux-x86_64.tar.gz Google Cloud でプロジェクトを作成するか、既存のプロジェクトを使用する 行こう: https://console.cloud.google.com/ アカウントを選択し、すでに作成していない場合は新しいプロジェクトを作成します。 Google Cloud CLIをインストールする それをインストールした後、ターミナルを開いて実行してください: gcloud init これで接続プロセスが始まります。 CLI は、Google アカウントをリンクすることによって動作します。このアカウントには、管理操作を行うために必要な許可が必要です。 CLI は、Google アカウントをリンクすることによって動作します。このアカウントには、管理操作を行うために必要な許可が必要です。 Choose the project you’ve created. 認証後、CLI はあなたに、あなたが作業したいプロジェクトを選択するように依頼します。 これは後で変更できます: gcloud config set project PROJECT_ID あるいは、あなたは常に通過することができます。 旗 2 コマンド --project PROJECT_ID all the gcloud サービスを可能にする: これらを可能にする必要があります。 あなたのターミナルで、 Run: gcloud services enable \ iamcredentials.googleapis.com \ sts.googleapis.com \ iam.googleapis.com \ cloudresourcemanager.googleapis.com 環境変数 全体的に使われているので、これらを自分のものに置き換えてください。 export PROJECT_ID="spiritual-slate-445211-i1" export PROJECT_NUMBER="1016670781645" export POOL_ID="hetzner-pool" export PROVIDER_ID="hetzner-provider" export SERVICE_ACCOUNT_ID="hetzner" export SERVICE_ACCOUNT="$SERVICE_ACCOUNT_ID@${PROJECT_ID}.iam.gserviceaccount.com" export DEX_ISSUER="https://auth.yourdomain.com" export ROLE_NAME="hetzner_role" プロジェクトIDとプロジェクト番号をメインページから取得できます。 プロジェクトIDとプロジェクト番号をメインページから取得できます。 Workload Identity Federation を設定する GCPでどのように機能するか: 複数のプロバイダまたはクライアントがそれに接続するアイデンティティプールを作成します。 これらのプロバイダーは、Dexのトークンが認証するために使用されるものです。 「属性マッピング」を使用してこれらを制限しようとします。 デックスのJWTを解析し、属性マッピングのフィールドをトークンのフィールドと比較します。 プールを可能な認証方法のグループとして考えて、GCPが他の第三者に対し認証できることをGCPに知らせる。 プールを可能な認証方法のグループとして考えて、GCPが他の第三者に対し認証できることをGCPに知らせる。 プールを作る このコマンドを実行する: gcloud iam workload-identity-pools create "$POOL_ID" \ --project="$PROJECT_ID" \ --location="global" \ --display-name="Hetzner Workload Identity Pool" \ --description="Pool for Hetzner VPS to access GCP services via OIDC" 例: gcloud iam workload-identity-pools create "hetzner-pool" \ --project="spiritual-slate-445211-i1" \ --location="global" \ --display-name="Hetzner Workload Identity Pool" \ --description="Pool for Hetzner VPS to access GCP services via OIDC" PowerShellの例: gcloud iam workload-identity-pools create "hetzner-pool" ` --project="spiritual-slate-445211-i1" ` --location="global" ` --display-name="Hetzner Workload Identity Pool" ` --description="Pool for Hetzner VPS to access GCP services via OIDC" OIDCプロバイダーの作成 これはGoogle Cloudとの中心的な接続点となるでしょう. これはDexからのJWTを検証する入力点になります。 --attribute-mapping flag. This is the one that tells GCP to compare the fields with JWT for OIDC. I recommend you (while testing) to start wide and then narrow down the permission scope as you become more knowledgeable. これは、JCPがJWT for OIDCとフィールドを比較するように言います。 --attribute-mapping flag. This is the one that tells GCP to compare the fields with JWT for OIDC. I recommend you (while testing) to start wide and then narrow down the permission scope as you become more knowledgeable. これは、JCPがJWT for OIDCとフィールドを比較するように言います。 # Create OIDC provider pointing to your DEX instance gcloud iam workload-identity-pools providers create-oidc "$PROVIDER_ID" \ --project="$PROJECT_ID" \ --location="global" \ --workload-identity-pool="$POOL_ID" \ --display-name="Hetzner Dex OIDC Provider" \ --description="OIDC provider using Dex for Hetzner VPS authentication" \ --attribute-mapping="google.subject=assertion.sub,attribute.email=assertion.email,attribute.groups=assertion.groups" \ --issuer-uri="$DEX_ISSUER" 例: # Create OIDC provider pointing to your DEX instance gcloud iam workload-identity-pools providers create-oidc "hetzner-provider" \ --project="spiritual-slate-445211-i1" \ --location="global" \ --workload-identity-pool="hetzner-pool" \ --display-name="Hetzner Dex OIDC Provider" \ --description="OIDC provider using Dex for Hetzner VPS authentication" \ --attribute-mapping="google.subject=assertion.sub,attribute.email=assertion.email,attribute.groups=assertion.groups" \ --issuer-uri="https://auth.yourdomain.com" これは、JWTの主題と電子メールが有効とみなされるプールのものと一致しなければならないことをGCPに通知します。 サービスアカウント設定 はい、GCP リソースにアクセスするために使用される最終的な権限を持つサービスアカウントを作成する必要があります。 違いは、我々は このアカウントを使用 常設サービスアカウントのキーを使用します。 impersonate 短期トークン 短期トークン サービスアカウントを作成する gcloud iam service-accounts create "$SERVICE_ACCOUNT_ID"\ --project="$PROJECT_ID" \ --display-name="Hetzner VPS Service Account" \ --description="Service account for Hetzner VPS containers" 例: gcloud iam service-accounts create "hetzner" \ --project="spiritual-slate-445211-i1" \ --display-name="Hetzner VPS Service Account" \ --description="Service account for Hetzner VPS containers" パワーシェル: gcloud iam service-accounts create "hetzner" ` --project="spiritual-slate-445211-i1" ` --display-name="Hetzner VPS Service Account" ` --description="Service account for Hetzner VPS containers" 役割をサービスアカウントに割り当てる このリンクを参照してGCPの事前定義された役割を割り当てることができます。 https://cloud.google.com/iam/docs/roles-permissions 私は、許可を直接カスタマイズされた役割に設定するより細かいアプローチを選択し、攻撃面積を減らすのに役立ちます。 OR - Granular roles or permissions を割り当てる: このアプローチは、GCP (403 forbidden errors here and there) で少しずつ進みますが、最終的にはよりセキュアなインフラがあります。 あなたはこのリンクでグラナルの許可を見つけることができます: https://cloud.google.com/iam/docs/リファレンス まずは、習慣的な役割を作ることから始めます。 Create a custom role for the service account - with minimum permissions カスタム ロールを作成するには、各許可を保持する YAML ファイルが必要です。 「roles-hetzner.gcp.yml」と名付けられたファイルを作成し、以下をコピーして挿入します(このファイルはあなたのマシンまたはレポでローカルに存在することに注意してください) # To update the role: gcloud iam roles update hetzner_role --project=spiritual-slate-445211-i1 --file=./roles-hetzner.gcp.yml title: Hetzner GCP Roles description: | This policy ensures that the Hetzner VPS has the required permissions to access all the Google Cloud services needed for running Docker containers with the same functionality as when they were deployed on Cloud Run. stage: GA # https://cloud.google.com/iam/docs/permissions-reference includedPermissions: # === IAM Permissions === # For service account creation and management - iam.serviceAccounts.getAccessToken - iam.serviceAccounts.signBlob # Required for signed URLs # === Artifact Registry Permissions === # For Docker image storage and management - artifactregistry.repositories.get - artifactregistry.repositories.list - artifactregistry.repositories.downloadArtifacts - artifactregistry.packages.get - artifactregistry.packages.list - artifactregistry.versions.get - artifactregistry.versions.list - artifactregistry.dockerimages.get - artifactregistry.dockerimages.list - artifactregistry.tags.get - artifactregistry.tags.list - artifactregistry.files.get - artifactregistry.files.list - artifactregistry.files.download - artifactregistry.versions.get - artifactregistry.versions.list - resourcemanager.projects.get - artifactregistry.attachments.get - artifactregistry.attachments.list # === Cloud Storage Permissions === # For bucket and object operations - storage.objects.create - storage.objects.delete # Optional: only include if you need to delete images - storage.objects.get - storage.objects.list - storage.objects.update - storage.objects.getIamPolicy - storage.objects.setIamPolicy # === Vertex AI Permissions === # For AI/ML platform operations - aiplatform.endpoints.predict - aiplatform.endpoints.get # Permissions for operations management - aiplatform.operations.list And then execute the command: gcloud iam roles create "$ROLE_NAME" --project="$PROJECT_ID" --file="./roles-hetzner-gcp.yml" gcloud iam roles create hetzner_role --project="spiritual-slate-445211-i1" --file="./roles-hetzner-gcp.yml" リフレッシュ: コマンドの --file パラメータは、ターミナルが次の場所にある相対的なパスを指します。 ファイルが同じ場所にあるので、私たちは行うことができます: gcloud iam roles create "herzner_role" --project="spiritual-slate-445211-i1" --file="roles-hetzner.gcp.yml" リフレッシュ: The --file parameter in the command points to a relative path where your terminal is: ファイルが同じ場所にあるので、私たちは行うことができます: gcloud iam roles create "herzner_role" --project="spiritual-slate-445211-i1" --file="roles-hetzner.gcp.yml" /Users/joseasilis/Documents/programming/alertdown/libs/infrastructure/src/pulumi 役割の更新 役割を変更するには、以下のように更新できます。 gcloud iam roles update hetzner_role --project=spiritual-slate-445211-i1 --file="./roles-hetzner.gcp.yml" How to get granular use it: グラナルの使い方: How to get granular use it: 私が使用したいサービス、例えばVertex AIを検索し、それをクリックします。 LLM または Editor/Viewer 役割を使用して、割り当てたい役割を要求します。 たとえば、私はそれが 持っている The ジェミニーと呼ぶことを許可します。 Vertex AI User aiplatform.endpoints.predict あなたが許可を得た場合、Google Cloud から 403 Forbidden が表示されます。 YAML ファイルに許可を追加し、役割を更新します。 欠けているサービスの提供 欠けているサービスを実行することで有効にすることができます: gcloud services enable <service-api>.googleapis.com 役割をサービスアカウントに割り当てる gcloud iam service-accounts add-iam-policy-binding \ "$SERVICE_ACCOUNT" \ --project="$PROJECT_ID" \ --role="projects/$PROJECT_ID/roles/$ROLE_NAME" \ --member="serviceAccount:$SERVICE_ACCOUNT" gcloud iam service-accounts add-iam-policy-binding \ "hetzner@spiritual-slate-445211-i1.iam.gserviceaccount.com" \ --project="spiritual-slate-445211-i1" \ --role="projects/spiritual-slate-445211-i1/roles/hetzner_role" \ --member="serviceAccount:hetzner@spiritual-slate-445211-i1.iam.gserviceaccount.comT" Connecting the Service Account to Workload Identity Federation and allowing Service Account Impersonation 上記の 2 つのステップにかかわらず、ワークロードIdentityUser 役割をユーザーに直接付属します(代わりに直接権限を追加することを選択できます)。 gcloud iam service-accounts add-iam-policy-binding \ "$SERVICE_ACCOUNT" \ --project="$PROJECT_ID" \ --role="roles/iam.workloadIdentityUser" \ --member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/*" Which becomes: gcloud iam service-accounts add-iam-policy-binding \ hetzner \ --project="spiritual-slate-445211-i1" \ --role="roles/iam.workloadIdentityUser" \ --member="principalSet://iam.googleapis.com/projects/1016670781645/locations/global/workloadIdentityPools/hetzner-pool/*" 「Inside the VPS - Docker Compose」 OpenID プロバイダー (Dex) に別々のサーバーを提供することをお勧めしますが、コストを削減するためにアプリと Dex を 1 つのサーバーで共同ホストすることにしました。 OpenID プロバイダー (Dex) に別々のサーバーを提供することをお勧めしますが、コストを削減するためにアプリと Dex を 1 つのサーバーで共同ホストすることにしました。 今、私たちはVPSに移行しています(例:Hetzner)。Git pull / or shell スクリプトをサーバーにコピーします(Docker compose といくつかのシェルファイル) 私たちは私たちを助けます: Dex サービスを起動し、IDP を設定します。 Authenticate against Artifact Registry with Docker. 私たちのHTTPSドメインのためのTLS証明書を設定します。 Nginx を Remix/React-Router アプリにゼロ展開で構成します。 Docker 構成: services: # Certificate management (initial + renewal) certbot: image: certbot/dns-cloudflare restart: unless-stopped volumes: # Map certbot data directly to avoid double nesting - ./certbot/conf:/etc/letsencrypt - ./certbot/www:/var/www/certbot - ./certbot/logs:/var/log/letsencrypt - ./certbot-scripts/certbot-manager.sh:/certbot-manager.sh:ro - /run/secrets/cloudflare.ini:/etc/cloudflare/cloudflare.ini:ro environment: - EMAIL=${CERTBOT_EMAIL:-anyemail@mydomain.com} entrypoint: ['/bin/sh', '/certbot-manager.sh'] networks: - shared-network nginx: image: nginx:stable-alpine restart: unless-stopped ports: - '80:80' # HTTP for certbot challenges and redirects - '443:443' # HTTPS for auth.mydomain.com volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./certbot/www:/var/www/certbot:ro - ./certbot/conf:/etc/letsencrypt:ro depends_on: - dex networks: - shared-network # Dex OIDC server dex: build: context: ./dex dockerfile: Dockerfile restart: unless-stopped networks: - shared-network volumes: - ./dex:/etc/dex - ./dex/data:/data - ./dex/entrypoint.sh:/entrypoint.sh:ro - ./dex/config.yaml:/etc/dex/config.yaml:ro environment: - DOPPLER_TOKEN=${DOPPLER_TOKEN} - DEX_GCP_STATIC_CLIENT_ID=${DEX_GCP_STATIC_CLIENT_ID} - DEX_GCP_STATIC_CLIENT_SECRET=${DEX_GCP_STATIC_CLIENT_SECRET} - DEX_GCP_STATIC_PASSWORD_EMAIL=${DEX_GCP_STATIC_PASSWORD_EMAIL} - DEX_GCP_URL=${DEX_GCP_URL} healthcheck: test: ['CMD', 'wget', '-qO-', 'http://localhost:5556/healthz'] interval: 5s timeout: 5s retries: 3 start_period: 3s expose: - '5556' # Main application app: image: us-east1-docker.pkg.dev/myfirstproject/remix-app-artifact-repo-production/remix-app-production:latest restart: unless-stopped networks: - shared-network working_dir: /app/apps/remix-app-vite command: ['doppler', 'run', '--', 'bun', 'server.ts'] expose: - '8080' environment: - DOPPLER_TOKEN=${DOPPLER_REMIX_TOKEN} healthcheck: test: [ 'CMD', 'sh', '-c', 'test ! -f /tmp/drain && curl -f --max-time 5 http://localhost:8080/health', ] interval: 5s timeout: 10s retries: 3 start_period: 3s # Allow time for app startup labels: # Configure docker-rollout pre-stop hook for graceful shutdown - 'docker-rollout.pre-stop-hook=sleep 7' networks: shared-network: driver: bridge The docker compose is made of 5 parts: Certbot - 私たちのためにTLS証明書を生成するために使用されます. 私たちはHTTPS接続を発行する必要があります. Googleが当社のサービスに接続し、リクエストの正確性を検証し、そしてセキュリティ101だからです. Nginxはその後、証明書を処理します. 私はCloudflareをプロキシとして使用しています. このバージョンのCertbotは私たちのCloudflareアカウントに接続し、私たちのためにDNS検証を管理し、証明書を自動的に生成します. Will consume the TLS certificate for both: Dex (IDP), and the React Router app. It will also serve as a load balancer when I’m trying to perform zero-downtime deployments with . Nginx - Docker Rollout Dex - The star of the show. It will create the Identity Provider that will issue our OIDC tokens that will be used to exchange for short-lived credentials to impersonate the service account. Dexの野蛮な特徴は、それが単一のYAMLファイルで構成されていることである。 - The NodeJS Remix/React Router docker image hosted in Artifact Registry. This was built in a CI/CD pipeline and then pushed to it (Outside the scope of this tutorial). The Docker image hosted in Artifact Registry, which will configure later on to pull it from Google using Workload Identity Federation and OIDC. This one has a health check that is used by Docker rollout to kill the old container (see down below). App - won’t cut it. We need to make sure certificates are issued first, and it was easier for me to handle the orchestration with a shell script. Some kung-fu was required to have Nginx up and running (As Dex needed to be booted up first before the app) The startup shell script docker compose up Docker Rollout (A bit out of scope, but I wanted to include it anyway) これはWowuによって作成された単一ファイルのスクリプトです。 that helps us have zero-downtime Docker container updates without the need for Kubernetes. https://github.com/Wowu/docker-rollout To install, execute in the VPS the following: # Create directory for Docker cli plugins mkdir -p ~/.docker/cli-plugins # Download docker-rollout script to Docker cli plugins directory curl https://raw.githubusercontent.com/wowu/docker-rollout/main/docker-rollout -o ~/.docker/cli-plugins/docker-rollout # Make the script executable chmod +x ~/.docker/cli-plugins/docker-rollout 使い方はとてもシンプルで、どこでも使えます。 (note the new syntax), We’d use その代わりに docker compose docker rollout 最新の画像をアップデートするには、あなたは Artifact Registry の新しい画像と実行 . It will automatically switch images without downtime. docker compose pull app docker rollout docker-compose.yml で定義された container_name および port は、同じ名称またはポートマッピングで複数の container を実行することはできません。 Your service cannot have and defined in 同じ名前の複数のコンテナまたはポートマッピングを実行することはできません。 container_name ports docker-compose.yml How does it work? Docker rollout will spin up a new instance of the app (That’s why we don’t use a 新しいサービスが起動し、実行されると、空の拡張なしのセンチネルのファイルを追加します(これはエンドポイント呼び出しかもしれません)。 in the temporary directory Docker container: . container_name drain 内 内 /tmp/drain これは、健康チェックを失敗させ、古いものを殺し、新しいものを生き残るためにDockerの展開をシグナルにします。 魔法の一部は Nginx 経由で行われます. それは自動的にアップストリームのサーバーにランダムをロードします (スイッチを作成するときに 2 つのアプリインスタンスが発生します) そして、健康チェックで失敗した 1 つを検出すると、サービスを停止し、新しいものにリダイレクトします。 In other kinds of apps, which aren’t placed in front of a load balancer, like a Temporal.io worker (Outside of the scope of this tutorial), you’ll need to add a mechanism to drain the worker and let it automatically 変化を起こす。 docker rollout ドップラー I stated that Doppler is a service responsible for handling all our environment variables. It’s straightforward to get started: You create an account with them, install the CLI, and add your environments to their service. You'll see down below that Dex will ask for a bcrypt hash. These hashes contain dollar signs ($) which need to be escaped in bash. Don't escape them in Doppler. We will handle them using a file replacement mechanism instead. This took me a solid 7 hours to get right. これらのハッシュには、バッシュで逃げる必要があるドルシグナル($)が含まれています。Dopplerで逃げないでください。代わりにファイル交換メカニズムを使用して処理します。 This took me a solid 7 hours to get right. CLI installation あなたは現在使用しているVPSまたはサーバーにDopplerをインストールする必要があります。 以下のコマンドを使ってインストールできます。 curl -Ls --tlsv1.2 --proto "=https" --retry 3 https://cli.doppler.com/install.sh | sudo sh Doppler Service に接続するために Doppler Token を生成する DEX 構成 Dex は “Federated OpenID Connect Provider” です。 言い換えれば、ID プロバイダと接続するための仲介者として機能します(これらのように考えてください: Google でログインし、 GiTHub でログインしてください)。 OpenID は OAuth 2.0 に基づく認証プロトコルで、アプリやユーザーが REST のような API でユーザープロフィール情報を取得できます。 It is provided as a Docker image, and we will configure it using Docker Compose. Dex が好きなのはそのシンプルさです. あなたは単一の YAML ファイルが必要です. コードのいくつかの行で奇跡を起こします. Create a file and place it in your VPS in (Create the directory with ) config.yaml ~/dex/config.yaml mkdir ~/dex # dex/config.yaml - Configuration for Google Cloud Workload Identity Federation issuer: $DEX_GCP_URL storage: type: sqlite3 config: file: /var/dex/dex.db web: # Listen on HTTP, assuming a reverse proxy handles TLS termination. http: 0.0.0.0:5556 # Enable the password database to allow authentication with static passwords. enablePasswordDB: true # Define a static user for authentication. # This user will be used to exchange a Dex ID token for a GCP token. staticPasswords: - email: $DEX_GCP_STATIC_PASSWORD_EMAIL hash: $DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED username: $DEX_GCP_STATIC_PASSWORD_EMAIL userID: '722ba69a-3cba-4007-8a24-2611d4c4d5f9' # The `staticClients` list contains OAuth2 clients that can connect to Dex. staticClients: # This is the client for Google Cloud Workload Identity Federation. # The `id` MUST be the full resource name of the GCP Workload Identity Provider. # This value will be the `aud` (audience) claim in the OIDC token. - id: $DEX_GCP_STATIC_CLIENT_ID secret: $DEX_GCP_STATIC_CLIENT_SECRET name: 'Google Cloud Workload Identity Federation' # Redirect URIs are not used in the token-exchange flow. redirectURIs: [] # This section configures OAuth2 behavior. oauth2: # Use the built-in password database as the connector for the password grant type. # This allows the static user defined above to authenticate. passwordConnector: local # By default, Dex supports the necessary grant types, including 'token-exchange' # and the response types 'code', 'token', and 'id_token'. # Explicitly defining them is not necessary unless you need to restrict them. skipApprovalScreen: true If we replace it with our environments, we get: # dex/config.yaml - Configuration for Google Cloud Workload Identity Federation issuer: https://auth.yourdomain.com storage: type: sqlite3 config: file: /var/dex/dex.db web: # Listen on HTTP, assuming a reverse proxy handles TLS termination. http: 0.0.0.0:5556 # Enable the password database to allow authentication with static passwords. enablePasswordDB: true # Define a static user for authentication. # This user will be used to exchange a Dex ID token for a GCP token. staticPasswords: - email: auth@mydomain.com hash: $2y$10$k2IZomh1UUyDlNrsuoNiFuNlOIn5Siw738AdFA6ukcMu07H0uGQ7K username: auth@mydomain.com userID: '722ba69a-3cba-4007-8a24-2611d4c4d5f9' # The `staticClients` list contains OAuth2 clients that can connect to Dex. staticClients: # This is the client for Google Cloud Workload Identity Federation. # The `id` MUST be the full resource name of the GCP Workload Identity Provider. # This value will be the `aud` (audience) claim in the OIDC token. - id: //iam.googleapis.com/projects/1016670781645/locations/global/workloadIdentityPools/hetzner-pool/providers/hetzner-provider secret: 28XhU2xcgQnusLRlG4nZlUZbFdn3lfof21jvvbTG970 name: 'Google Cloud Workload Identity Federation' # Redirect URIs are not used in the token-exchange flow. redirectURIs: [] # This section configures OAuth2 behavior. oauth2: # Use the built-in password database as the connector for the password grant type. # This allows the static user defined above to authenticate. passwordConnector: local # By default, Dex supports the necessary grant types, including 'token-exchange' # and the response types 'code', 'token', and 'id_token'. # Explicitly defining them is not necessary unless you need to restrict them. skipApprovalScreen: true こちらが崩壊: The Issuer issuer: $DEX_GCP_URL ユニークになるパスまたはサブドメインを定義します(これに匹敵するために名前空間を更新する必要があります - 後で見ていきます) 言い換えれば、それはこんなものになるだろう: issuer: https://auth.mydomain.com The Storage storage: type: sqlite3 config: file: /var/dex/dex.db Dex はその状態を保存する必要がありますので、リフレッシュトークンを処理し、現在の JWT を無効にすることができます。 About TLS certificates Dex isn't handling the TLS configuration, Nginx is (You’ll see it in the config). In other deployments, you can let Dex handle the certificates directly by pointing it to the cert files. However, that will be outside the scope of this tutorial. The Web port web: # Listen on HTTP, assuming a reverse proxy handles TLS termination. http: 0.0.0.0:5556 This is the port that gets exposed within the Docker container. We’ll see later on how we’ll map it out to our Nginx container. 静的なパスワードを作成する方法: enablePasswordDB: true # Define a static user for authentication. # This user will be used to exchange a Dex ID token for a GCP token. staticPasswords: - email: $DEX_GCP_STATIC_PASSWORD_EMAIL hash: $DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED username: $DEX_GCP_STATIC_PASSWORD_EMAIL userID: '722ba69a-3cba-4007-8a24-2611d4c4d5f9' # The `staticClients` list contains OAuth2 clients that can connect to Dex. staticClients: # This is the client for Google Cloud Workload Identity Federation. # The `id` MUST be the full resource name of the GCP Workload Identity Provider. # This value will be the `aud` (audience) claim in the OIDC token. - id: $DEX_GCP_STATIC_CLIENT_ID secret: $DEX_GCP_STATIC_CLIENT_SECRET name: 'Google Cloud Workload Identity Federation' # Redirect URIs are not used in the token-exchange flow. redirectURIs: [] # This section configures OAuth2 behavior. oauth2: # Use the built-in password database as the connector for the password grant type. # This allows the static user defined above to authenticate. passwordConnector: local # By default, Dex supports the necessary grant types, including 'token-exchange' # and the response types 'code', 'token', and 'id_token'. # Explicitly defining them is not necessary unless you need to restrict them. skipApprovalScreen: true This will allow us to provide a username and password to authenticate against Dex, which will generate the JWT for us to authenticate against GCP. This is also used in other scenarios to authenticate against other providers on your behalf. This means that if I provide the username and password, and I have an OAuth connection with GitHub, Microsoft, or Google, it will connect to those services on our behalf and return a token that can connect to those services. But again, this is outside the scope of the tutorial これは、私がユーザ名とパスワードを提供し、私はGitHub、Microsoft、またはGoogleとのOAuth接続を持っている場合、それは私たちの代わりにそれらのサービスに接続し、それらのサービスに接続できるトークンを返します。 スタンダード: - the magical part: id これは非常に重要です. これは、生成されたJWTの視聴者フィールドを指定します. Googleはこれと比較し、要求を検証します! 私はこの権利を得るためにどれだけの時間を費やしたかをあなたに伝えることはできません。 これは非常に重要です. これは、生成されたJWTの視聴者フィールドを指定します. Googleはこれと比較し、要求を検証します! 私はこの権利を得るためにどれだけの時間を費やしたかをあなたに伝えることはできません。 私たちは、Aを提供する必要があります。 to our : full URI hetzner-provider full URI //iam.googleapis.com/projects/1016670781645/locations/global/workloadIdentityPools/hetzner-pool/providers/hetzner-provider 注:We start with: // 注:We start with: // : password Use your password manager and generate a strong password. using any , so escaping isn’t an issue. This will be used to protect your API from anyone accessing your service! Avoid dollar signs a-strong-password-generated-using-a-password-generator Avoid dollar signs このパスワードは、後で HTTP パイロードを Dex に送信するときに送信されます! You pass to Dex the hashed string of the password above in the フィールド staticPasswords.password スタンダード: the auth@mydomain.com → This can be anything. Since this will not be propagated upward in the OpenID chain (i.e., used by another provider), it is only used to authenticate against the service. So you can provide something non-existent email: : a random UUID. You can use ULIDs or anything. This field isn’t used in our case. userID It’s a bcrypt hash that you generate using: password: htpasswd -bnBC 10 "" <-the-password-for-reference-> E.g: htpasswd -bnBC 10 "" a-strong-password-generated-using-a-password-generator It generates: $2y$10$s4ETxkQQeuJu4Kp58O607u.wiqlHnkyV8LkFK1g4cMKGFU959uusq (Note that it starts with a dollar sign) ハッシュされた bcrypt パスワードには上記のように 3 ドルシグナルが含まれます これらは、環境変数としてそれらを注入しようとすると、 bash/shell で正しく脱出する必要があります。 A hashed bcrypt password will have 3 dollar signs as shown above. These need to be escaped correctly in bash/shell if you try to inject them as environment variables. As mentioned in the Dex section, it becomes problematic. I’ll show you how to overcome this using envsubst. Dex の入力ポイントを作成する: #!/bin/sh set -e echo "🔧 Dex Custom Entrypoint - Starting initialization..." # Export all current environment variables echo "📝 Exporting current environment variables..." export $(printenv | grep -v '^_' | cut -d= -f1) # Check if doppler is configured if [ -z "$DOPPLER_TOKEN" ]; then echo "❌ Error: DOPPLER_TOKEN environment variable is required" exit 1 fi echo "🔐 Fetching DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED from Doppler..." # Get the bcrypt hashed password from doppler DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED=$(doppler secrets get DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED --plain) if [ -z "$DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED" ]; then echo "❌ Error: Failed to retrieve DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED from Doppler" exit 1 fi # Export the retrieved password hash export DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED # Apply environment variable substitution to config echo "🔄 Applying environment variable substitution..." envsubst < /etc/dex/config.yaml > /tmp/config.yaml # Cleanup function cleanup() { echo "🧹 Cleaning up temporary files..." if [ -f "/tmp/config.yaml" ]; then rm -f /tmp/config.yaml echo "✅ Removed /tmp/config.yaml" fi echo "👋 Dex container shutdown complete" exit 0 } # Set up signal handlers for graceful shutdown trap cleanup SIGTERM SIGINT SIGQUIT echo "🚀 Starting Dex server..." # Start dex server with processed config in background dex serve /tmp/config.yaml & DEX_PID=$! # Wait for dex process to finish wait $DEX_PID このファイルは3つの目的を果たします: Load all the environments from Doppler VPS project Load specifically the Bcrypt password hash in a plain format so it does not add any escaping. DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED=$(doppler secrets get DEX_GCP_STATIC_PASSWORD_SECRET_BCRYPT_HASHED --plain) Use and generate a temporary file which we serve in the with all of our environments replaced. This was the only working mechanism I found that worked. We remove this temporary file in the cleanup function. env_subst config.yaml dex serve /tmp/config.yaml When using an .env file, this script isn’t needed. You can boot Dex directly. However, downloading the hashed bcrypt from Doppler became a mess. This solution nailed it. .env ファイルを使用する場合、このスクリプトは必要ありません. あなたは Dex を直接起動することができます. しかし、Doppler からハッシュ bcrypt をダウンロードすることは混乱となりました. このソリューションはそれをナイフしました. NGINX : I don't think it needs any introduction. NGINX is an HTTP web server, reverse proxy, content cache, load balancer, TCP/UDP proxy server, and mail proxy server. It's one of the skeletons of the entire web. We will use it as the main entry point for our main Docker application and our DEX service. It will also be responsible for handling the TLS connections for us. しかし、この構成はちょっと挑戦的です。 Nginx will not load properly unless all the services are up and running. I will show you below how we utilize an automation script that leverages two Nginx configurations, providing zero downtime. しかし、この構成はちょっと挑戦的です。 Nginx will not load properly unless all the services are up and running. I will show you below how we utilize an automation script that leverages two Nginx configurations, providing zero downtime. We use these two configurations because NGINX will not load properly unless all the services are available. As you can see, our main application is held in Artifact Registry, which also needs DEX to be authenticated against. To overcome this ,we: Load a Dex-only configuration first. Authenticate Docker against it, generating an OIDC token that authenticates in GCP. Pull the image from Artifact Registry. Provide a second configuration that includes our app, and we reload it in real-time using nginx -s reload nginx.conf.init (Initial Dex only config): user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Basic server for returning a maintenance page server { listen 443 ssl http2 default_server; server_name _; # Catch-all ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; # Return a 503 for all requests to the main app location / { return 503 '{"status": "initializing", "message": "Application is starting up, please try again shortly."}'; add_header Content-Type 'application/json'; } } # HTTPS server for auth.yourdomain.com (dex) server { listen 443 ssl http2; server_name auth.yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; location / { proxy_pass http://dex:5556; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } location /healthz { access_log off; proxy_pass http://dex:5556/healthz; } } # HTTP server for redirects and ACME challenges server { listen 80; server_name www.yourdomain.com yourdomain.com auth.yourdomain.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } } nginx.conf.prod (Full config) # https://www.digitalocean.com/community/tutorials/how-to-upgrade-nginx-in-place-without-dropping-client-connections # https://www.f5.com/company/blog/nginx/avoiding-top-10-nginx-configuration-mistakes user nginx; # Number of CPU in the server - cat /proc/cpuinfo worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 4096; use epoll; multi_accept on; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Performance optimizations sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 30; keepalive_requests 1000; server_tokens off; # Buffer optimizations client_body_buffer_size 128k; client_max_body_size 50m; client_header_buffer_size 1k; large_client_header_buffers 4 4k; output_buffers 1 32k; postpone_output 1460; # Gzip compression gzip on; gzip_vary on; gzip_min_length 1024; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; # Rate limiting limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m; # Dynamic upstream for app service (supports docker rollout) upstream app_server { # Use Docker's internal DNS for service discovery # This allows nginx to discover all containers for the 'app' service server app:8080 max_fails=3 fail_timeout=5s; # Configure keepalive connections # Double of the upstream block keepalive 4; keepalive_requests 1000; keepalive_timeout 60s; } # Dynamic upstream for dex service (supports docker rollout) upstream dex_server { server dex:5556 ; } # HTTP server for redirects and ACME challenges server { listen 80; server_name www.yourdomain.com yourdomain.com auth.yourdomain.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } # HTTPS server for yourdomain.com (main app) server { listen 443 ssl http2; server_name www.yourdomain.com yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:50m; ssl_session_timeout 1d; ssl_session_tickets off; ssl_buffer_size 8k; ssl_prefer_server_ciphers on; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; limit_req zone=api burst=20 nodelay; # Handle static assets specifically location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ { proxy_pass http://app_server; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Container draining support - retry on different upstream if current fails proxy_next_upstream error timeout http_500 http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 10s; # Optimized timeouts for faster failover during rollouts proxy_connect_timeout 3s; proxy_send_timeout 10s; proxy_read_timeout 10s; # Prevent caching of broken responses proxy_buffering off; # Static asset headers expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; } location / { proxy_pass http://app_server; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Container draining support - retry on different upstream if current fails proxy_next_upstream error timeout http_500 http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 15s; # Optimized timeouts for faster failover during rollouts proxy_connect_timeout 5s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Proxy buffering proxy_buffering on; proxy_buffer_size 64k; proxy_buffers 4 64k; proxy_busy_buffers_size 64k; } # Health check endpoints location /health { access_log off; proxy_pass http://app_server/health; # Fast failover for health checks proxy_connect_timeout 2s; proxy_send_timeout 5s; proxy_read_timeout 5s; proxy_next_upstream error timeout http_500 http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 5s; } } # HTTPS server for auth.yourdomain.com (dex) server { listen 443 ssl http2; server_name auth.yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:50m; ssl_session_timeout 1d; ssl_session_tickets off; ssl_buffer_size 8k; ssl_prefer_server_ciphers on; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; limit_req zone=auth burst=5 nodelay; location / { proxy_pass http://dex_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; # Container draining support - retry on different upstream if current fails proxy_next_upstream error timeout http_500 http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 10s; # Optimized timeouts for faster failover during rollouts proxy_connect_timeout 5s; proxy_send_timeout 15s; proxy_read_timeout 15s; } # Health check for Dex location /healthz { access_log off; proxy_pass http://dex_server/healthz; # Fast failover for health checks proxy_connect_timeout 2s; proxy_send_timeout 5s; proxy_read_timeout 5s; proxy_next_upstream error timeout http_500 http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 5s; } } } ボーナス! F5はNGINXの背後にある会社であり、それを設定する方法をよりよく理解するのに役立つ素晴らしい料理本を持っています。 https://www.f5.com/content/dam/f5/corp/global/pdf/ebooks/NGINX_Cookbook-final.pdf ボーナス! F5 is the company behind NGINX, and they have a fantastic cookbook that helps you better understand how to configure it. It works for both the free and the paid versions. https://www.f5.com/content/dam/f5/corp/global/pdf/ebooks/NGINX_Cookbook-final.pdf Configuring Docker to connect to Artifact Registry using Workload Identity Federation and OIDC. Docker に Workload Identity Federation 経由で GCP に接続するように指示する必要があります。 これを手動で設定する予定です。 Credential Helper for that. これは3つのファイルで構成されます: (This authenticates against DEX and generates a JWT) fetch-id-token.sh fetch-google-oidc-token.sh (これは、fetch-id-token.sh から JWT を取って、Google Cloud から OIDC トークンを生成します) docker-credential-gcr.sh (上記の2つのファイルをオーケストラ化し、サービスアカウントを偽装するために fetch-google-oidc-token.sh から OIDC トークンを GCP に送信します) これらのファイルを盲目的にコピーして挿入しないでください、その多くは、VPSの現在の非ルートユーザーのようなハードコード値を持っています: localuser Don’t blindly copy and paste these files, as many of them have hardcoded values like the current non-root user in the VPS: localuser All of these files need to have execution access: chmod +x ~/scripts/fetch-id-token.sh chmod +x ~/scripts/fetch-google-oidc-token.sh chmod +x /usr/local/bin/docker-credential-gcr Docker 内の Credential Helper を設定する: ナビゲート or create the file if it doesn’t exist, and add the following: ~/.docker/config.json { "credHelpers": { "us-east1-docker.pkg.dev": "gcr" } } これは Docker が使用するように指示します。 credential helper to authenticate whenever pulling or pushing the image gcr 次に、Docker が名前のバイナリを実行しようとします。 (extensionless - e.g: ). docker-credential-<helper> docker-credential-gcr It is a naming convention used by Docker, and it finds it via $PATH when needed. トップ > トップ > トップ > トップ > トップ > トップ > トップ > ( ) : /usr/local/bin/docker-credential-gcr #! /bin/bash # # This file is extensionless. # Update all the /home/localuser/scripts directory with your own! # A custom Docker credential helper for Workload Identity Federation. # This script performs the full OIDC-to-Google-Access-Token exchange. # # Prerequisites: # 1. `curl` and `jq` must be installed. # 2. The custom OIDC token must be available in a file. # 3. The script must be executable (`chmod +x /path/to/this/script.sh`). # 4. This script is copied to /usr/local/bin/docker-credential-gcr (This is a file) # set -euo pipefail $PROJECT_ID="spiritual-slate-445211-i1" # Add logging to a file for debugging exec 2>> /tmp/docker-credential-gcr.log echo "$(date): Starting docker-credential-gcr" >> /tmp/docker-credential-gcr.log set -a source /home/localuser/.env set +a # Step 1: Get the ID Token echo "$(date): Getting ID token" >> /tmp/docker-credential-gcr.log if ! SUBJECT_TOKEN=$(/home/localuser/scripts/fetch-id-token.sh 2>> /tmp/docker-credential-gcr.log); then echo "$(date): ERROR - Failed to get ID token" >> /tmp/docker-credential-gcr.log exit 1 fi echo "$(date): Got ID token: ${SUBJECT_TOKEN:0:20}..." >> /tmp/docker-credential-gcr.log # Step 2: Get federated token echo "$(date): Getting federated token" >> /tmp/docker-credential-gcr.log if ! FEDERATED_ACCESS_TOKEN=$(/home/localuser/scripts/fetch-google-oidc-token.sh "$SUBJECT_TOKEN" 2>> /tmp/docker-credential-gcr.log); then echo "$(date): ERROR - Failed to get federated token" >> /tmp/docker-credential-gcr.log exit 1 fi if [ "${FEDERATED_ACCESS_TOKEN}" == "null" ]; then echo "Error: Failed to get federated token from STS. Response: ${STS_RESPONSE}" >&2 echo "{}" exit 1 fi # Check required environment variable for service account if [[ -z "${GCP_SERVICE_ACCOUNT:-}" ]]; then echo "$(date): ERROR - GCP_SERVICE_ACCOUNT_EMAIL not set" >> /tmp/docker-credential-gcr.log exit 1 fi # Step 4: Use the federated token to impersonate the Service Account. # This calls the IAM Credentials API to get a final, usable Google Cloud access token. IAM_API_URL="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/hetzner@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken" IAM_PAYLOAD='{"scope": ["https://www.googleapis.com/auth/cloud-platform"]}' IAM_RESPONSE=$(curl -s -X POST "${IAM_API_URL}" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${FEDERATED_ACCESS_TOKEN}" \ -d "${IAM_PAYLOAD}") GOOGLE_ACCESS_TOKEN=$(echo "${IAM_RESPONSE}" | jq -r .accessToken) if [ "${GOOGLE_ACCESS_TOKEN}" == "null" ]; then echo "Error: Failed to get Google access token via impersonation. Response: ${IAM_RESPONSE}" >&2 echo "{}" exit 1 fi # Step 5: Output the credentials in the JSON format that Docker expects. # The "Username" must be "oauth2accesstoken". # The "Secret" is the final Google Cloud access token. cat <<EOF { "Username": "oauth2accesstoken", "Secret": "${GOOGLE_ACCESS_TOKEN}" } EOF This file orchestrates the following two. It also adds some debugging mechanisms to 物事が失敗したときにデバッグするのを手助けするために。 /tmp/docker-credential-gcr.log トップ > トップ > トップ > トップ > ( - Create the ディレクター( ~/scripts/fetch-id-token.sh scripts #!/usr/bin/env bash # set -euo pipefail # Load environment variables from .env and Doppler set -a source .env 2>/dev/null || true set +a # Check if any required variables are empty if [[ -z "$DEX_GCP_URL" || -z "$DEX_GCP_STATIC_CLIENT_ID" || -z "$DEX_GCP_STATIC_CLIENT_SECRET" || -z "$DEX_GCP_STATIC_PASSWORD_EMAIL" || -z "$DEX_GCP_STATIC_PASSWORD_SECRET" ]]; then echo "Error: One or more required DEX_GCP_* environment variables are empty!" >&2 echo "Make sure DOPPLER_TOKEN is set and you have access to the required secrets." >&2 exit 1 fi BASIC_AUTH_HEADER=$(echo -n "$DEX_GCP_STATIC_CLIENT_ID:$DEX_GCP_STATIC_CLIENT_SECRET" | base64 -w 0) # Request OIDC token from Dex using the correct password grant flow response=$(curl --silent --location "$DEX_GCP_URL/token" \ --header 'Content-Type: application/x-www-form-urlencoded' \ --header "Authorization: Basic $BASIC_AUTH_HEADER" \ --data-urlencode 'grant_type=password' \ --data-urlencode "username=$DEX_GCP_STATIC_PASSWORD_EMAIL" \ --data-urlencode "password=$DEX_GCP_STATIC_PASSWORD_SECRET" \ --data-urlencode 'scope=openid email profile groups' 2>&1) curl_exit_code=$? if [[ $curl_exit_code -ne 0 ]]; then echo "Error: Failed to call Dex API (curl exit code: $curl_exit_code)" >&2 echo "Response: $response" >&2 exit 1 fi # Check if response contains an error if echo "$response" | grep -q '"error"'; then echo "Error: Authentication failed" >&2 echo "Response: $response" >&2 exit 1 fi # Extract the id_token from the JSON response id_token=$(echo "$response" | jq -r '.id_token') # Validate we got a token if [[ -z "$id_token" || "$id_token" == "null" ]]; then echo "Error: Failed to get id_token from Dex. Response: $response" >&2 exit 1 fi # Output the token echo "$id_token" fetch-google-oidc-token ( ) ~/scripts/fetch-google-oidc-token #!/usr/bin/env bash set -euo pipefail # Check if ID_TOKEN is provided as argument if [[ $# -eq 0 ]]; then echo "Error: ID_TOKEN parameter is required" >&2 echo "Usage: $0 <ID_TOKEN>" >&2 exit 1 fi ID_TOKEN="$1" # Validate ID_TOKEN is not empty if [[ -z "$ID_TOKEN" ]]; then echo "Error: ID_TOKEN parameter cannot be empty" >&2 exit 1 fi # Check required environment variables : "${DEX_GCP_STATIC_CLIENT_ID:?DEX_GCP_STATIC_CLIENT_ID environment variable not set}" # Build the audience URL AUDIENCE="$DEX_GCP_STATIC_CLIENT_ID" # Exchange ID_TOKEN for ACCESS_TOKEN using Google STS echo "Calling Google STS with audience: $AUDIENCE" >&2 response=$(curl --silent --location 'https://sts.googleapis.com/v1/token' \ --header 'Content-Type: application/json' \ --data "{ \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\", \"subject_token_type\": \"urn:ietf:params:oauth:token-type:id_token\", \"subject_token\": \"$ID_TOKEN\", \"audience\": \"$AUDIENCE\", \"requested_token_type\": \"urn:ietf:params:oauth:token-type:access_token\", \"scope\": \"https://www.googleapis.com/auth/cloud-platform\" }") curl_exit_code=$? # Check if curl succeeded if [[ $curl_exit_code -ne 0 ]]; then echo "Error: Failed to call Google STS API (curl exit code: $curl_exit_code)" >&2 echo "STS Response: $response" >&2 exit 1 fi # Extract the access_token from the JSON response access_token=$(echo "$response" | jq -r '.access_token') # Validate we got a token if [[ -z "$access_token" || "$access_token" == "null" ]]; then echo "Error: Failed to get access_token from Google STS. Response: $response" >&2 exit 1 fi # Output the access token echo "$access_token" What these files do in summary: Fetch a JWT from Dex. Send the JWT to Google to generate an OIDC Token. Use the OIDC Token to impersonate the service account. Use the token as the password field of the Docker container. これらのファイルについて: The (Note that this file is extensionless) must be placed in: docker-credential-gcr /usr/local/bin/ The full path should be: /usr/local/bin/docker-credential-gcr This makes it accessible for Docker to authenticate against Artifact Registry In my setup, I have a non-sudo user called . I’ve added the and inside the ’s home directory: localuser fetch-id-token.sh fetch-google-oidc-token.sh localuser /home/localuser/scripts/ The dir is one I created myself. scripts You must update to match the directories!!! ( docker-credential-gcr Putting everything together - The smart-start.sh smart-start.sh As I’ve mentioned, orchestrating this is a bit tricky, as I’m hosting everything within a single server and a single Nginx process, we need to (recap): Generate the TLS certificate Load Nginx with Dex. Let Docker authenticate against Dex and GCP - Pull the image from Artifact Registry Reload Nginx config to serve the app. For this, we’ve created a helper 実行可能なアクセスも必要です。 smart-start.sh あなたはこれを置くことができます in the or the leading directory next to the file: /home/localuser docker-compose.yml smart-start.sh #!/usr/bin/env bash # # smart-start.sh – one-shot bootstrap for the Hetzner VPS ## # Usage examples: # DOPPLER_TOKEN="dp.st.prd.xyz" ./smart-start.sh # Full deployment # DOPPLER_TOKEN="dp.st.prd.xyz" SKIP_CERT=1 ./smart-start.sh # Skip certificates set -euo pipefail ############################################################################### # Helpers ############################################################################### c_blue='\033[0;34m'; c_green='\033[0;32m'; c_yellow='\033[1;33m'; c_red='\033[0;31m'; c_nc='\033[0m' log(){ printf "${c_blue}ℹ︎ %s${c_nc}\n" "$*"; } ok (){ printf "${c_green}✔ %s${c_nc}\n" "$*"; } warn(){ printf "${c_yellow}⚠ %s${c_nc}\n" "$*"; } die (){ printf "${c_red}✖ %s${c_nc}\n" "$*" >&2; exit 1; } ############################################################################### # Environment & sanity ############################################################################### log "Pre-flight checks" [[ $EUID -eq 0 ]] && die "Run as an unprivileged user with docker group membership." command -v docker >/dev/null || die "Docker missing." command -v doppler >/dev/null || die "Doppler CLI missing." docker compose version >/dev/null || die "Docker Compose plugin missing." docker ps >/dev/null || die "User cannot talk to the Docker socket." # Validate DOPPLER_TOKEN [[ -z "${DOPPLER_TOKEN:-}" ]] && die "DOPPLER_TOKEN environment variable is required." # Helper function to run docker commands with doppler docker_compose() { DOPPLER_TOKEN="$DOPPLER_TOKEN" doppler run -- docker compose "$@" } docker_rollout() { DOPPLER_TOKEN="$DOPPLER_TOKEN" doppler run -- docker rollout "$@" } # # Helper function to check if service is running service_running() { local service="$1" docker_compose ps --services --filter "status=running" | grep -q "^${service}$" } # Helper function to wait for service health wait_for_service_health() { local service="$1" local timeout="${2:-60}" local count=0 log "Waiting for $service health check (timeout: ${timeout}s)..." while [ $count -lt $timeout ]; do if docker_compose ps "$service" --format "table {{.Health}}" 2>/dev/null | grep -q "healthy"; then ok "$service is healthy" return 0 fi # If service is not running, try to check if it's still starting if ! service_running "$service"; then warn "$service is not running, checking if it exited..." docker_compose logs --tail=10 "$service" return 1 fi sleep 2 count=$((count + 2)) done warn "$service health check timeout after ${timeout}s" docker_compose logs --tail=20 "$service" return 1 } ok "Environment looks good." ############################################################################### # TLS (Let's Encrypt via dns-01, can be skipped) ############################################################################### if [[ ${SKIP_CERT:-0} -eq 0 ]]; then log "Phase 1 – TLS certificates" cert_live="certbot/conf/live/alertdown.ai/fullchain.pem" if [[ ! -f $cert_live ]] || ! openssl x509 -checkend 604800 -noout -in "$cert_live" >/dev/null 2>&1 then log "Obtaining / renewing wildcard cert via Cloudflare" [[ -f /run/secrets/cloudflare.ini ]] || die "Cloudflare credentials missing at /run/secrets/cloudflare.ini (chmod 600)." mkdir -p certbot/{conf,logs,www} docker run --rm \ -v "$PWD/certbot/conf:/etc/letsencrypt" \ -v "$PWD/certbot/logs:/var/log/letsencrypt" \ -v /run/secrets/cloudflare.ini:/cloudflare.ini:ro \ certbot/dns-cloudflare certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /cloudflare.ini \ --dns-cloudflare-propagation-seconds 300 \ --email "${CERTBOT_EMAIL:-tech@alertdown.ai}" --agree-tos --no-eff-email \ # We tell Certbot to generate wildcard certificates. -d mydomain.com -d "*.mydomain.com" \ --non-interactive --rsa-key-size 4096 fi ok "TLS certificate ready." else warn "SKIP_CERT=1 → skipping Let's Encrypt" fi ############################################################################### # Bring up auth services (nginx + dex) ############################################################################### log "Phase 2 – Bring up auth services (nginx + dex)" # Use the initial maintenance config first log "Switching to initial NGINX config..." cp nginx/nginx.conf.init nginx/nginx.conf # Start dex first log "Starting dex service..." docker_compose up -d dex if ! wait_for_service_health "dex" 60; then die "Dex failed to become healthy" fi # Start nginx with the init config or reload if already running if service_running "nginx"; then log "Nginx already running, reloading with maintenance config..." if ! docker_compose exec nginx nginx -t -c /etc/nginx/nginx.conf; then die "New nginx config is invalid" fi if ! docker_compose exec nginx nginx -s reload; then die "Nginx reload failed - config may be invalid" fi else # Start nginx normally docker_compose up -d nginx fi # Give nginx a moment to start and verify it's running sleep 5 if ! service_running "nginx"; then docker_compose logs nginx die "Nginx failed to start" fi # Test auth endpoint log "Testing auth endpoint availability..." for i in {1..30}; do if curl -fs "https://auth.mydomain.com/healthz" >/dev/null 2>&1 || \ curl -fs "$DEX_GCP_URL/healthz" >/dev/null 2>&1; then ok "Auth endpoint is accessible" break fi sleep 2 [[ $i -eq 30 ]] && warn "Auth endpoint not yet accessible (may still be starting)" done ############################################################################### # Pull and start app services ############################################################################### log "Phase 3 – Pull and start app service" log "Pulling app image..." if ! docker_compose pull app; then warn "App image pull failed (check WIF setup) – continuing with cached image if available." fi log "Starting app service..." docker_rollout app if ! wait_for_service_health "app" 180; then warn "App service health check failed. The app may not be accessible." # Even if health check fails, we proceed to switch nginx config fi # Switch to the final production nginx config log "Switching to production NGINX config..." cp nginx/nginx.conf.prod nginx/nginx.conf # Reload nginx to apply the new configuration log "Reloading nginx with production config..." if docker_compose exec nginx nginx -s reload; then ok "Nginx reloaded successfully with production config." else warn "Nginx reload failed. Check logs." docker_compose logs nginx fi # Spin up certbot manager to listen for certbot renewals log "Phase 4 – Starting certbot manager" docker_compose up -d certbot ############################################################################### # Health probes ############################################################################### log "Comprehensive health probes" # Internal service health checks declare -A internal_services=( ["dex"]="dex" ["app"]="app" ) for name in "${!internal_services[@]}"; do service="${internal_services[$name]}" if service_running "$service"; then if docker_compose ps "$service" --format "table {{.Health}}" 2>/dev/null | grep -q "healthy"; then ok "Internal $name service ✓" else warn "Internal $name service ✗ (not healthy)" fi else warn "Internal $name service ✗ (not running)" fi done # External endpoint health checks declare -A external_probes=( ["Auth (HTTPS)"]="curl -fs --connect-timeout 10 https://auth.alertdown.ai/healthz" ["App (HTTPS)"]="curl -fs --connect-timeout 10 https://alertdown.ai/health" ["App (HTTPS/nginx-health)"]="curl -fs --connect-timeout 10 https://alertdown.ai/nginx-health" ["HTTP redirect"]="curl -fs --connect-timeout 5 http://alertdown.ai/ | grep -q '301'" ) for name in "${!external_probes[@]}"; do if timeout 15 bash -c "${external_probes[$name]}" >/dev/null 2>&1; then ok "External $name ✓" else warn "External $name ✗" fi done # Service status summary echo log "Service Status Summary:" docker_compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Health}}" echo -e "\n${c_green}🚀 AlertDown stack deployment complete:" echo -e "${c_green} Auth: https://auth.alertdown.ai${c_nc}" echo -e "${c_green} App: https://alertdown.ai${c_nc}" if ! service_running "app" || ! docker_compose ps "app" --format "table {{.Health}}" 2>/dev/null | grep -q "healthy"; then echo -e "${c_yellow} Note: App service may still be starting up${c_nc}" fi 脚本を実行する: これを実行するには、あなた: DOPPLER_TOKEN=<insert-doppler-vps-token-here> bash smart-start.sh E.g: DOPPLER_TOKEN=dp.stasdasdsad22OmPSi bash smart-start.sh 脚本を分割する: #!/usr/bin/env bash # # smart-start.sh – one-shot bootstrap for the Hetzner VPS ## # Usage examples: # DOPPLER_TOKEN="dp.st.prd.xyz" ./smart-start.sh # Full deployment # DOPPLER_TOKEN="dp.st.prd.xyz" SKIP_CERT=1 ./smart-start.sh # Skip certificates set -euo pipefail Selects bash, shows examples, and enables strict mode: exit on error, fail on unset vars, catch pipe failures. -e -u -o pipefail 1) helper: colors + logger functions ヘルパー:Colors + Logger Functions c_blue='\033[0;34m'; c_green='\033[0;32m'; c_yellow='\033[1;33m'; c_red='\033[0;31m'; c_nc='\033[0m' log(){ printf "${c_blue}ℹ︎ %s${c_nc}\n" "$*"; } ok (){ printf "${c_green}✔ %s${c_nc}\n" "$*"; } warn(){ printf "${c_yellow}⚠ %s${c_nc}\n" "$*"; } die (){ printf "${c_red}✖ %s${c_nc}\n" "$*" >&2; exit 1; } Colored output helpers for info/ok/warn/error ( しかも出る)。 die 2) environment & sanity checks (2)環境・衛生検査 log "Pre-flight checks" [[ $EUID -eq 0 ]] && die "Run as an unprivileged user with docker group membership." command -v docker >/dev/null || die "Docker missing." command -v doppler >/dev/null || die "Doppler CLI missing." docker compose version >/dev/null || die "Docker Compose plugin missing." docker ps >/dev/null || die "User cannot talk to the Docker socket." # Validate DOPPLER_TOKEN [[ -z "${DOPPLER_TOKEN:-}" ]] && die "DOPPLER_TOKEN environment variable is required." Ensures: not root (expects user in group), docker , , docker compose plugin available, docker doppler The user can reach the Docker socket, is set. DOPPLER_TOKEN 3) Doppler-wrapped Docker and service helpers 3) Doppler-wrapped Docker and service helpers docker_compose() { DOPPLER_TOKEN="$DOPPLER_TOKEN" doppler run -- docker compose "$@" } docker_rollout() { DOPPLER_TOKEN="$DOPPLER_TOKEN" doppler run -- docker rollout "$@" } service_running() { local service="$1" docker_compose ps --services --filter "status=running" | grep -q "^${service}$" } wait_for_service_health() { local service="$1" local timeout="${2:-60}" local count=0 log "Waiting for $service health check (timeout: ${timeout}s)..." while [ $count -lt $timeout ]; do if docker_compose ps "$service" --format "table {{.Health}}" 2>/dev/null | grep -q "healthy"; then ok "$service is healthy" return 0 fi if ! service_running "$service"; then warn "$service is not running, checking if it exited..." docker_compose logs --tail=10 "$service" return 1 fi sleep 2 count=$((count + 2)) done warn "$service health check timeout after ${timeout}s" docker_compose logs --tail=20 "$service" return 1 } ok "Environment looks good." It: / : Runs Docker commands with env injected by Doppler. docker_compose docker_rollout service_running: サービスが「実行」状態にあるかどうかを確認します。 Polls Docker Compose health for a service with a timeout; logs on failure. wait_for_service_health 4) TLS via Let’s Encrypt (dns-01) — skippable with SKIP_CERT=1 4) Let’s Encrypt (dns-01) 経由の TLS — 使用可能 SKIP_CERT=1 if [[ ${SKIP_CERT:-0} -eq 0 ]]; then log "Phase 1 – TLS certificates" cert_live="certbot/conf/live/alertdown.ai/fullchain.pem" if [[ ! -f $cert_live ]] || ! openssl x509 -checkend 604800 -noout -in "$cert_live" >/dev/null 2>&1 then log "Obtaining / renewing wildcard cert via Cloudflare" [[ -f /run/secrets/cloudflare.ini ]] || die "Cloudflare credentials missing at /run/secrets/cloudflare.ini (chmod 600)." mkdir -p certbot/{conf,logs,www} docker run --rm \ -v "$PWD/certbot/conf:/etc/letsencrypt" \ -v "$PWD/certbot/logs:/var/log/letsencrypt" \ -v /run/secrets/cloudflare.ini:/cloudflare.ini:ro \ certbot/dns-cloudflare certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /cloudflare.ini \ --dns-cloudflare-propagation-seconds 300 \ --email "${CERTBOT_EMAIL:-tech@alertdown.ai}" --agree-tos --no-eff-email \ # We tell Certbot to generate wildcard certificates. -d mydomain.com -d "*.mydomain.com" \ --non-interactive --rsa-key-size 4096 fi ok "TLS certificate ready." else warn "SKIP_CERT=1 → skipping Let's Encrypt" fi If not skipping: checks if cert exists and is valid for ≥7 days. alertdown.ai uses to issue/renew wildcard certs via dns-01 with Cloudflare creds at . certbot/dns-cloudflare /run/secrets/cloudflare.ini stores certs under . certbot/conf 注: ドメインは mydomain.com ですが、 cert パスは alertdown.ai を使用します。 5) Phase 2 — Bring up the auth stack (Nginx + Dex) 5) フェーズ2 - オートH スタックを持ち上げる (Nginx + Dex) log "Phase 2 – Bring up auth services (nginx + dex)" log "Switching to initial NGINX config..." cp nginx/nginx.conf.init nginx/nginx.conf log "Starting dex service..." docker_compose up -d dex if ! wait_for_service_health "dex" 60; then die "Dex failed to become healthy" fi if service_running "nginx"; then log "Nginx already running, reloading with maintenance config..." if ! docker_compose exec nginx nginx -t -c /etc/nginx/nginx.conf; then die "New nginx config is invalid" fi if ! docker_compose exec nginx nginx -s reload; then die "Nginx reload failed - config may be invalid" fi else docker_compose up -d nginx fi sleep 5 if ! service_running "nginx"; then docker_compose logs nginx die "Nginx failed to start" fi log "Testing auth endpoint availability..." for i in {1..30}; do if curl -fs "https://auth.mydomain.com/healthz" >/dev/null 2>&1 || \ curl -fs "$DEX_GCP_URL/healthz" >/dev/null 2>&1; then ok "Auth endpoint is accessible" break fi sleep 2 [[ $i -eq 30 ]] && warn "Auth endpoint not yet accessible (may still be starting)" done It: Swaps nginx to a maintenance/initial config. DEXを始めて、コンテナの健康=健康になるまで待つ。 Ensures nginx is up; validates config and reloads if already running. Probes auth health at or . https://auth.mydomain.com/healthz $DEX_GCP_URL/healthz again vs your actual domain; unify. Note: mydomain.com 6) Phase 3 — Pull & start app service, then switch nginx to prod log "Phase 3 – Pull and start app service" log "Pulling app image..." if ! docker_compose pull app; then warn "App image pull failed (check WIF setup) – continuing with cached image if available." fi log "Starting app service..." docker_rollout app if ! wait_for_service_health "app" 180; then warn "App service health check failed. The app may not be accessible." fi log "Switching to production NGINX config..." cp nginx/nginx.conf.prod nginx/nginx.conf log "Reloading nginx with production config..." if docker_compose exec nginx nginx -s reload; then ok "Nginx reloaded successfully with production config." else warn "Nginx reload failed. Check logs." docker_compose logs nginx fi It: アプリの画像を引っ張る(引っ張り失敗した場合の警告、キャッシュ画像を使用) docker rollout(ゼロダウンタイムロールロードツール)を介してアプリを展開します。 Waits up to 180s for app health. nginx を生産 config と reloads に交換します。 7) Phase 4 — start certbot manager (for renew hooks) 7) Phase 4 — start certbot manager (for renew hooks) log "Phase 4 – Starting certbot manager" docker_compose up -d certbot 8) Comprehensive health probes + summary 8) Comprehensive health probes + summary log "Comprehensive health probes" declare -A internal_services=( ["dex"]="dex" ["app"]="app" ) for name in "${!internal_services[@]}"; do service="${internal_services[$name]}" if service_running "$service"; then if docker_compose ps "$service" --format "table {{.Health}}" 2>/dev/null | grep -q "healthy"; then ok "Internal $name service ✓" else warn "Internal $name service ✗ (not healthy)" fi else warn "Internal $name service ✗ (not running)" fi done declare -A external_probes=( ["Auth (HTTPS)"]="curl -fs --connect-timeout 10 https://auth.alertdown.ai/healthz" ["App (HTTPS)"]="curl -fs --connect-timeout 10 https://alertdown.ai/health" ["App (HTTPS/nginx-health)"]="curl -fs --connect-timeout 10 https://alertdown.ai/nginx-health" ["HTTP redirect"]="curl -fs --connect-timeout 5 http://alertdown.ai/ | grep -q '301'" ) for name in "${!external_probes[@]}"; do if timeout 15 bash -c "${external_probes[$name]}" >/dev/null 2>&1; then ok "External $name ✓" else warn "External $name ✗" fi done echo log "Service Status Summary:" docker_compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Health}}" echo -e "\n${c_green}🚀 AlertDown stack deployment complete:" echo -e "${c_green} Auth: https://auth.alertdown.ai${c_nc}" echo -e "${c_green} App: https://alertdown.ai${c_nc}" if ! service_running "app" || ! docker_compose ps "app" --format "table {{.Health}}" 2>/dev/null | grep -q "healthy"; then echo -e "${c_yellow} Note: App service may still be starting up${c_nc}" fi It: checks container “running” + “healthy” for and . dex app hits external HTTPS health endpoints + HTTP→HTTPS redirect. prints a one-line docker compose status table and friendly success banner. アプリがまだ起動しているか確認してください。 Beware: 進む前にこのファイルを確認してください. 更新する必要がある他のハードコード値があります。 Hooray 🥳 ホワイト With this, you’ll have アーティファクト レジストリから引き出されたドッカー画像! DEX UP AND RUN DEX UP AND RUN ポストマンによる認証 問題が発生した場合、常にcURLまたはPostmanを使用してトークン取引所をデバッグすることを試すことができます。 3 トークン交換を行う必要があります。 DEX Google STS(ストレージ転送サービス) Google IAM Credentials デックスに対する認証 我々は下にこれをカバーし、シェルで我々は、以下のようなauthスクリプトを生成することができます。 BASIC_AUTH_HEADER=$(echo -n "$DEX_GCP_STATIC_CLIENT_ID:$DEX_GCP_STATIC_CLIENT_SECRET" | base64 -w 0) 我々は下にこれをカバーし、シェルで我々は、以下のようなauthスクリプトを生成することができます。 BASIC_AUTH_HEADER=$(echo -n "$DEX_GCP_STATIC_CLIENT_ID:$DEX_GCP_STATIC_CLIENT_SECRET" | base64 -w 0) The generated fetch request: const myHeaders = new Headers(); myHeaders.append("Content-Type", "application/x-www-form-urlencoded"); myHeaders.append("Authorization", "Basic Ly9pYW0uZ29vZ2xlYXBpcy5jb20vcHJvamVjdHMvMTA4MzM1MDgwNzQ5OC9sb2NhdGlvbnMvZ2xvYmFsL3dvcmtsb2FkSWRlbnRpdHlQb29scy9oZXR6bmVyLXBvb2wvcHJvdmlkZXJzL2hldHpuZXItcHJvdmlkZXI6YS1zdHJvbmctcGFzc3dvcmQtZ2VuZXJhdGVkLXVzaW5nLWEtcGFzc3dvcmQtZ2VuZXJhdG9y"); const urlencoded = new URLSearchParams(); urlencoded.append("grant_type", "password"); urlencoded.append("username", "auth@mydomain.com"); urlencoded.append("password", "pleaseuseastrongerpassword"); urlencoded.append("scope", "openid email profile groups"); const requestOptions = { method: "POST", headers: myHeaders, body: urlencoded, redirect: "follow" }; fetch("https://auth.yourdomain.com", requestOptions) .then((response) => response.text()) .then((result) => console.log(result)) .catch((error) => console.error(error)); そして、今度はCURLで: curl --location 'https://auth.yourdomain.com' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --header 'Authorization: Basic Ly9pYW0uZ29vZ2xlYXBpcy5jb20vcHJvamVjdHMvMTA4MzM1MDgwNzQ5OC9sb2NhdGlvbnMvZ2xvYmFsL3dvcmtsb2FkSWRlbnRpdHlQb29scy9oZXR6bmVyLXBvb2wvcHJvdmlkZXJzL2hldHpuZXItcHJvdmlkZXI6YS1zdHJvbmctcGFzc3dvcmQtZ2VuZXJhdGVkLXVzaW5nLWEtcGFzc3dvcmQtZ2VuZXJhdG9y' \ --data-urlencode 'grant_type=password' \ --data-urlencode 'username=auth@mydomain.com' \ --data-urlencode 'password=pleaseuseastrongerpassword' \ --data-urlencode 'scope=openid email profile groups' Google STS API に対する認証: トークンが手元にある場合は、Google STS(ストレージ転送サービス)に対して認証する必要がありますので、クラウドプロバイダー間でデータを移動することができます。 : Where ALERTDOWN_AUTH_ID_TOKEN => デックスから来たJWTです。 => AUDIENCE //iam.googleapis.com/projects/1016670781645/locations/global/workloadIdentityPools/hetzner-pool/providers/hetzner-provider 範囲に注目! Here’s the fetch command: const myHeaders = new Headers(); myHeaders.append("Content-Type", "application/json"); const raw = JSON.stringify({ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", "subject_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE1NjdmY2MyMTVhMmQxNGY2MzVhZmY1ZjI5MjNmZDkzMTVkZWNkNzQifQ.eyJpc3MiOiJodHRwczovL2F1dGguYWxlcnRkb3duLmFpIiwic3ViIjoiQ2lRM01qSmlZVFk1WVMwelkySmhMVFF3TURjdE9HRXlOQzB5TmpFeFpEUmpOR1ExWmprU0JXeHZZMkZzIiwiYXVkIjoiLy9pYW0uZ29vZ2xlYXBpcy5jb20vcHJvamVjdHMvMTA4MzM1MDgwNzQ5OC9sb2NhdGlvbnMvZ2xvYmFsL3dvcmtsb2FkSWRlbnRpdHlQb29scy9oZXR6bmVyLXBvb2wvcHJvdmlkZXJzL2hldHpuZXItcHJvdmlkZXIiLCJleHAiOjE3NTMyNTc4NDUsImlhdCI6MTc1MzE3MTQ0NSwiYXRfaGFzaCI6IjJ5bW1mQzFfUUZaYnRibTdCLWZuS3ciLCJlbWFpbCI6ImF1dGhAYWxlcnRkb3duLmFpIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJhdXRoQGFsZXJ0ZG93bi5haSJ9.kyI61WGR5m0h4YvflYV4sCHDY0G7ix7R4m59ITvE_Bq3oIwjbH2NxAMzmtPbUp9kCcsbosAeJTcfJWj2n03-LtRZKc1WjELrFytlnSDgt1KeNCqYYWsdG5eUORzYgvfl9ayNqf7QgDPc3Sr7XQElfk07F-uJPAGPssUXY-qxos6lZHrmComzEWkWqfbuq5e-cvLsBP6TmFAt58B2XKAcSLYSuFrp8eMDaCZ7zQ12z9NR9q0N7u7cVKsJT2429I27fh6LrQsthMaaDMEKzfhY-HskbmcvYO_z4U2M1plYvXAqJRJzGGrkArPSGklxvfFS6gIqLSI7MLnzzsJKpTUt1w", "audience": "//iam.googleapis.com/projects/1083350807498/locations/global/workloadIdentityPools/hetzner-pool/providers/hetzner-provider", "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", "scope": "https://www.googleapis.com/auth/cloud-platform", "options": "{\"serviceAccount\": \"hetzner@alertdown.iam.gserviceaccount.com\"}" }); const requestOptions = { method: "POST", headers: myHeaders, body: raw, redirect: "follow" }; fetch("https://sts.googleapis.com/v1/token", requestOptions) .then((response) => response.text()) .then((result) => console.log(result)) .catch((error) => console.error(error)); 以下はCURLコマンドです。 curl --location 'https://sts.googleapis.com/v1/token' \ --header 'Content-Type: application/json' \ --data-raw '{ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", "subject_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE1NjdmY2MyMTVhMmQxNGY2MzVhZmY1ZjI5MjNmZDkzMTVkZWNkNzQifQ.eyJpc3MiOiJodHRwczovL2F1dGguYWxlcnRkb3duLmFpIiwic3ViIjoiQ2lRM01qSmlZVFk1WVMwelkySmhMVFF3TURjdE9HRXlOQzB5TmpFeFpEUmpOR1ExWmprU0JXeHZZMkZzIiwiYXVkIjoiLy9pYW0uZ29vZ2xlYXBpcy5jb20vcHJvamVjdHMvMTA4MzM1MDgwNzQ5OC9sb2NhdGlvbnMvZ2xvYmFsL3dvcmtsb2FkSWRlbnRpdHlQb29scy9oZXR6bmVyLXBvb2wvcHJvdmlkZXJzL2hldHpuZXItcHJvdmlkZXIiLCJleHAiOjE3NTMyNTc4NDUsImlhdCI6MTc1MzE3MTQ0NSwiYXRfaGFzaCI6IjJ5bW1mQzFfUUZaYnRibTdCLWZuS3ciLCJlbWFpbCI6ImF1dGhAYWxlcnRkb3duLmFpIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJhdXRoQGFsZXJ0ZG93bi5haSJ9.kyI61WGR5m0h4YvflYV4sCHDY0G7ix7R4m59ITvE_Bq3oIwjbH2NxAMzmtPbUp9kCcsbosAeJTcfJWj2n03-LtRZKc1WjELrFytlnSDgt1KeNCqYYWsdG5eUORzYgvfl9ayNqf7QgDPc3Sr7XQElfk07F-uJPAGPssUXY-qxos6lZHrmComzEWkWqfbuq5e-cvLsBP6TmFAt58B2XKAcSLYSuFrp8eMDaCZ7zQ12z9NR9q0N7u7cVKsJT2429I27fh6LrQsthMaaDMEKzfhY-HskbmcvYO_z4U2M1plYvXAqJRJzGGrkArPSGklxvfFS6gIqLSI7MLnzzsJKpTUt1w", "audience": "//iam.googleapis.com/projects/1083350807498/locations/global/workloadIdentityPools/hetzner-pool/providers/hetzner-provider", "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", "scope": "https://www.googleapis.com/auth/cloud-platform", "options": "{\"serviceAccount\": \"hetzner@alertdown.iam.gserviceaccount.com\"}" }' Google IAM 認証 これは最後の部分です! これで私たちは私たちのサービスアカウントを表す短い生きたJWTを得ます フェッチ: const myHeaders = new Headers(); myHeaders.append("Accept", "application/json"); myHeaders.append("Content-Type", "application/json"); // The bearer is the token we receive from Google STS myHeaders.append("Authorization", "Bearer ya29.d.c0ASRK0GZhTkZi8JGyqpYLR_m7ikJXVUiOL0mOA4jtgydbRyRyNusJu0up5PGOebyIYAGZoKHkreDgaukv7DbNZm3OT6JPVB5HRW_KiqZZyUfgnQRloLrcTPOyf6bDl_yHyzEjVlOy-FYPs98RSt2C-POO3R8NZC-7jEYYzqVlimcNgt7E4QBmeSsxhXShegLCjlDcgSBOob3Kb_-Q-xeTvtkflhYo7jPFiE8ChJEqnIIxL6CzritqgOsrGB4VBNuR0BhghbrH_Sa4q7ELTMclLuRb_PuTJ1YtxW1Ia7az3ENOQSF1gxKyeubBAAvKGhtfEDJYVj3mkkPUjXYUTJHPzwV6HRNF2Rls3XWfPZpiTILbbBp1bPt45Up0-9cY6NMie9b1hL9FmtCHxJOzmxDPWWDhi9KDAsRx0OQkJKJWAtl_jNX7HbjNiMRXPF-WMqR3TQia9DEBsEqWNGSMg67uFCbxbtz0DWVjjQS9VlErmK_h1pwdwhaSMWSW5_Qz-VmZ5E2K2uPdPDMLWgdDjk01b4mVXVsDJGpIj2W-9D3C8yzV5avJcAhuWpDCjOtUTUxo5LKDvjZAFbPBaIa5-LfM54UlIvMnem2y9sgIIVxptmR1gslv0PJMgCyeYDjxlCcvQqv8W5IuFw61O7zIp3FlmU8EwcbMGdfs8PRulgLjep2jL9HhxBo8zAcz1aJgVnV0kHFPfYSTVsBUQcNHYCOBmmp_NkrgKW8Tjs1e7buzZpKz4Op6Up2rqMa69ArwasSkTiQ1cGZxM9_hEt9lt9bCjvlyns9JWcSxdBrRptk4fFXBhQyYbZQ90WW2BBxfTCopa_QKcwAiNqVgKHQZlxuGeVgXVuwpBdNqKMGT29BjuiqGlz_64fmLhPr37ivSjdt6YyCKB1S6-_sjDMFUAC73HtCyaMcF8WsNdbQDgBzvNYfZMg_YnsHkQdClj7EwOMymOna9hOZyKHbtFP89IZ852yzTUqEEGAn3WAaneiBbYQjjB1WtMtSGTTGVmggK65NG1HOjiuI8R7Ik0Dgx4kOSQQYD6fHVLcYPignZIzxm8TZhIcuBPIRKm5FHb9LD69lFtpDkJ8w0vfC2bAMz-Y15u-mla8jIkVTDi_3i1mYmJh8cCm0wwEDCBWEtNOJ5zwNFsYMHQwDso-9nc1cM6P57JXsq7g"); const raw = JSON.stringify({ "scope": [ "https://www.googleapis.com/auth/cloud-platform" ] }); const requestOptions = { method: "POST", headers: myHeaders, body: raw, redirect: "follow" }; fetch("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/hetzner@alertdown.iam.gserviceaccount.com:generateAccessToken", requestOptions) .then((response) => response.text()) .then((result) => console.log(result)) .catch((error) => console.error(error)); クルル: curl --location 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/hetzner@alertdown.iam.gserviceaccount.com:generateAccessToken' \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ya29.d.c0ASRK0GZhTkZi8JGyqpYLR_m7ikJXVUiOL0mOA4jtgydbRyRyNusJu0up5PGOebyIYAGZoKHkreDgaukv7DbNZm3OT6JPVB5HRW_KiqZZyUfgnQRloLrcTPOyf6bDl_yHyzEjVlOy-FYPs98RSt2C-POO3R8NZC-7jEYYzqVlimcNgt7E4QBmeSsxhXShegLCjlDcgSBOob3Kb_-Q-xeTvtkflhYo7jPFiE8ChJEqnIIxL6CzritqgOsrGB4VBNuR0BhghbrH_Sa4q7ELTMclLuRb_PuTJ1YtxW1Ia7az3ENOQSF1gxKyeubBAAvKGhtfEDJYVj3mkkPUjXYUTJHPzwV6HRNF2Rls3XWfPZpiTILbbBp1bPt45Up0-9cY6NMie9b1hL9FmtCHxJOzmxDPWWDhi9KDAsRx0OQkJKJWAtl_jNX7HbjNiMRXPF-WMqR3TQia9DEBsEqWNGSMg67uFCbxbtz0DWVjjQS9VlErmK_h1pwdwhaSMWSW5_Qz-VmZ5E2K2uPdPDMLWgdDjk01b4mVXVsDJGpIj2W-9D3C8yzV5avJcAhuWpDCjOtUTUxo5LKDvjZAFbPBaIa5-LfM54UlIvMnem2y9sgIIVxptmR1gslv0PJMgCyeYDjxlCcvQqv8W5IuFw61O7zIp3FlmU8EwcbMGdfs8PRulgLjep2jL9HhxBo8zAcz1aJgVnV0kHFPfYSTVsBUQcNHYCOBmmp_NkrgKW8Tjs1e7buzZpKz4Op6Up2rqMa69ArwasSkTiQ1cGZxM9_hEt9lt9bCjvlyns9JWcSxdBrRptk4fFXBhQyYbZQ90WW2BBxfTCopa_QKcwAiNqVgKHQZlxuGeVgXVuwpBdNqKMGT29BjuiqGlz_64fmLhPr37ivSjdt6YyCKB1S6-_sjDMFUAC73HtCyaMcF8WsNdbQDgBzvNYfZMg_YnsHkQdClj7EwOMymOna9hOZyKHbtFP89IZ852yzTUqEEGAn3WAaneiBbYQjjB1WtMtSGTTGVmggK65NG1HOjiuI8R7Ik0Dgx4kOSQQYD6fHVLcYPignZIzxm8TZhIcuBPIRKm5FHb9LD69lFtpDkJ8w0vfC2bAMz-Y15u-mla8jIkVTDi_3i1mYmJh8cCm0wwEDCBWEtNOJ5zwNFsYMHQwDso-9nc1cM6P57JXsq7g' \ --data '{ "scope": [ "https://www.googleapis.com/auth/cloud-platform" ] }' The Bearer token is the ya29.d token we receive from Google STS. ベアラー・トークンは、Google STS から受け取る ya29.d トークンです。 NodeJSからOIDCトークンを発行する方法 このプロセスはDockerのプロセスほど面倒くさいものではありませんが、まだ毛が薄く、ドキュメントがほとんどありません。 しかし、それでもいくつかの設定が必要です。 google-auth-library Here’s how: 0. The OIDC Config (We pass it from Doppler): OIDC_CLIENT_ID="//iam.googleapis.com/projects/1016670781645/locations/global/workloadIdentityPools/hetzner-pool/providers/hetzner-provider" OIDC_CLIENT_SECRET="This is the password that we pass in base64 format in the header" OIDC_ISSUER="https://auth.mydomain.com" OIDC_PASSWORD="This is the unhashed bcrypt password" OIDC_POOL_ID="hetzner-pool" OIDC_PROVIDER_ID="hetzner-provider" OIDC_USERNAME="DEX_GCP_STATIC_PASSWORD_EMAIL -> the email address we used in Dex" 1. Install the required packages: google-auth-library pnpm add google-auth-library @google-cloud/vertexai Auth.ts を作成する: import { ExternalAccountClientOptions, SubjectTokenSupplier, } from 'google-auth-library'; import { GoogleOIDCFetchException } from './google.auth.exception'; export type OIDCToken = { access_token: string; token_type: 'bearer'; expires_in: number; id_token: string; }; export type OidcConfigParams = { oidcIssuer: string; oidcClientId: string; oidcClientSecret: string; oidcUsername: string; oidcPassword: string; googleProjectNumber: string; oidcPoolId: string; oidcProviderId: string; googleServiceAccount: string; }; class OidcTokenSupplier implements SubjectTokenSupplier { readonly #config: OidcConfigParams; constructor(config: OidcConfigParams) { this.#config = config; } async getSubjectToken( ): Promise<string> { // our Dex URL const tokenUrl = `${this.#config.oidcIssuer}/token`; const basicAuthHeader = Buffer.from(`${this.#config.oidcClientId}:${this.#config.oidcClientSecret}`).toString('base64'); const urlencoded = new URLSearchParams(); urlencoded.append("grant_type", "password"); urlencoded.append("username", this.#config.oidcUsername); urlencoded.append("password", this.#config.oidcPassword); urlencoded.append("scope", "openid email profile groups"); const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `Basic ${basicAuthHeader}`, }, body: urlencoded, }); if (!response.ok) { throw new GoogleOIDCFetchException( `Failed to get OIDC token: ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText, body: await response.text(), } ); } const data = await response.json() as OIDCToken; return data.id_token; } } export function getOidcConfig( params: OidcConfigParams ): ExternalAccountClientOptions { const audience = `//iam.googleapis.com/projects/${params.googleProjectNumber}/locations/global/workloadIdentityPools/${params.oidcPoolId}/providers/${params.oidcProviderId}`; return { type: 'external_account', audience, subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', token_url: 'https://sts.googleapis.com/v1/token', subject_token_supplier: new OidcTokenSupplier(params), service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${params.googleServiceAccount}:generateAccessToken`, scopes: ['https://www.googleapis.com/auth/cloud-platform'], }; } We inherit from the SubjectTokenSupplier, and we use it to point to our Dex configuration. THE この機能は、Google Auth Credentials がすべての Google パッケージサービスから期待する形に入力を変換します. This is similar to the Postman config, with the difference that the STS and IAM endpoints are baked in. getOidcConfig Vertex AI 構成: Vertex AI で消費できます: export class GoogleVertexLLM { #client: VertexAI; #projectId: string; #location: string; constructor(config: GoogleVertexLLMConstructorConfig) { this.#projectId = config.projectId; this.#location = config.location; this.#client = new VertexAI({ project: this.#projectId, location: this.#location, googleAuthOptions: { credentials: getOidcConfig(config.oidcConfig) }, }); } async generateChat<T>( systemPrompt: string ): PromiseExceptionResult<T> { const parts: Part[] = [ { text: systemPrompt, }, ]; const model = this.#client.getGenerativeModel({ model: 'gemini-2.0-flash', systemInstruction: { role: 'system', parts, }, }); const chat = model.startChat({ generationConfig: { temperature: 0, topP: 0.95, maxOutputTokens: 8192, responseMimeType: 'application/json', }, }); } } それから自動的に私たちのためにすべてのトークン交換を実行します! WE DID IT Omgosh. It was crazy. It took a while, but we were able to connect to the services and issue a rotating token! サービスに接続し、回転トークンを発行することができました! きっと、あなたに長くかかることはないでしょう。 記事から明らかにされていない部分がある場合は、教えてください! You can always find me on もしくはLinkedIn ツイッター/X ジョブズ ジョブズ