見出し画像

Cloud Run に Node.js アプリを継続的にデプロイ

Google Cloud Platform (以下GCP) の Cloud Run は、フルマネージドな環境でコンテナ型アプリケーションを稼働させられるサービスです。ここではAPIサーバーを想定したアプリケーションを作成し、Cloud Run で動かします。

前半ではNode.jsを使ったアプリケーションを作成し、適宜npmスクリプトの追加やテスト実装についても触れます。後半は Cloud Run への手動デプロイから、GitHubへのpushをトリガーにした簡易的なCDについて言及します。

Cloud Run は、Webサーバーを動かすためのDockerfileを含むコードや、コンテナイメージがあれば、Node.jsに限らず多くの実装をデプロイ可能です。これはフルマネージドなコンテナプラットフォームの1つのメリットです。

したがって、この記事でもアプリケーション作成とデプロイの記述は独立して成立するように努めました。アプリケーションの作成方法は【Node.js】、Cloud Run へのデプロイについては【Cloud Run】の項目をご覧ください。

✍️

伊藤忠テクノソリューションズ株式会社
Buildサービスチーム ソフトウェアエンジニア
板倉翔太

🥚 前提

この記事を読むにあたっての前提となる知識や環境を明らかにします。

  • GitHubにpushできる

  • 1つ以上のGCPのプロジェクトがある、もしくは作成できる

  • 以下のコマンドが実行できる
    (Node.jsのバージョンはv18.7.0で確認しています)

$
git
gcloud
docker
node
npm
  • 以下のコマンドは必須ではないもののサンプルとして登場

$
curl
jq

🔧 使用する技術

アプリケーション関連

  • Node.js (非同期型のイベント駆動のJavaScript環境)

  • Express (Node.jsのためのWebフレームワーク)

  • jest (JavaScriptのテストライブラリ)

  • webpack (JavaScriptを始めとしたアセットをバンドルする)

  • Babel (ES2015などのコードを古いブラウザでも動くように変換)

インフラ関連

  • Docker (コンテナ仮想化プラットフォーム)

  • Cloud Run (コンテナアプリケーションを動作させるGCPのサービス)

  • Cloud Build (GCPのサーバーレスCI/CDプラットフォームサービス)

  • Artifact Registry (コンテナイメージ等を保存するGCPのサービス)


🖥️ 【Node.js】 ディレクトリ構成

今回作成するアプリケーションのディレクトリ構成です。以降の項目で一つ一つのファイルを手動または自動で作成しながら進めます。

root/
  ├─ dist/
  │   └─ main.js
  ├─ node_modules/
  │   └─ ...
  ├─ src/
  │   ├─ __tests__/
  │   │   └─ hello.test.js
  │   ├─ hello.js
  │   └─ index.js
  ├─ .dockerignore
  ├─ .gitignore
  ├─ cloudbuild.yaml
  ├─ Dockerfile
  ├─ babel.config.js
  ├─ package-lock.json
  ├─ package.json
  └─ webpack.config.js

🖥️ 【Node.js】 プロジェクトの作成

最小限の ./package.json を作成します。

$
npm init

コマンドを打つと幾つか質問されるので、答えていくと ./package.json 及び ./package-lock.json が作成されます。次に、今回必要なパッケージをインストールします。

  • express (Webアプリケーションフレームワーク)

  • webpack-cli (webpackを操作するCommand Line Interface)

  • babel-loader (webpackで 🔗 ロード時の前処理としてBabelを指定する)

  • @babel/preset-env (Babelの細かい設定不要のプリセット)

  • jest (テストツール)

$
npm install express
npm install --save-dev webpack-cli babel-loader @babel/preset-env jest

🖥️ 【Node.js】 最小コード

アプリケーションを動作させるための最低限のコードを作成します。

./src/hello.js (APIのハンドラ)

const hello = async (req, res) => {
  const name = process.env.NAME || 'World';
  return res.send(`Hello ${name}!`);
};

export { hello };

./src/index.js (ルーティング)

import express from 'express';
import { Router } from 'express';
import { hello } from './hello';

const port = parseInt(process.env.PORT) || 8080;
const app = express();
const router = Router();

router.get('/', hello);
app.use(router);

