🤖

Rust × gRPC-webをECSにデプロイする

2024/12/12に公開

こんにちは、株式会社ナンバーナインの糟谷です。
ナンバーナインでは今回マイクロサービス構成の一部にRustを使用することにしました。
その際、gRPC-webの導入とデプロイ周りで苦労したので知見を記録しておきます。

最低限再現したサンプルプロジェクトを作成したので詳細はそちらを参照してください。
https://github.com/siro33950/rust-gprc-sample

TL;DR

  • tonicに全部乗っかればいい
    • gRPC-webでALBのヘルスチェックを通そうとすると色々ある
  • コンパイルはcrossを使う
    • crossはDockerを使っているのでprotobuf-compiler周りで色々ある
    • CI周りでも少しある

Rust × gRPC-webプロジェクトの作成

プロジェクトの作成とTonicの導入

RustでgRPCを使うとtonicを使うのがデファクトのようです。
まずはtonicのexamples/helloworld-tutorial.mdに則ってプロジェクトを作っていきます。

記載通りに作ればいいだけなので詳細は割愛します。
RustやDockerなど、前提となるツールの導入についても同様です。

gRPC-webへの対応

マイクロサービス構成の一部とはいえ、フロントエンドからアクセスさせる必要があります。
現状はBFFやEnvoyの導入は考えていないため、gRPC-webに対応させていきます。
tonicにはtonic-webというcreateが用意されており、これを導入することでgRPC-webに対応させることができます。
基本的にはドキュメントの通りに実装すれば問題なく動作します。

まずはCargo.tomltonic-webを追加します。

Cargo.toml
[dependencies]
tonic-web = "0.12.3"

次にserver.rstonic-webを有効にするための追記をします。
この際、.layer(GrpcWebLayer::new())と記述することで複数Serviceすべてに適用させる方法もありますが、後述のヘルスチェック時に問題が発生するためService毎に有効化する方法を使用します。

server.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	let addr = "[::1]:50051".parse()?;
	let greeter = MyGreeter::default();
	  
	Server::builder()
		.accept_http1(true) // HTTP/1.1リクエストを受け付ける
		.add_service(tonic_web::enable(GreeterServer::new(greeter))) // tonic_webを有効にする
		.serve(addr)
		.await?;
	
	Ok(())
}

CORS対応

フロントエンドからのアクセスを考慮するため、CORS対応も実施します。
CORS対応にはtower_httpのcorsモジュールを利用します。
なお、tonicはtowerベースで作られています。

まずはCargo.tomltower-httpを追加します。
また、HeaderやMethodの指定で利用するためhttpも導入しておきます。

Cargo.toml
[dependencies]
http = "1.1.0"
tower-http = { version = "0.6.2", features = ["cors"] }

server.rsにCORS対応を記載します。

server.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	let addr = "[::1]:50051".parse()?;
	let greeter = MyGreeter::default();
	
	// CORS設定
	let cors_layer = CorsLayer::new()
		.allow_origin(AllowOrigin::list(vec!["localhost:3000".parse().unwrap()]))
		.allow_headers(AllowHeaders::list(vec![
			header::CONTENT_TYPE,
			header::AUTHORIZATION,
		]))
		.allow_methods(AllowMethods::list(vec![Method::GET, Method::POST]));
	
	Server::builder()
		.accept_http1(true)
		.layer(cors_layer) // CORS設定を適用
		.add_service(tonic_web::enable(GreeterServer::new(greeter)))
		.serve(addr)
		.await?;
	
	Ok(())
}

クロスコンパイル

ナンバーナインでのメイン開発機はApple Silicon Macですが、デプロイ先はLinuxであるため、クロスコンパイルが必要です。
クロスコンパイルの手法は様々ありますが、今回はRustのクロスコンパイルツールであるcrossを利用することにします。

まずはcrossの導入。

cargo install cross --git https://github.com/cross-rs/cross

そのまま実行すると、

cross build --bin helloworld-server --target x86_64-unknown-linux-gnu

エラーが発生します。

error: failed to run custom build command for `helloworld-tonic v0.1.0 (/workspace/rust-gprc-sample)`

Caused by:
  process didn't exit successfully: `/target/debug/build/helloworld-tonic-048e75dede2a8a19/build-script-build` (exit status: 1)
  --- stdout
  cargo:rerun-if-changed=proto/helloworld.proto
  cargo:rerun-if-changed=proto

  --- stderr
  Error: Custom { kind: Other, error: "protoc failed: " }

