ABEJA Tech Blog

中の人の興味のある情報を発信していきます

AWS Lambdaを支える技術

こんにちは、今年の4月に新卒入社でABEJAに入社しました島倉と申します。 現在はプロジェクトマネージャーとして働いています。

これはABEJAアドベントカレンダー2024の9日目の記事です。

今回は、生成AIの発展が注目されている中あえて、「地味だけど重要な技術」にフォーカスしてAWS LambdaやECS Fargateの一部で使われている仮想化技術Firecrackerの背景やアーキテクチャ、さらに実際のコードの解読まで深掘りしていきます!

なぜFirecrackerが開発されたのか

Firecrackerが生まれた背景には、AWS Lambdaが抱えていたいくつかの課題があったようです。 出典: Firecracker: Lightweight Virtualization for Serverless Applications

従来の仮想化技術の課題

Lambdaのサービス提供開始当初はLinuxコンテナを利用して関数ごとの分離を行い、OSの仮想化によってテナントごとの分離を行っていたそうです。ただ、このアプローチにはいくつか問題があったようです。

We were unsatisfied with this approach for several reasons, including the necessity of trading off between security and compatibility that containers represent, and the difficulties of efficiently packing workloads onto fixed-size VMs.

特に以下の2点が問題視されていました:

  • セキュリティと互換性のトレードオフ
    • Linuxコンテナは軽量で高速ですが、カーネル共有に伴ってセキュリティリスクが高く、特にサイドチャネル攻撃に対して脆弱
  • 固定サイズの仮想マシン(VM)にワークロードを効率的に詰め込むことの難しさ

これらを解決するために、AWSは以下の要件を満たす新しい技術を探したそうです。

Firecrackerの設計要件

  • Isolation(隔離性):
    • 複数の関数が同一のハードウェア上で安全に実行される必要があり、Privilege Escalation、Covert Channel、情報漏洩などのリスクから保護される必要がある
  • Overhead and Density:
    • 数千の関数を一つのハードウェアマシーン上で、少ない無駄で実行される必要がある
  • Performance(パフォーマンス):
    • 関数はネイティブに実行する場合と同等の性能で実行される必要がある。また、性能は一貫して同じハードウェア上の隣接する関数の挙動から影響を受けない必要がある
  • Compatibility(互換性):
    • Lambdaは任意のLinuxバイナリやライブラリを含む関数を許可し、これらはコード変更や再コンパイルなしでサポートされる必要がある
  • Fast Switching:
    • 直ぐに新しい関数を実行し、古い関数を直ぐに破棄する必要がある
  • Soft Allocation(ソフト割り当て):
    • CPU、メモリ、その他のリソースをオーバーコミットできる必要があり、それぞれの関数は割り当てられたリソースではなく、実際に必要なリソースのみを消費する必要がある

この要件を満たす技術として、AWSは独自の軽量仮想化技術であるMicroVMを開発するに至ったそうです。

Firecrackerとは何か

ここで、実装されることとなったのが、Firecrackerになります。Firecrackerは、MicroVMを実現するための仮想マシン管理ツールになります。

大きな特徴としては、

  1. Rust言語で開発されており、起動時間は約125ミリ秒と非常に高速
  2. 軽量設計により、1台のホスト上で非常に多くのmicroVMを実行可能
  3. 各microVMはサンドボックス化され、外部からの攻撃に対する高い堅牢性

この辺りになるかと思います。

実際にFirecrackerのコードはOSSとして公開されているので興味のある方はみてみてください。 GitHub - firecracker-microvm/firecracker

Firecrackerのアーキテクチャ

実際に自分も、興味が出てきたのでほんの少しだけ深ぼってみました。

全体のアーキテクチャを書き起こしてみました。

Firecrackerの全体アーキテクチャ

RestAPIを介して、Firecracker Orchestratorが、それぞれのリソースの管理を行うようになっているようです。