app.listen(port, () => {
  console.log(`ctc-buildservice-sample: listening on port ${port}`);
});

よくある Express のサンプルコードですが、この時点でルーティングとハンドラのファイルを分けています。後にテストコードを追加しやすくするためです。テストは早い段階であったほうが良いので追加しておきましょう。

🖥️ 【Node.js】 テストコード

./src/__tests__/hello.test.js (ハンドラのテスト)

import { jest } from '@jest/globals';
import { hello } from '../hello';

test('helloのテスト', () => {
  const req = {};
  const res = {
    send: jest.fn(),
  };
  hello(req, res);
  expect(res.send.mock.calls.length).toBe(1);
  expect(res.send.mock.calls[0]).toEqual(['Hello World!']);
});

次のコマンドでテストが実行されます。

$
npx jest

./package.json の `scripts.test` の値を `jest` に書き換えることで、次のようにnpmスクリプトとしても実行できます。

$
npm test

🖥️ 【Node.js】 トランスパイル

実はここで `node ./src/index.js` のようにNode.jsを動かしてもエラーになります。なぜなら、今回のコードがECMAScriptのmoduleの構文(importやexportなど)になっていて、Node.jsが構文を理解できていないからです。

Node.jsがmoduleと理解するのは、明示的にmoduleであると指定したときや、拡張子が .mjs のときです。すなわち、./package.json の `type` に `module` を指定する、拡張子を .mjs に変更する、などの方法で動作させることは可能です。これらが将来的に標準になるかも知れません。

しかし、インストールするライブラリで対応していなかったり、追加の設定をしないとエラーになったり、という可能性が残っています。今回に関してはjestの設定を少し変える必要がありました。

そこで、./node_modules/ にインストールされたライブラリを含めてバンドルし、Node.jsが解釈できるように書き換えるという、広く普及している方法を採用します。そのために使うのがwebpackとBabelです。

webpackはmoduleとして書かれた複数のJavaScriptファイルをバンドルして1つにまとめます。その過程で、Node.jsで読めるようにトランスパイルするのがBabelです。この2つを組み合わせる方法がよく利用されます。

設定

./webpack.config.js (webpackの設定)
拡張子 .js のファイルを `babel-loader` を使ってトランスパイルする設定。

module.exports = {
  target: 'node',
  entry: './src/index.js',
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
    ],
  },
};

./babel.config.js (Babelの設定)

module.exports = {
  sourceType: 'unambiguous',
  presets: ['@babel/preset-env'],
};

実行

$
npx webpack

./dist/main.js にビルドされたファイルが作成されます。

🖥️ 【Node.js】 ローカルでの確認

アプリケーションが正しく動くか確認します。次のコマンドを実行した後、http://localhost:8080 にアクセスして確認しましょう。

$
node ./dist/main.js

npmスクリプト化したいので、./package.json を少し変更します。Webpackによって実行ファイルは `dist/main.js` になったので、`main` の値を書き換えます。実はこの変更はそれほど重要ではありません。npmに公開するわけではないためです。ただ、これで次のように実行できるようになりました。

$
node .

さらに、`scripts.start` を `node .` に変えて、npmスクリプトとして実行できるようにします。

$
npm start

🖥️ 【Node.js】 Dockerでの確認

イメージを作成するためのDockerfileを作成します。

./Dockerfile

FROM node:18-slim

WORKDIR /root
COPY package*.json ./
RUN npm install

COPY . .
RUN npx webpack
EXPOSE 8080

CMD [ "node", "." ]

./.dockerignore (build時にコピーしないもの)

node_modules
npm-debug.log
dist

Build

$
docker build . -t ctc-buildservice-sample:latest

Run

$
docker run --rm -d -p 8080:8080 -e NAME=Docker ctc-buildservice-sample:latest

http://localhost:8080 にアクセスするとDockerで動いていることが確認できます。また、BuildやRunのコマンドは、./package.json の `scripts` に任意のコマンド名で登録しておくと、ローカルでの確認が楽になります。

🖥️ 【Node.js】 アウトプット

ここまででNode.jsのアプリケーションを作成し、テストの実行、コンテナでの確認もできました。要所要所で開発に役立つように ./package.json も更新してきました。初めに示したディレクトリ構造とほぼ同じになっているはずです。下記は ./package.json の全体像のサンプルです。

