じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

RustのWebアプリケーションにオブザーバビリティを実装するインフラエンジニアのための入門ガイド

はじめに

「新規プロジェクトに参画したら、アプリケーションがRustで書かれていた...

このような経験をされた方も多いのではないでしょうか。もしくは今後あるかもしれません。特に、オブザーバビリティの実装を担当することになったインフラエンジニアにとって、Rustは馴染みの薄い言語かもしれません。

このガイドは、インフラエンジニアとしての経験は豊富だが、Rustの経験が少ないインフラエンジニアのために書かれています。既存のRustアプリケーションにログ、メトリクス、トレーシングを実装する方法を、Rustの前提知識を必要とせずに理解できるよう解説します。前提知識が不要なだけで都度学習はしてもらいます。

Rust の移行に関してはこちらを見てもらうと良いかなぁって思っています。

syu-m-5151.hatenablog.com

想定読者

  • オブザーバビリティの実装経験がある
  • PythonJava、Goなどでの実装経験はある
  • Rustは初めて触れる、もしくは経験が浅い
  • 既存のRustアプリケーションにオブザーバビリティを実装する必要がある

このガイドで得られること

  • Rustアプリケーションの基本的な構造の理解
  • オブザーバビリティ実装に必要なRustの最小限の知識
  • 実装手順とコード例
  • トラブルシューティングのポイント

まず、典型的なRustのWebアプリケーションの構造を見ていきましょう。

Rustの基本的な概念

アトリビュート#[...]

Rustでは#[...]という記法をアトリビュート(属性)と呼びます。これはコードに対して追加の情報や機能を付与する特別な構文です。アトリビュートを使用することで、コンパイラへの指示や機能の自動実装が可能になります。これは他の言語では以下のように表現されるものに相当します。

参考: The Rust Reference - Attributes

主なアトリビュートの例:

// 自動的に特定の機能を実装する
#[derive(Debug)]  // println!("{:?}", obj)でデバッグ出力を可能にする
                  // 例: println!("{:?}", user); // User { id: 1, name: "John" }

#[derive(Clone)]  // オブジェクトのクローン(複製)を可能にする
                  // 例: let user2 = user.clone();

#[derive(Serialize, Deserialize)]  // JSONとの相互変換を可能にする
                  // 例: let json = serde_json::to_string(&user)?;
                  // let user: User = serde_json::from_str(&json)?;

// 関数やモジュールの属性を指定する
#[test]  // テスト関数であることを示す
         // 例: cargo testでテストとして実行される

#[actix_web::main]  // actix-webのメイン関数であることを示す
                    // 非同期ランタイムの設定を自動的に行う

アトリビュートが実際に何をしているのかを具体例で見てみます。

// #[derive(Debug)]がない場合
struct User {
    id: u32,
    name: String,
}
let user = User { id: 1, name: "John".to_string() };
println!("{:?}", user);  // コンパイルエラー!

// #[derive(Debug)]がある場合
#[derive(Debug)]
struct User {
    id: u32,
    name: String,
}
let user = User { id: 1, name: "John".to_string() };
println!("{:?}", user);  // User { id: 1, name: "John" } と出力される

アトリビュートを使用することで、以下のようなメリットが得られます。

