Rust × gRPC-webをECSにデプロイする
こんにちは、株式会社ナンバーナインの糟谷です。
ナンバーナインでは今回マイクロサービス構成の一部にRustを使用することにしました。
その際、gRPC-webの導入とデプロイ周りで苦労したので知見を記録しておきます。
最低限再現したサンプルプロジェクトを作成したので詳細はそちらを参照してください。
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.toml
にtonic-web
を追加します。
[dependencies]
tonic-web = "0.12.3"
次にserver.rs
にtonic-web
を有効にするための追記をします。
この際、.layer(GrpcWebLayer::new())
と記述することで複数Serviceすべてに適用させる方法もありますが、後述のヘルスチェック時に問題が発生するためService毎に有効化する方法を使用します。
#[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.toml
にtower-http
を追加します。
また、HeaderやMethodの指定で利用するためhttp
も導入しておきます。
[dependencies]
http = "1.1.0"
tower-http = { version = "0.6.2", features = ["cors"] }
server.rs
にCORS対応を記載します。
#[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を追加します。
[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
で最新のイメージを指定することで解決できます
[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
を導入します。
[dependencies]
tonic-health = "0.12.3"
health_reporter
を使用してHealthServiceを有効にします。
#[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のバイナリが実行できない場合があります。
その場合、distroless
やdebian-slim
などのglibcを含むイメージを使用する必要があります。
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を作っておきます。
[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環境でも設定により利用可能ですが、今回はローカルとの共通化という観点から設定しない方針としました。
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
終わりに
情報が少なく問題にぶつかった時に先例がネットにないことが多く辛かったです。
しかし、基本的には仕組みに乗っかれば解決出来るようになっており、最終的にはシンプルに解決できたかなと思ってます。
その他、直接関係ない気付きとして
- AIツールは検索を効率化できますが、公式ドキュメントに書いてあることを言わなかったりするので結局公式を読むのが一番早い
- 再現のためのサンプルプロジェクトを作成することで、試行錯誤の過程で不要だった作業を特定出来て学びが多い
なんかがありました。
参考
Discussion