{
  "name": "ctc-buildservice-sample",
  "version": "1.0.0",
  "description": "",
  "main": "dist/main.js",
  "scripts": {
    "test": "jest",
    "start": "npx webpack; node .",
    "docker:build": "docker build . -t ctc-buildservice-sample:latest",
    "docker:run": "docker run --rm -d -p 8080:8080 -e NAME=Docker ctc-buildservice-sample:latest",
    "deploy:local": "npm run docker:build; npm run docker:run"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/preset-env": "^7.18.10",
    "babel-loader": "^8.2.5",
    "jest": "^28.1.3",
    "webpack-cli": "^4.10.0"
  },
  "dependencies": {
    "express": "^4.18.1"
  }
}

📦 【Cloud Run】 インフラの準備

ここからは Cloud Run で動かすための手順です。`docker run` で動くサーバーのイメージがあればNode.jsでなくてもデプロイ可能です。

APIの有効化

Cloud Run などを初めて使うときは、まずAPIを有効化させる必要があります。料金を確認の上、それぞれのAPIを有効化させてください。

認証

ユーザー情報を利用したGCPへのログイン

$
gcloud auth login

📦 【Cloud Run】 レジストリへのPush

Cloud Run にデプロイするためにDockerイメージを Artifact Registry にPushします。ここでは手動で行う手順を記載します。

まずはリポジトリの作成です。リポジトリの名前には任意の値を指定してください。`--location` はリージョン名を指定します。利用可能なリージョンは 🔗 Available regions をご覧ください。

$
IMAGE_REPO_REGION="asia-northeast1"
IMAGE_REPO_NAME="ctc-buildservice-sample-repo"

gcloud artifacts repositories create ${IMAGE_REPO_NAME} \
    --location=${IMAGE_REPO_REGION} \
    --repository-format=docker

Dockerの認証ヘルパーにgcloudを登録。

$
IMAGE_REPO_REGION="asia-northeast1"

gcloud auth configure-docker ${IMAGE_REPO_REGION}-docker.pkg.dev

Build

$
GCP_PROJECT_ID="[project ID]"
IMAGE_REPO_REGION="asia-northeast1"
IMAGE_REPO_HOST="${IMAGE_REPO_REGION}-docker.pkg.dev"
IMAGE_REPO_NAME="ctc-buildservice-sample-repo"
IMAGE_NAME="ctc-buildservice-sample"
IMAGE_URL="${IMAGE_REPO_HOST}/${GCP_PROJECT_ID}/${IMAGE_REPO_NAME}/${IMAGE_NAME}:latest"

docker build . -t ${IMAGE_URL}

Push

$
GCP_PROJECT_ID="[project ID]"
IMAGE_REPO_REGION="asia-northeast1"
IMAGE_REPO_HOST="${IMAGE_REPO_REGION}-docker.pkg.dev"
IMAGE_REPO_NAME="ctc-buildservice-sample-repo"
IMAGE_NAME="ctc-buildservice-sample"
IMAGE_URL="${IMAGE_REPO_HOST}/${GCP_PROJECT_ID}/${IMAGE_REPO_NAME}/${IMAGE_NAME}:latest"

docker push ${IMAGE_URL}

この手順によって、Cloud Run が参照するイメージを設置できました。

📦 【Cloud Run】 サービスの作成

gcloudコマンドで Cloud Run のサービスを作成します。次の例の `--image` と `--region` 以外のオプションは指定しなければデフォルト値が割り当てられます。`--region` を指定しない場合、コマンド実行後に選択します。サービス名は任意の値です。

$
GCP_PROJECT_ID="[project ID]"
IMAGE_REPO_REGION="asia-northeast1"
IMAGE_REPO_HOST="${IMAGE_REPO_REGION}-docker.pkg.dev"
IMAGE_REPO_NAME="ctc-buildservice-sample-repo"
IMAGE_NAME="ctc-buildservice-sample"
IMAGE_URL="${IMAGE_REPO_HOST}/${GCP_PROJECT_ID}/${IMAGE_REPO_NAME}/${IMAGE_NAME}:latest"
SERVICE_NAME="ctc-buildservice-sample-service"
SERVICE_REGION="asia-northeast1"