また、安全性確保のために三つの仕組みを備えています。

  • Seccompフィルタ
    Seccompは、Linuxカーネルの機能で、プロセスが使用できるシステムコールをが不要なシステムコールを制限します
  • Jailerプロセス
    cgroup・chrootのセットアップ、Firecrackerバイナリを非特権ユーザーとして実行します
  • Guest OS
    Linux KVM(Kernel-based Virtual Machine)を利用して、隔離された環境でゲストOSを動作させます ※ KVMはLinuxカーネルに組み込まれた仮想化技術で、Linux をハイパーバイザーとして動作させ複数の独立した仮想マシンを稼働させられます

これらの仕組みを使って、安全に分離されたSandBox環境を構築し、複数のプロセスやVMを効率的かつ安全に実行できるよう設計されおり、特にjailer barrierがホスト環境の保護を強化し、Virtualization BarrierがゲストOSの独立性を保証する役割を果たしている形です。

Firecracker microVMs

この辺りで、大枠のアーキテクチャは掴めてきたのではないでしょうか。

Firecrackerのコード解説とその仕組み

ここからは、Firecrackerのコードを読み解き、その起動時の動作を詳しく見ていきます。(ここは、MicroVMの仕組みというよりは、起動時にどのような流れで動作するのかについて詳しく解説します)

/src/firecracker/src/main.rs

api_server_adapter::run_with_api(
            &mut seccomp_filters,
            vmm_config_json,
            bind_path,
            instance_info,
            process_time_reporter,
            boot_timer_enabled,
            api_payload_limit,
            mmds_size_limit,
            metadata_json.as_deref(),
        )
        .map_err(MainError::RunWithApi)

run_with_apiをエントリーポイントとして、HttpServer の起動、EventManager の初期化、Seccompフィルタのセットアップなどの、MicroVMを立ち上げて動作させるために必要なセットアップを行います。

ここでは大きく分けて、以下の4つのステップで初期化が行われます。

  1. APIサーバの初期化
  2. microVMの構築と起動
  3. イベントループの管理
  4. メトリクス収集

(オブジェクト図を簡易的に作成してみました)

オブジェクト図

1. APIサーバの初期化

 let mut server = match HttpServer::new(&bind_path) {
  Ok(s) => s,
     Err(ServerError::IOError(inner)) if inner.kind() == std::io::ErrorKind::AddrInUse => {
            let sock_path = bind_path.display().to_string();
            return Err(ApiServerError::FailedToBindSocket(sock_path));
     }
     Err(err) => {
       return Err(ApiServerError::FailedToBindAndRunHttpServer(err));
     }
};

Unixドメインソケット(bind_path)を通じて、APIリクエストを受け付けるサーバを起動し、Firecrackerプロセスを操作するAPIスレッドを分離して実行します。

2. microVMの構築と起動

let build_result = match config_json {
        Some(json) => super::build_microvm_from_json(
            seccomp_filters,
            &mut event_manager,
            json,
            instance_info,
            boot_timer_enabled,
            mmds_size_limit,
            metadata_json,
        )
        .map_err(ApiServerError::BuildFromJson),
        None => PrebootApiController::build_microvm_from_requests(
            seccomp_filters,
            &mut event_manager,
            instance_info,
            &from_api,
            &to_api,
            &api_event_fd,
            boot_timer_enabled,
            mmds_size_limit,
            metadata_json,
        )
        .map_err(ApiServerError::BuildMicroVmError),
    };

    let result = build_result.and_then(|(vm_resources, vmm)| {
        firecracker_metrics
            .lock()
            .expect("Poisoned lock")
            .start(super::metrics::WRITE_METRICS_PERIOD_MS);

        ApiServerAdapter::run_microvm(
            api_event_fd,
            from_api,
            to_api,
            vm_resources,
            vmm,
            &mut event_manager,
        )
    });

ここでは、設定ファイルに基づいてmicroVMを作成しています。この、作成時にVMMスレッドからAPIスレッド・APIスレッドからVMMスレッドへのリクエストを転送するためのチャネルをここで定義しています。ここでは、API側からVMM側に操作を行って、そのレスポンスをVMM側からAPI側に返すために使われています。