構造体(struct)とパターンマッチング(match

Rustの構造体は、他の言語のクラスに相当します。また、パターンマッチングは他言語のswitch文に似ていますが、より強力です。

// match式の例
match result {
    Some(value) => println!("値が存在します: {}", value),
    None => println!("値が存在しません"),
}

参考: The Rust Programming Language - Pattern Matching

エンドポイントの戻り値型

-> impl Responder

これは「Responderトレイトをimplementsする何らかの型」を返すことを意味します。雑に言うとJavaのインターフェースやTypeScriptの型に似た概念です。

参考: Actix Web - Responder trait

Mutexを使用したデータの共有

users: Mutex<HashMap<u32, User>>

Mutexは「相互排除(Mutual Exclusion)」の略で、複数のスレッドから安全にデータにアクセスするための機構です。

参考: Rust Standard Library - Mutex

Path引数の取得

id: web::Path<u32>

URLのパスパラメータを型安全に取得します。例:/users/123123部分。

参考: Actix Web - Path Extractor

Webアプリケーションの簡易な実装

それでは、簡易なRustのWebアプリケーションの構造を見てみましょう。

// src/main.rs - 既存のWebアプリケーション
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use std::collections::HashMap;

// Rustでは構造体の定義に#[derive(...)]という形式で機能を追加します
// SerializeとDeserializeは、JSONとの相互変換を可能にします
#[derive(Serialize, Deserialize, Clone)]
struct User {
    id: u32,
    name: String,
    email: String,
}

// AppStateは、アプリケーション全体で共有する状態を定義します
// Mutexは、複数のスレッドから安全にデータを変更するために使用します
struct AppState {
    users: Mutex<HashMap<u32, User>>,
    user_counter: Mutex<u32>,
}

// エンドポイントの実装
async fn create_user(
    state: web::Data<AppState>,
    user_data: web::Json<User>
) -> impl Responder {
    let mut user_counter = state.user_counter.lock().unwrap();
    let mut users = state.users.lock().unwrap();
    
    let new_user = User {
        id: *user_counter,
        name: user_data.name.clone(),
        email: user_data.email.clone(),
    };
    
    users.insert(*user_counter, new_user.clone());
    *user_counter += 1;
    
    HttpResponse::Created().json(new_user)
}

async fn get_user(
    state: web::Data<AppState>,
    id: web::Path<u32>
) -> impl Responder {
    let users = state.users.lock().unwrap();
    
    match users.get(&id.into_inner()) {
        Some(user) => HttpResponse::Ok().json(user),
        None => HttpResponse::NotFound().finish()
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // アプリケーションの状態を初期化
    let app_state = web::Data::new(AppState {
        users: Mutex::new(HashMap::new()),
        user_counter: Mutex::new(0),
    });

    HttpServer::new(move || {
        App::new()
            .app_data(app_state.clone())
            .route("/users", web::post().to(create_user))
            .route("/users/{id}", web::get().to(get_user))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

参考:

APIの使用例

# ヘルスチェック
curl http://localhost:8080/health

# ユーザーの作成
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com"}'

# ユーザーの取得
curl http://localhost:8080/users/0

この基本的な実装を理解することで、次のステップであるオブザーバビリティの実装がより理解しやすくなります。

Rustの重要な概念(インフラエンジニアが知っておくべきこと)

  1. 依存関係の管理
    • RustではCargo.tomlファイルで依存関係を管理します
    • npmのpackage.jsonやrequirements.txtに相当します
[dependencies]
name = "version"  # 基本的な依存
name = { version = "version", features = ["feature1", "feature2"] }  # 機能を指定
  1. モジュールとパス
    • useキーワードでモジュールをインポートします
    • modキーワードで新しいモジュールを定義します
// src/logging.rs などの新しいファイルを作成した場合
mod logging;  // main.rsでこのように宣言
use crate::logging::setup_logger;  // 関数を使用する際はこのように指定
  1. エラーハンドリング
    • RustではResult<T, E>型でエラーハンドリングを行います
    • ?演算子でエラーを上位に伝播させます
// エラーハンドリングの例
fn function() -> Result<(), Box<dyn Error>> {
    let result = something_that_might_fail()?;  // エラーが発生したら即座にReturnします
    Ok(())
}

オブザーバビリティの実装

この辺はぜひもう一度読んでほしいです。

syu-m-5151.hatenablog.com

依存関係の追加

まず、Cargo.tomlに必要な依存関係を追加します。

[dependencies]
# 既存の依存関係
actix-web = "4.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# オブザーバビリティ関連の依存関係を追加
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7"
prometheus = "0.13"
lazy_static = "1.4"
opentelemetry = { version = "0.21", features = ["rt-tokio"] }
opentelemetry-otlp = "0.14"
tracing-opentelemetry = "0.22"

めちゃくちゃに良いブログがあるの合わせて紹介させてください。

blog.ymgyt.io

zenn.dev

モジュール構造の作成

オブザーバビリティ関連のコードを整理するために、以下のような構造を作成します。

// src/observability/mod.rs
mod logging;
mod metrics;
mod tracing;

pub use logging::setup_logging;
pub use metrics::setup_metrics;
pub use tracing::setup_tracing;

ログの実装

今度、別でRust のロギングのライブラリの比較をしたいです⋯。

moriyoshi.hatenablog.com

www.forcia.com

ライブラリが云々よりも実際にちゃんと設計するのも大切ですよね。

qiita.com

// src/observability/logging.rs
use tracing::{info, warn, error, Level};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

pub fn setup_logging() {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| format!("{}=info", env!("CARGO_PKG_NAME")).into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();
}

// ログマクロの使用例
// info!("メッセージ");
// error!("エラー: {}", err);

メトリクスの実装

// src/observability/metrics.rs
use prometheus::{Registry, Counter, IntCounter, opts};
use lazy_static::lazy_static;

// メトリクスの定義
lazy_static! {
    pub static ref REGISTRY: Registry = Registry::new();
    pub static ref HTTP_REQUESTS_TOTAL: IntCounter = IntCounter::new(
        "http_requests_total",
        "Total number of HTTP requests"
    ).unwrap();
    pub static ref USER_OPERATIONS_TOTAL: IntCounter = IntCounter::with_opts(
        opts!("user_operations_total", "Total number of user operations")
            .const_label("service", "user-api")
    ).unwrap();
}

pub fn setup_metrics() -> Result<(), Box<dyn std::error::Error>> {
    // メトリクスの登録
    REGISTRY.register(Box::new(HTTP_REQUESTS_TOTAL.clone()))?;
    REGISTRY.register(Box::new(USER_OPERATIONS_TOTAL.clone()))?;
    Ok(())
}

// Prometheusメトリクスエンドポイント用のハンドラ
pub async fn metrics_handler() -> impl Responder {
    let mut buffer = vec![];
    let encoder = prometheus::TextEncoder::new();
    encoder.encode(&REGISTRY.gather(), &mut buffer).unwrap();
    
    HttpResponse::Ok()
        .content_type("text/plain")
        .body(buffer)
}

トレーシングの実装

気になればこちらも読んでもらいたいです。

syu-m-5151.hatenablog.com

// src/observability/tracing.rs
use opentelemetry::sdk::Resource;
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithExportConfig;

pub fn setup_tracing() -> Result<(), Box<dyn std::error::Error>> {
    let tracer = opentelemetry_otlp::new_pipeline()
        .tracing()
        .with_exporter(
            opentelemetry_otlp::new_exporter()
                .tonic()
                .with_endpoint(
                    std::env::var("OTLP_ENDPOINT")
                        .unwrap_or_else(|_| "http://localhost:4317".to_string())
                ),
        )
        .with_trace_config(
            opentelemetry::sdk::trace::config()
                .with_resource(Resource::new(vec![
                    KeyValue::new("service.name", "user-api"),
                ]))
        )
        .install_batch(opentelemetry::runtime::Tokio)?;

    // トレーシングの初期化
    opentelemetry::global::set_tracer_provider(tracer);
    
    Ok(())
}

こういう実装の仕方もあるので

caddi.tech

既存のエンドポイントへの統合

// 修正後のcreate_user関数
#[tracing::instrument(name = "create_user", skip(state, user_data))]
async fn create_user(
    state: web::Data<AppState>,
    user_data: web::Json<User>
) -> impl Responder {
    // メトリクスのインクリメント
    HTTP_REQUESTS_TOTAL.inc();
    USER_OPERATIONS_TOTAL.inc();

    // ログの出力
    info!(
        user_name = %user_data.name,
        user_email = %user_data.email,
        "Creating new user"
    );

    let mut user_counter = state.user_counter.lock().unwrap();
    let mut users = state.users.lock().unwrap();
    
    let new_user = User {
        id: *user_counter,
        name: user_data.name.clone(),
        email: user_data.email.clone(),
    };
    
    users.insert(*user_counter, new_user.clone());
    *user_counter += 1;

    info!(user_id = new_user.id, "User created successfully");
    
    HttpResponse::Created().json(new_user)
}

メインアプリケーションの更新

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // オブザーバビリティの初期化
    setup_logging();
    setup_metrics().expect("Failed to setup metrics");
    setup_tracing().expect("Failed to setup tracing");

    let app_state = web::Data::new(AppState {
        users: Mutex::new(HashMap::new()),
        user_counter: Mutex::new(0),
    });

    info!("Starting server at http://localhost:8080");

    HttpServer::new(move || {
        App::new()
            .wrap(tracing_actix_web::TracingLogger::default())
            .app_data(app_state.clone())
            .route("/metrics", web::get().to(metrics_handler))
            .route("/users", web::post().to(create_user))
            .route("/users/{id}", web::get().to(get_user))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

3. 動作確認

アプリケーションの起動

# 開発モードで実行
cargo run

# 本番モードで実行(最適化あり)
cargo run --release

APIのテスト

# ユーザーの作成
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com"}'

# ユーザーの取得
curl http://localhost:8080/users/0

# メトリクスの確認
curl http://localhost:8080/metrics

ログの確認

# 環境変数でログレベルを設定
RUST_LOG=debug cargo run

4. トラブルシューティング

一般的な問題と解決方法

  1. コンパイルエラー
    • 依存関係のバージョンの不一致
cargo update  # 依存関係を更新
  1. ランタイムエラー
    • OpenTelemetryエンドポイントに接続できない
# エンドポイントの確認
OTLP_ENDPOINT=http://localhost:4317 cargo run
  1. メトリクスが表示されない
// メトリクスが正しく登録されているか確認
println!("Registered metrics: {:?}", REGISTRY.gather());

5. 本番環境への展開

環境変数の設定

# 必要な環境変数
export RUST_LOG=info
export OTLP_ENDPOINT=http://otel-collector:4317
export SERVICE_NAME=user-api

Dockerファイルの例

FROM rust:1.70 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release

FROM debian:buster-slim
COPY --from=builder /usr/src/app/target/release/my-app /usr/local/bin/
CMD ["my-app"]

6.Rustオブザーバビリティ実装の最終成果物

ディレクトリ構造

my-rust-api/
├── Cargo.toml
├── Dockerfile
├── .env
└── src/
    ├── main.rs
    └── observability/
        ├── mod.rs
        ├── logging.rs
        ├── metrics.rs
        └── tracing.rs

各ファイルの実装

Cargo.toml

[package]
name = "my-rust-api"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7"
prometheus = "0.13"
lazy_static = "1.4"
opentelemetry = { version = "0.21", features = ["rt-tokio"] }
opentelemetry-otlp = "0.14"
tracing-opentelemetry = "0.22"

src/main.rs

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use std::collections::HashMap;
use tracing::info;

mod observability;
use observability::{setup_logging, setup_metrics, setup_tracing, metrics_handler};

#[derive(Serialize, Deserialize, Clone)]
struct User {
    id: u32,
    name: String,
    email: String,
}

struct AppState {
    users: Mutex<HashMap<u32, User>>,
    user_counter: Mutex<u32>,
}

#[tracing::instrument(name = "create_user", skip(state, user_data))]
async fn create_user(
    state: web::Data<AppState>,
    user_data: web::Json<User>
) -> impl Responder {
    use crate::observability::metrics::HTTP_REQUESTS_TOTAL;
    use crate::observability::metrics::USER_OPERATIONS_TOTAL;

    HTTP_REQUESTS_TOTAL.inc();
    USER_OPERATIONS_TOTAL.inc();

    info!(
        user_name = %user_data.name,
        user_email = %user_data.email,
        "Creating new user"
    );

    let mut user_counter = state.user_counter.lock().unwrap();
    let mut users = state.users.lock().unwrap();
    
    let new_user = User {
        id: *user_counter,
        name: user_data.name.clone(),
        email: user_data.email.clone(),
    };
    
    users.insert(*user_counter, new_user.clone());
    *user_counter += 1;

    info!(user_id = new_user.id, "User created successfully");
    HttpResponse::Created().json(new_user)
}

#[tracing::instrument(name = "get_user", skip(state))]
async fn get_user(
    state: web::Data<AppState>,
    id: web::Path<u32>
) -> impl Responder {
    use crate::observability::metrics::HTTP_REQUESTS_TOTAL;

    HTTP_REQUESTS_TOTAL.inc();
    let users = state.users.lock().unwrap();
    
    match users.get(&id.into_inner()) {
        Some(user) => {
            info!(user_id = user.id, "User found");
            HttpResponse::Ok().json(user)
        },
        None => {
            info!(user_id = %id, "User not found");
            HttpResponse::NotFound().finish()
        }
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // オブザーバビリティの初期化
    setup_logging();
    setup_metrics().expect("Failed to setup metrics");
    setup_tracing().expect("Failed to setup tracing");

    let app_state = web::Data::new(AppState {
        users: Mutex::new(HashMap::new()),
        user_counter: Mutex::new(0),
    });

    info!("Starting server at http://localhost:8080");

    HttpServer::new(move || {
        App::new()
            .wrap(tracing_actix_web::TracingLogger::default())
            .app_data(app_state.clone())
            .route("/metrics", web::get().to(metrics_handler))
            .route("/users", web::post().to(create_user))
            .route("/users/{id}", web::get().to(get_user))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

src/observability/mod.rs

mod logging;
mod metrics;
mod tracing;

pub use logging::setup_logging;
pub use metrics::{setup_metrics, metrics_handler};
pub use tracing::setup_tracing;

pub(crate) use metrics::HTTP_REQUESTS_TOTAL;
pub(crate) use metrics::USER_OPERATIONS_TOTAL;

4. src/observability/logging.rs

use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

pub fn setup_logging() {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| format!("{}=info", env!("CARGO_PKG_NAME")).into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();
}

src/observability/metrics.rs

use actix_web::{HttpResponse, Responder};
use prometheus::{Registry, IntCounter, opts};
use lazy_static::lazy_static;

lazy_static! {
    pub static ref REGISTRY: Registry = Registry::new();
    
    pub static ref HTTP_REQUESTS_TOTAL: IntCounter = IntCounter::new(
        "http_requests_total",
        "Total number of HTTP requests"
    ).unwrap();
    
    pub static ref USER_OPERATIONS_TOTAL: IntCounter = IntCounter::with_opts(
        opts!("user_operations_total", "Total number of user operations")
            .const_label("service", "user-api")
    ).unwrap();
}

pub fn setup_metrics() -> Result<(), Box<dyn std::error::Error>> {
    REGISTRY.register(Box::new(HTTP_REQUESTS_TOTAL.clone()))?;
    REGISTRY.register(Box::new(USER_OPERATIONS_TOTAL.clone()))?;
    Ok(())
}

pub async fn metrics_handler() -> impl Responder {
    let mut buffer = vec![];
    let encoder = prometheus::TextEncoder::new();
    encoder.encode(&REGISTRY.gather(), &mut buffer).unwrap();
    
    HttpResponse::Ok()
        .content_type("text/plain")
        .body(buffer)
}

src/observability/tracing.rs

use opentelemetry::sdk::Resource;
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithExportConfig;

pub fn setup_tracing() -> Result<(), Box<dyn std::error::Error>> {
    let tracer = opentelemetry_otlp::new_pipeline()
        .tracing()
        .with_exporter(
            opentelemetry_otlp::new_exporter()
                .tonic()
                .with_endpoint(
                    std::env::var("OTLP_ENDPOINT")
                        .unwrap_or_else(|_| "http://localhost:4317".to_string())
                ),
        )
        .with_trace_config(
            opentelemetry::sdk::trace::config()
                .with_resource(Resource::new(vec![
                    KeyValue::new("service.name", "user-api"),
                ]))
        )
        .install_batch(opentelemetry::runtime::Tokio)?;

    opentelemetry::global::set_tracer_provider(tracer);
    
    Ok(())
}

.env

RUST_LOG=info
OTLP_ENDPOINT=http://localhost:4317
SERVICE_NAME=user-api

Dockerfile

FROM rust:1.70 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release

FROM debian:buster-slim
COPY --from=builder /usr/src/app/target/release/my-rust-api /usr/local/bin/
COPY .env /usr/local/bin/
WORKDIR /usr/local/bin
CMD ["my-rust-api"]

動作確認方法

  1. アプリケーションの起動:
cargo run
  1. APIのテスト:
# ユーザーの作成
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com"}'

# ユーザーの取得
curl http://localhost:8080/users/0

# メトリクスの確認
curl http://localhost:8080/metrics

この実装により、以下のオブザーバビリティ機能が利用可能になります。

  1. ログ出力:構造化ログが標準出力に出力されます
  2. メトリクス:/metricsエンドポイントでPrometheus形式のメトリクスが取得可能
  3. トレーシング:OpenTelemetryを通じて分散トレーシングが可能

各機能は環境変数を通じて設定可能で、本番環境での運用に対応しています。

7. 参考リンク

まとめ

このガイドでは、Rustの経験が浅いインフラエンジニアを対象に、既存のRustアプリケーションにオブザーバビリティを実装する方法を解説しました。

アトリビュートやトレイトといったRustの基本的な概念から始め、オブザーバビリティ実装に必要な最小限の知識を説明しました。Cargoを使用した依存関係の管理方法や、モジュール構造の基本についても触れることで、Rustの開発環境への理解を深めることができたと思います。

実装面では、ログ出力にtracing、メトリクスにprometheus、分散トレーシングにOpenTelemetryを採用し、それぞれを個別のモジュールとして整理された形で実装する方法を示しました。これにより、構造化ログによる効率的なログ管理や、Prometheusと互換性のあるメトリクスエンドポイント、そしてOpenTelemetryによる分散トレーシングといった実用的な機能を実現することができました。

このガイドを通じて、Rustの詳細な知識がなくても、実用的なオブザーバビリティ機能を実装できることを示すことができました。Cargoのパッケージは複雑怪奇なので注意してほしいです。オブザーバビリティの実装は、アプリケーションの健全性監視と問題解決に不可欠です。このガイドが、Rustでのオブザーバビリティ実装に取り組むインフラエンジニアの一助となれば幸いです。