gcloud run deploy ${SERVICE_NAME} \
    --image=${IMAGE_URL} \
    --region=${SERVICE_REGION} \
    --min-instances=0 \
    --max-instances=4 \
    --ingress=all \
    --allow-unauthenticated \
    --cpu=1 \
    --port=8080 \
    --set-env-vars=NAME='Cloud Run'

サービス一覧を見ると、デプロイされていることがわかります。表示されたURLにアクセスすると、サーバーが動いていることが確認できます。

$
gcloud run services list

`curl` と `jq` コマンドを使ってアクセスする例も示します。(コマンドが使えない場合は別途インストールが必要)

$
SERVICE_NAME="ctc-buildservice-sample-service"

curl $(gcloud run services list \
    --format=json | jq ".[] | select(.metadata.name == \"${SERVICE_NAME}\").status.url" -r)

余談

ところで、gcloudコマンドで Cloud Run にデプロイする際、動きの似ているコマンドがあります。本記事の執筆時点でドキュメントの説明や実行例の違いはありますが、動作やオプションにあまり違いがないように見えます。

📦 【Cloud Run】 GitHubとの接続

ここからはデプロイの自動化の手順となります。GitHubにコードをpushしている状態を前提として進めます。まずはGitHubとGCPの Cloud Build を紐付けたいので、GitHubに Cloud Build のアプリをインストールします。こちらよりインストールしてください。

🔗 Install Google Cloud Build (GitHub)

📦 【Cloud Run】 デプロイ自動化の構成

今回やりたいのは次の手順です。

  1. GitHubにpushでCloud Build のトリガー発動

  2. GitHub内の ./cloudbuild.yaml を読み込んで手順の準備

  3. GitHub内の ./Dockerfile を読み込んでBuild

  4. Buildしたイメージを Artifact Registry にPush

  5. Artifact Registry にPushしたイメージを Cloud Run にデプロイ

すでに、Build・Push・デプロイは手動で行ってきました。あとはこれを Cloud Build が読み込める形式で手順化し、トリガーを作成するだけです。Yaml形式で作成した構成ファイルの例を示します。

なお、構成ファイルのスキーマについては 🔗 ビルド構成ファイルのスキーマ を御覧ください。

./cloudbuild.yaml

steps:
  - id: Build
    name: gcr.io/cloud-builders/docker
    args:
      - build
      - '--no-cache'
      - '-t'
      - '$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:$COMMIT_SHA'
      - .
      - '-f'
      - Dockerfile
  - id: Set latest tag
    name: gcr.io/cloud-builders/docker
    args:
      - tag
      - '$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:$COMMIT_SHA'
      - '$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:latest'
  - id: Push
    name: gcr.io/cloud-builders/docker
    args:
      - push
      - '$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:$COMMIT_SHA'
  - id: Push latest
    name: gcr.io/cloud-builders/docker
    args:
      - push
      - '$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:latest'
  - id: Deploy
    name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    args:
      - run
      - services
      - update
      - $_SERVICE_NAME
      - '--platform=managed'
      - '--image=$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:$COMMIT_SHA'
      - >-
        --labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,trigger-name=$TRIGGER_NAME
      - '--region=$_SERVICE_REGION'
      - '--quiet'
    entrypoint: gcloud
images:
  - '$_IMAGE_REPO_HOST/$PROJECT_ID/$_IMAGE_REPO_NAME/$_SERVICE_NAME:$COMMIT_SHA'
options:
  substitutionOption: ALLOW_LOOSE
substitutions:
  _IMAGE_REPO_HOST: asia-northeast1-docker.pkg.dev
  _IMAGE_REPO_NAME: ctc-buildservice-sample-repo
  _SERVICE_NAME: ctc-buildservice-sample-service
  _SERVICE_REGION: asia-northeast1
tags:
  - gcp-cloud-build-deploy-cloud-run
  - gcp-cloud-build-deploy-cloud-run-managed
  - ctc-buildservice-sample-service

イメージのBuild時に commit ID をタグとしてつけているあたりが、手動の手順と少し違います。latestタグも同時に付けてPushしています。

`substitutions` は実行時にユーザー定義の変数の置換として扱われます。環境に合わせて適宜変更してください。(ここに表示している例は、これまでの手順で作成してきたイメージやサービスです。)