3. イベントループの管理

loop {
        event_manager
            .run()
            .expect("Failed to start the event manager");

        match vmm.lock().unwrap().shutdown_exit_code() {
            Some(FcExitCode::Ok) => break,
            Some(exit_code) => return Err(RunWithoutApiError::Shutdown(exit_code)),
            None => continue,
        }
    }
    Ok(())

EventManagerは、監視対象のファイルディスクリプタ(FD)を登録し、イベントループで発生するイベントを検知して、登録されたEventSubscriberに通知するような仕組みです。

4. メトリクス収集:

パフォーマンスデータ(メトリクス)を定期的に収集し記録します。

let firecracker_metrics = Arc::new(Mutex::new(metrics::PeriodicMetrics::new()));
    event_manager.add_subscriber(firecracker_metrics.clone());
    
    
pub(crate) const WRITE_METRICS_PERIOD_MS: u64 = 60000;

上記のように、EventManagerにサブスクライバーとして登録され, 60秒ごとに通知されるように設定されています。

microVMの仕組み

ここまでで、Firecrackerの起動時の動作について理解できました。
では、最後にMicroVMはなぜ軽量なのか・どのように動作するのかについて紐解いていきます。
microVMの構築と起動には、VMMスレッドが利用されています。

The VMM thread exposes the machine model, minimal legacy device model, microVM metadata service (MMDS) and VirtIO device emulated Net, Block and Vsock devices, complete with I/O rate limiting.

このVMMスレッドはマシンモデル、最小限のレガシーデバイスモデル、マイクロVMメタデータサービス(MMDS)、およびVirtIOデバイスでエミュレートされたNet、Block、Vsockデバイス(I/Oレート制限を含む)を提供します。

MicroVMはなぜ軽量なのか

MicroVMの軽量性と高速性は大きく以下の5つによって実現されています。

  1. 起動プロセスでカーネルを直接ロード
  2. mmap による効率的なメモリ管理
  3. KVMを利用したvCPU管理
  4. Seccompで不要なシステムコールを制限
  5. 最小限のVirtIOデバイス

1. 起動プロセスでカーネルを直接ロード

MicroVMでは、BIOSやUEFIを使わず直接カーネルイメージを直接ゲストメモリにロードしています。

let mut kernel_file = boot_config
    .kernel_file
    .try_clone()
    .map_err(|err| StartMicrovmError::Internal(VmmError::KernelFile(err)))?;
#[cfg(target_arch = "x86_64")]
let entry_addr = Loader::load::<std::fs::File, GuestMemoryMmap>(
    guest_memory,
    None,
    &mut kernel_file,
    Some(GuestAddress(crate::arch::get_kernel_start())),
)
.map_err(StartMicrovmError::KernelLoader)?;

load_kernelの中で上記のようなコードを実行しており、カーネルファイルを準備してゲストメモリへカーネルを直接ロードしています。
これによりハードウェアの初期化やブートローダーの実行をMicroVM起動時に実行しないため起動の立ち上げを実現しているようです。

2. mmap による効率的なメモリ管理

let guest_memory = vm_resources
        .allocate_guest_memory()
        .map_err(StartMicrovmError::GuestMemory)?;

上記のコードによりメモリの割り当てを行っています。 このallocate_guest_memoryの中は以下のような実装になっています。