このエラーはProtocol Buffersコンパイラであるprotobuf-compilerがインストールされておらず、protocコマンドが使えないことが原因です。
しかし、このエラーは開発機にインストールされていてローカルではビルドが通る場合でも発生します。
crossを利用したコンパイルはDocker上で動作しているため、イメージの依存関係に追加する必要があります。

依存関係を追加するためにはリポジトリ直下にCross.tomlを作成し、Pre-build hookを追加します。

Cross.toml
[target.x86_64-unknown-linux-gnu]
pre-build = [
	"dpkg --add-architecture $CROSS_DEB_ARCH",
	"apt install -y protobuf-compiler",
]

これにより依存関係にprotobuf-compilerが追加され、crossによるビルドが成功するようになります。

proto3への対応

proto3の新しい機能(optionalフィールドやmap型など)を使用している場合、crossのデフォルトイメージに含まれるprotobuf-compilerのバージョンが古いためコンパイルエラーが発生することがあります。
この場合、Cross.tomlで最新のイメージを指定することで解決できます

Cross.toml
[target.x86_64-unknown-linux-gnu]
pre-build = [
    "dpkg --add-architecture $CROSS_DEB_ARCH",
    "apt install -y protobuf-compiler",
]
image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:edge"

ECSへのデプロイ

ヘルスチェック

ECSへのデプロイをするためにまずはヘルスチェックを実装していきます。
ヘルスチェック用のprotoファイルを作成する方法もありますが、tonic-healthを実装することでも実現できます。
tonic-healthは元々gRPC Health Checking Protocolで規定されているヘルスチェックを実装するためのcreateですが、/grpc.health.v1.Health/Checkはボディ無しでもリクエストが可能なため、ALBのヘルスチェックにも流用できます。

tonic-healthを導入します。

Cargo.toml
[dependencies]
tonic-health = "0.12.3"

health_reporterを使用してHealthServiceを有効にします。

server.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	let addr = "[::1]:50051".parse()?;
	let greeter = MyGreeter::default();
	
	// Create HealthReporter
	let (mut health_reporter, health_service) = tonic_health::server::health_reporter();
	health_reporter
		.set_serving::<GreeterServer<MyGreeter>>() // ついでにGreeterServerにもヘルスチェックを生やしておく
		.await;
	
	let cors_layer = CorsLayer::new()
		.allow_origin(AllowOrigin::list(vec!["localhost:3000".parse().unwrap()]))
		.allow_headers(AllowHeaders::list(vec![
			header::CONTENT_TYPE,
			header::AUTHORIZATION,
		]))
		.allow_methods(AllowMethods::list(vec![Method::GET, Method::POST]));
	
	Server::builder()
		.accept_http1(true)
		.layer(cors_layer)
		.add_service(tonic_web::enable(GreeterServer::new(greeter)))
		.add_service(health_service) // HealthServiceを追加. tonic-webを有効にするとエラーになる
		.serve(addr)
		.await?;
	
	Ok(())
}

この際、health_serviceにもtonic-webを有効にしてしまうと、ローカルでは動作するものの、ALBでのヘルスチェック時にエラーが発生してしまいます。
詳細な理由は把握できていませんが、tonic-webを有効にした場合、ALBからのヘルスチェックは、gRPCとHTTPのいずれの場合もエラーとなってしまいます。
そのため、HealthServiceについてはtonic-webを無効にしておく必要があります。

HTTPのヘルスチェックが通らない理由については、tonic-webのドキュメントに記載されている以下の内容が原因と考えられます。

- `tonic_web` is designed to work with grpc-web-compliant clients only. It is not expected to handle arbitrary HTTP/x.x requests or bespoke protocols.

gRPCのヘルスチェックが通らない理由については、ALBからの純粋なgRPCヘルスチェックリクエストが、tonic-webによるgRPC-web形式への変換プロセスと互換性がないためと推測されます。

デプロイ

ECSへのデプロイをするためにDockerイメージを作成します。

Dockerfileをプロジェクトルートに作成します。
なお、ベースイメージとしてAlpineを使用するとglibcが存在しないためRustのバイナリが実行できない場合があります。
その場合、distrolessdebian-slimなどのglibcを含むイメージを使用する必要があります。

Dockerfile
FROM gcr.io/distroless/cc-debian12:latest AS prod

WORKDIR /workspace

ADD target/x86_64-unknown-linux-gnu/release/helloworld-server /workspace/helloworld-server

CMD ["/workspace/helloworld-server"]

Dockerイメージを作成します。

docker build --target prod -t helloworld-server .

タグ付けしてECRへPushします。

docker tag helloworld-server:latest hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com/helloworld-server:latest
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com
docker push hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com/helloworld-server:latest

ecs-deployを使用してECSへデプロイします。