その他、デフォルトで扱える変数の置換もあります。
🔗 変数値の置換#デフォルトの置換の使用

ところで、`steps` で `name` に指定しているのは何でしょうか。これは 🔗 クラウドビルダー と呼ばれる、公開されているイメージを使用してタスクを実行できるものです。その中でも `gcr.io/cloud-builders/docker` は Cloud Build が用意しているDockerのコマンドです。

📦 【Cloud Run】 トリガーの作成

Cloud Build のトリガーに構成ファイルを登録して、トリガーを作成します。構成ファイルのパスや構成ファイル内のDockerfileのパスに注意してください。トリガーが正しく設定されると、mainブランチにpushやmergeされたタイミングでトリガーが発動し、最終的に Cloud Run にデプロイされます。

$
TRIGGER_NAME='ctc-buildservice-sample-trigger'
GITHUB_REPO_NAME='itkr-nodejs-cloudrun'
GITHUB_REPO_OWNER='buildorg-selfstudy'

gcloud beta builds triggers create github \
    --name=${TRIGGER_NAME} \
    --description='mainにpushしたらデプロイする' \
    --repo-name=${GITHUB_REPO_NAME} \
    --repo-owner=${GITHUB_REPO_OWNER} \
    --branch-pattern=^main$ \
    --build-config=cloudbuild.yaml

📦 【Cloud Run】 ビルド履歴の確認

$
gcloud builds list

上記コマンドでビルド履歴を表示可能です。ビルドがSUCCESSとなっていれば成功です。うまく言っていない場合は、ログを確認してください。

$
gcloud builds log [ID]

`jq` を使って最新のビルドのログを表示する例はこちら。

$
gcloud builds log \
    $(gcloud builds list --sort-by=~CREATE_TIME --format=json | jq ". [0].id" -r)

なお、🔗 Webコンソール画面 でも履歴を確認できます。


🐣 ネクストアクション

ここまでNode.jsのアプリの作成と、GCPを使ったコンテナ型アプリケーションのデプロイについて記載してきました。実際のサービス開発にはまだまだやることがあります。これらは機会がればどこかで掲載したいと思います。

  • ストレージの確保

  • 静的ファイルのホスティング

  • CDN

  • APIゲートウェイ

  • ロードバランス

  • 認証

  • テストの自動化

  • ログの出力


💡 コンテナとプロトタイプ

今回、本番稼働に向けた開発のエッセンスに少しは触れられたのではないかと思います。一方、コンテナ型アプリケーションと Cloud Run を利用した開発は、本番稼働だけでなく、プロトタイプ開発にも有用だと考えています。

ユーザーの課題を起点とするサービスやアプリケーションの開発には、ユーザーの課題と、ソリューションや製品が本当にフィットするのかを検証する必要があり、そのためにはプロトタイプ作りが有効な手段となり得ます。

コンテナでの動作を前提としたプロトタイプを開発することで、できるだけロックインを避け、アプリケーションの開発に注力できます。ビジネス観点の確認でプロトタイプを開発するときには積極的に利用したいです。

そんなコンテナ型アプリケーションを簡単にデプロイできる Cloud Run もまた、非常に強力なツールです。エンジニアが慣れ親しんでいる開発環境と、コンテナを通じてシームレスにつながる動作環境は開発に有利に働きます。

💡 コンテナとモダナイゼーション

経済産業省が 🔗 DXレポート を発表した2018年頃から、ビジネスの現場でもアプリケーションのモダナイゼーションが改めて注目されるようになりました。レガシーシステムは、維持するために多くの時間やリソースを必要とし、ビジネスの俊敏性やイノベーションを妨げてしまいます。

ビジネス環境の変化が激しくなる中で、巨大に膨れ上がったレガシーシステムのマイクロサービス化やDevOps・CI/CDの導入は重要なアプローチの1つであり、そのアプローチに対し、コンテナやGCPはとても相性が良いです。

💭

なお、Buildサービスチームではアプリケーションのモダナイゼーションを含めたDXの内製化支援を行っており、クラウドサービスを利用した開発を得意としています。ご依頼・採用情報など、ぜひお問い合わせください。

みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!