pub fn allocate_guest_memory(&self) -> Result<GuestMemoryMmap, MemoryError> {
        let vhost_user_device_used = self
            .block
            .devices
            .iter()
            .any(|b| b.lock().expect("Poisoned lock").is_vhost_user());

        // Page faults are more expensive for shared memory mapping, including  memfd.
        // For this reason, we only back guest memory with a memfd
        // if a vhost-user-blk device is configured in the VM, otherwise we fall back to
        // an anonymous private memory.
        //
        // The vhost-user-blk branch is not currently covered by integration tests in Rust,
        // because that would require running a backend process. If in the future we converge to
        // a single way of backing guest memory for vhost-user and non-vhost-user cases,
        // that would not be worth the effort.
        if vhost_user_device_used {
            GuestMemoryMmap::memfd_backed(
                self.vm_config.mem_size_mib,
                self.vm_config.track_dirty_pages,
                self.vm_config.huge_pages,
            )
        } else {
            let regions = crate::arch::arch_memory_regions(self.vm_config.mem_size_mib << 20);
            GuestMemoryMmap::from_raw_regions(
                &regions,
                self.vm_config.track_dirty_pages,
                self.vm_config.huge_pages,
            )
        }
    }

ここでは、mmapを利用しており、匿名メモリの割り当て・ページフォルトの削減などの仕組みを利用することで軽量性を実現しています。

3. KVMを利用したvCPU管理

let cpu_template = vm_resources.vm_config.cpu_template.get_cpu_template()?;

let (mut vmm, mut vcpus) = create_vmm_and_vcpus(
    instance_info,
    event_manager,
    guest_memory,
    None,
    vm_resources.vm_config.track_dirty_pages,
    vm_resources.vm_config.vcpu_count,
    cpu_template.kvm_capabilities.clone(),
)?;

FirecrackerではKVMを使用してvCPUを管理しており、create_vmm_and_vcpusでゲストOSに割り当てるvCPUをKVM上で作成しています。
これによってCPUのエミュレーションが不要になり、Qemuを利用を避けられ軽量に仮想化が可能になっています。

4. Seccompで不要なシステムコールを制限

seccompiler::apply_filter(
    seccomp_filters
        .get("vmm")
        .ok_or_else(|| MissingSeccompFilters("vmm".to_string()))?,
)
.map_err(VmmError::SeccompFilters)
.map_err(Internal)?;

ここで、必要最低限のシステムコールだけを許可することで、リソース消費を削減し、
セキュリティリスクを軽減しています。

{
    "vmm": {
        "default_action": "trap",
        "filter_action": "allow",
        "filter": [
            {
                "syscall": "stat",
                "comment": "Used when creating snapshots in vmm:persist::snapshot_memory_to_file through std::fs::File::metadata"
            },
            {
                "syscall": "epoll_ctl"
            },
            {
                "syscall": "epoll_pwait"
            },
            {
                "syscall": "exit"
            },
            {
                "syscall": "exit_group"
            },
            {
                "syscall": "open"
            },
            {
                "syscall": "read"
            },
....

5. 最小限のVirtIOデバイス

VmResourcesの生成時に以下でVirtIOデバイスを登録しており、その時に、最低限のNext、Block、Vsockのみをサポートすることで高速な動作を実現しています。
※ VirtIOは仮想マシン(ゲストOS)とホストOS間でデータ転送を効率的に行うための準仮想化フレームワークです。

block: default_blocks(),
net_builder: default_net_builder(),
vsock: Default::default(),

まとめ

以上で、Firecrackerの全体的な仕組みと動作の流れについて、概要を掴んでいただけたのではないでしょうか。実際にコードを見ることで学べることが多く、刺激を受けることができました。
私自身、業務外でもOSSへのコントリビュートなどを通じて最新技術に触れ、スキルを磨き続けていきたいと考えています。
この記事が皆さんにとって新たな技術への興味を引き出すきっかけとなれば幸いです。

読んでいただきありがとうございました!

We Are Hiring!

ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方やインターンシップのエントリーもお待ちしております!)

careers.abejainc.com

特に下記ポジションの募集を強化しています!ぜひ御覧ください!

プラットフォームグループ:シニアソフトウェアエンジニア | 株式会社ABEJA

トランスフォーメーション領域:ソフトウェアエンジニア(リードクラス) | 株式会社ABEJA

トランスフォーメーション領域:データサイエンティスト(シニアクラス) | 株式会社ABEJA