ecs-deploy \
	-r ap-northeast-1 \
	--cluster helloworld \
	--service-name helloworld-server \
	--image hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com/helloworld-server:latest \
	-t 600 \
	--verbose

ついでにこのタイミングでMakefileを作っておきます。

Makefile.toml
[tasks.run]
description = "Build the project."
command = "cargo"
args = [
  "run",
  "--bin", "helloworld-server"
]

[tasks.build]
description = "Build the project for Linux target using Docker"
command = "cross"
args = [
    "build",
    "--release", 
    "--bin", "helloworld-server",
    "--target", "x86_64-unknown-linux-gnu"
]

[tasks.docker-build]
description = "Build the project for Linux target using Docker"
dependencies = ["build"]
command = "docker"
args = [
  "build", "--target", "prod", "-t", "helloworld-server", "."
]


[tasks.push-dockerhub]
description = "Push the project to Docker Hub"
dependencies = ["docker-build"]
script = [
    '''
    docker tag helloworld-server:latest hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com/helloworld-server:latest
    aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com
    docker push hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com/helloworld-server:latest
    '''
]

[tasks.deploy]
description = "Deploy to Docker Hub"
dependencies = ["push-dockerhub"]
script = [
    '''
    ecs-deploy \
        -r ap-northeast-1 \
        --cluster helloworld \
        --service-name helloworld-server \
        --image hogehoge.dkr.ecr.ap-northeast-1.amazonaws.com/helloworld-server:latest \
        -t 600 \
        --verbose
    '''
]

CIからのデプロイ

最後に、デプロイをCI上から実施します。
当社で使用しているCIはCircleCIであるため、それに準拠します。

CIの設定ファイルを.circleci/config.ymlに作成します。
その際、Docker in Docker環境ではcrossの設定が別途必要になるため、マシンイメージを使用します。
Docker in Docker環境でも設定により利用可能ですが、今回はローカルとの共通化という観点から設定しない方針としました。

config.yml
version: 2.1
orbs:
  aws-cli: circleci/aws-cli@5.1.1

executors:
  # Crossでのビルド時、Dockerを使うと設定が面倒なのでLinuxを使う
  executor:
    machine:
      image: ubuntu-2204:current
      resource_class: medium

commands:
  install-protoc:
    steps:
      - run:
          name: "Install protoc"
          command: |
            if ! [ -f /usr/bin/protoc ]; then
              sudo apt-get update
              sudo apt-get install -y protobuf-compiler
            fi

  install-rust-and-cargo-make:
    steps:
      - run:
          name: "Install Rust and Cargo Make"
          command: |
            curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
            source $HOME/.cargo/env
            cargo install cargo-make

  install-ecs-deploy:
    steps:
      - run:
          name: "Install ecs-deploy"
          command: |
            latest=$(curl -s 'https://api.github.com/repos/silinternational/ecs-deploy/tags' | jq -r '.[].name' | head -1 || true)
            curl -s https://raw.githubusercontent.com/silinternational/ecs-deploy/${latest}/ecs-deploy > ~/bin/ecs-deploy
            chmod +x ~/bin/ecs-deploy

jobs:
  deploy:
    executor: executor
    environment:
      aws_access_key_id: $AWS_ACCESS_KEY_ID
      aws_secret_access_key: $AWS_SECRET_ACCESS_KEY
    steps:
      - checkout
      - install-protoc
      - install-rust-and-cargo-make
      - run:
          name: "Run cargo install cross"
          command: cargo install cross
      - run:
          name: "Run cargo make build"
          command: cargo make build
      - aws-cli/install
      - aws-cli/setup
      - install-ecs-deploy
      - setup_remote_docker
      - run:
          name: "Run cargo make deploy"
          command: cargo make deploy

workflows:
  build-workflow:
    jobs:
      - deploy:
          context: helloworld

終わりに

情報が少なく問題にぶつかった時に先例がネットにないことが多く辛かったです。
しかし、基本的には仕組みに乗っかれば解決出来るようになっており、最終的にはシンプルに解決できたかなと思ってます。

その他、直接関係ない気付きとして

  1. AIツールは検索を効率化できますが、公式ドキュメントに書いてあることを言わなかったりするので結局公式を読むのが一番早い
  2. 再現のためのサンプルプロジェクトを作成することで、試行錯誤の過程で不要だった作業を特定出来て学びが多い

なんかがありました。

参考

https://speakerdeck.com/i10416/rust-x-web-x-gcp-woyatuteiku
https://dev.classmethod.jp/articles/rust-crosscompile/
https://trap.jp/post/2335/

ナンバーナイン開発室

Discussion