ECS Fargate上で動かしたRails 8アプリのデータベースにEFSにおいたSQLiteを指定する

先日、以下のブログ記事を拝読しました。

note.com

昔、ちょっと試した時には遅くて開発環境しか使えないなという印象を持っていたのですが、Rails 8になってだいぶ変わったようです。

そこで、ECS FargateにてRails 8アプリを動かし、データベースとしてEFS上に置いたSQLiteという構成を取ってみようと思い立ち、実装してみました。

Rails 8アプリの実装

Rails 8アプリの実装に特別注意することはありません。ECSで動かす場合はいくつかの環境変数を設定すればOKです。以下の記事はRails 7.2の場合ですが、やることは特に変わりません。

miyohide.hatenablog.com

今回、Production環境にてデータベースをSQLiteにするので、config/database.ymlの設定を以下のようにします。

(省略)
production:
  <<: *default
  database: /mnt/efs/production.sqlite3

AWSの設定(CDK)

AWSの環境はCDKにて実装します。Railsを動かす際の基本的な実装は以下のブログ記事に記しています。

miyohide.hatenablog.com

また、EFSをマウントする際の基本的な実装は以下のブログ記事に記しています。

miyohide.hatenablog.com

基本はこれらを組み合わせればできるのですが、細かい注意点があるのでEFS周りを中心に取り上げて解説します。

今回はEFSを使うので、作成しておきます。

    const fileSystem = new FileSystem(this, 'MyEfsFileSystem', {
      vpc: vpc,
      encrypted: true,
      removalPolicy: RemovalPolicy.DESTROY,
      lifecyclePolicy: LifecyclePolicy.AFTER_14_DAYS,
      performanceMode: PerformanceMode.GENERAL_PURPOSE,
      throughputMode: ThroughputMode.BURSTING
    });

ポイントはアクセスポイントの作成です。

    const accessPoint = new AccessPoint(this, 'EFSAccessPoint', {
      fileSystem: fileSystem,
      path: '/mnt/efs',
      posixUser: {
        uid: '1000',
        gid: '1000'
      },
      createAcl: {
        ownerGid: '1000',
        ownerUid: '1000',
        permissions: '755'
      },
    });

このアクセスポイントの指定はRails 8が生成してくれるDockerfileの以下の指定に起因するものです。

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

アクセスポイントの指定がないと、Rails 8アプリがUID/GIDがそれぞれ1000のユーザーでファイルを書き込みにいく一方で/mnt/efsがrootユーザーしか書き込めないことになります。結果として、ECSサービスの起動時にSQLite3が作成できずに起動に失敗します。

pathSQLiteファイルを置く場所としてconfig/database.yamlで指定した場所と合わせてください。

作成したEFSとアクセスポイントはタスク定義にて指定しておきます。メモリやCPUは適宜調整してください。以下の例ではコンテナイメージをM2 mac上で作ったのでcpuArchitectureARM64`になっていますが、こちらも適宜修正してください。

    const taskDef = new FargateTaskDefinition(this, "MyTaskDef", {
      memoryLimitMiB: 512,
      cpu: 256,
      runtimePlatform: {
        operatingSystemFamily: OperatingSystemFamily.LINUX,
        cpuArchitecture: CpuArchitecture.ARM64
      },
      volumes: [
        {
          name: "dbfile",
          efsVolumeConfiguration: {
            fileSystemId: fileSystem.fileSystemId,
            authorizationConfig: {
              accessPointId: accessPoint.accessPointId,
              iam: "ENABLED"
            },
            transitEncryption: "ENABLED"
          }
        }
      ]
    });

あとはコンテナ定義とマウントポイントを作成してあげます。

    const containerDef = new ContainerDefinition(this, 'MyContainerDefinition', {
      image: ContainerImage.fromRegistry('使用するコンテナイメージ名'),
      logging: LogDrivers.awsLogs({ streamPrefix: 'myrailsecs', logRetention: RetentionDays.ONE_DAY }),
      taskDefinition: taskDef,
      environment: {
        RAILS_ENV: "production",
        RAILS_LOG_TO_STDOUT: "1",
        RAILS_SERVE_STATIC_FILES: "1",
        RAILS_MASTER_KEY: this.node.tryGetContext("RAILS_MASTER_KEY")
      }
    });

    containerDef.addMountPoints(
      {
        containerPath: '/mnt/efs',
        sourceVolume: 'dbfile',
        readOnly: false
      }
    );

最後にセキュリティグループの設定です。ECSサービスからEFSに対して接続を許可してあげます。

    fileSystem.connections.allowDefaultPortFrom(fargateService.connections);

これでCDKの実装はおしまいです。

動作

これでRails 8+SQLiteを使ったアプリケーションを動かすことができました。

ですが、少し動作確認をしたところ、以下のエラーメッセージを時折出力することに気がつきました。タイミングはデータベースへの書き込みタイミング。

database disk image is malformed

どうもSQLiteのデータベースファイルが壊れたという意味のようで、実際、Railsアプリでは500エラーが返ってきます。ただ、その後読み込み処理を行ったところ正常にデータは読み込めたのでちょっと不思議です。

よくよく調べてみるとSQliteのFAQに以下のものがありました。SQLiteはロック機構をOSの機能に依存しているため、NFSを使った場合はうまくいかないことが多いとのこと。結果、今回の構成は非推奨のような気がします。

sqlite.org

考察

以上を踏まえると、SQLiteをEFS上に置いてデータベースの代わりにするのはちょっと危険な気がします。この構成が取れたら、RDSを使わずにコスト削減ということも考えられたのですが、そう簡単にはいかないように思えます。