😎

Strapi v4をAmazon ECSで動かす

2022/01/27に公開

はじめに

オープンソースHeadless CMSのStrapiのv4をAmazon ECSで動かしてみます。v3まではDockerイメージが公式に提供されていたのですが、現状v4向けのイメージが提供されておらず手間取ったのでブログにまとめることにしました。コンテナで動かすと画像などのファイルを外部に保存する必要があります。今回は@strapi/provider-upload-aws-s3という保存先をS3バケットに変更する公式プラグインを利用しました。

AWSサービスの構築

Strapiを動かす為に必要なAWSサービスをAWS CDKを使って構築します。

プロジェクトディレクトリを作成して、CDKのコードを初期生成します。

cdk-strapi-on-ecs && cd $_
npx cdk@2 init app --language typescript ./

エントリーポイントとスタックのコードを書きます。

bin/cdk-strapi-on-ecs.ts
#!/usr/bin/env node

import 'source-map-support/register';
import {App} from 'aws-cdk-lib';
import {CdkStrapiOnEcsStack} from '../lib/cdk-strapi-on-ecs-stack';

const app = new App();
new CdkStrapiOnEcsStack(app, 'CdkStrapiOnEcsStack');

スタックもサクッとシングルスタックで作成してしまいます。

lib/cdk-strapi-on-ecs-stack.ts
import {Stack, StackProps} from 'aws-cdk-lib';
import {
  InstanceClass,
  InstanceSize,
  InstanceType,
  Vpc,
} from 'aws-cdk-lib/aws-ec2';
import {
  ContainerImage,
  FargateTaskDefinition,
  Secret,
} from 'aws-cdk-lib/aws-ecs';
import {ApplicationLoadBalancedFargateService} from 'aws-cdk-lib/aws-ecs-patterns';
import {
  DatabaseInstance,
  DatabaseInstanceEngine,
  PostgresEngineVersion,
} from 'aws-cdk-lib/aws-rds';
import {Bucket, BucketAccessControl} from 'aws-cdk-lib/aws-s3';
import {Construct} from 'constructs';
import {resolve} from 'path';

export class CdkStrapiOnEcsStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new Vpc(this, 'Vpc');

    const db = new DatabaseInstance(this, 'Database', {
      vpc,
      engine: DatabaseInstanceEngine.postgres({
        version: PostgresEngineVersion.VER_13_4,
      }),
      instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MICRO),
      databaseName: 'strapi',
      multiAz: false,
    });
    if (db.secret === undefined) {
      throw new Error('DatabaseInstance.secret is undefined');
    }

    const bucket = new Bucket(this, 'Bucket', {
      accessControl: BucketAccessControl.PUBLIC_READ,
    });

    const taskDefinition = new FargateTaskDefinition(this, 'TaskDef');
    bucket.grantReadWrite(taskDefinition.taskRole);
    bucket.grantPutAcl(taskDefinition.taskRole);

    const container = taskDefinition.addContainer('Strapi', {
      image: ContainerImage.fromAsset(resolve(__dirname, '../strapi')),
      environment: {
        DATABASE_NAME: 'strapi',
        AWS_REGION: this.region,
        AWS_BUCKET_NAME: bucket.bucketName,
      },
      secrets: {
        DATABASE_HOST: Secret.fromSecretsManager(db.secret, 'host'),
        DATABASE_PORT: Secret.fromSecretsManager(db.secret, 'port'),
        DATABASE_USERNAME: Secret.fromSecretsManager(db.secret, 'username'),
        DATABASE_PASSWORD: Secret.fromSecretsManager(db.secret, 'password'),
      },
    });
    container.addPortMappings({containerPort: 1337});

    const {service} = new ApplicationLoadBalancedFargateService(
      this,
      'Service',
      {vpc, taskDefinition}
    );
    db.connections.allowDefaultPortFrom(service);
  }
}

Strapi Dockerイメージの作成

プロジェクトルートでStrapiのコードを初期生成します。

npx create-strapi-app@latest strapi --quickstart 
## プロセスが立ち上がったらCtrl + Cで終了してください

必要なライブラリをインストールし、ビルドはnode_modules上で行うので不要なnode_modulesを削除します。

cd strapi
yarn add @strapi/provider-upload-aws-s3 pg
rm -rf node_modules

データベースにはPostgreSQLを使用する為、接続情報を環境変数から取得するように設定を変更します。

strapi/config/database.js
module.exports = ({env}) => ({
  connection: {
    client: 'postgres',
    connection: {
      host: env('DATABASE_HOST', 'postgres'),
      port: env.int('DATABASE_PORT', 5432),
      database: env('DATABASE_NAME', 'strapi'),
      user: env('DATABASE_USERNAME', 'strapi'),
      password: env('DATABASE_PASSWORD', 'strapi'),
      ssl: env.bool('DATABASE_SSL', false),
    },
  },
  debug: false,
});

静的コンテンツをS3バケットに保存するためにプラグインの設定を行います。

strapi/config/plugins.js
module.exports = ({ env }) => ({
  upload: {
    config: {
      provider: 'aws-s3',
      providerOptions: {
        region: env('AWS_REGION'),
        params: {
          Bucket: env('AWS_BUCKET_NAME'),
        },
      },
    },
  },
});

静的コンテンツがS3バケットのドメイン名となりStrapiと別ドメインになるので、コンテンツセキュリティポリシーの為の設定を行います。middlewaresでは環境変数を取得出来ないため、S3バケットのドメイン名ワイルドカードで緩く指定しています。より厳密に制御したい場合はバケット名を指定して作成してください。

strapi/config/middlewares.js
module.exports = [
  'strapi::errors',
  'strapi::security',
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::logger',
  'strapi::query',
  'strapi::body',
  'strapi::favicon',
  'strapi::public',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'connect-src': ["'self'", 'https:'],
          'img-src': ["'self'", 'data:', 'blob:', '*.s3.ap-northeast-1.amazonaws.com'],
          'media-src': ["'self'", 'data:', 'blob:', '*.s3.ap-northeast-1.amazonaws.com'],
          upgradeInsecureRequests: null,
        },
      },
    },
  },
];

Dockerfileを作成します。Strapiは扱うコンテンツの定義を追加したい場合は、ローカル環境で立ち上げWebブラウザから追加操作をした時に生成されるコードを環境に追加する必要があります。なので、ローカルでDocker上でStrapiを立ち上げた時にディレクトリをマウントする必要があります(strapi:/opt/app/strapi)。マウントするとnode_modulesが参照出来なくなってしまいます。なので、Dockerイメージを作成するタイミングでnode_modulesをマウントされるディレクトリとは別のディレクトリ(/opt/node_modules)に作成しておきます。

strapi/Dockerfile
FROM node:16-alpine

ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

WORKDIR /opt/
COPY package.json yarn.lock ./
ENV PATH /opt/node_modules/.bin:$PATH

RUN yarn config set network-timeout 600000 -g
RUN yarn install

WORKDIR /opt/app
COPY ./ .
RUN yarn build

EXPOSE 1337
CMD ["yarn", "start"]

ローカルで立ち上げるためのDocker Composeの設定

プロジェクトルートにDocker Composeファイルを作成します。データベースにPostgreSQLを使うように設定を変えているので、DockerでPostgreSQLを立ち上げます。
また、コンテンツを追加する際はstrapi developで立ち上げる必要があるので、コマンドをyarn developに上書きします。

docker-compose.yml
version: '3'
services:
  strapi:
    container_name: strapi
    command: yarn develop
    build: strapi
    environment:
      DATABASE_NAME: strapi
      DATABASE_HOST: postgres
      DATABASE_PORT: 5432
      DATABASE_USERNAME: strapi
      DATABASE_PASSWORD: strapi
      NODE_ENV: development
    volumes:
      - ./strapi:/opt/app
    ports:
      - '1337:1337'
    depends_on:
      - postgres

  postgres:
    image: postgres:14.1
    container_name: postgres
    environment:
      POSTGRES_USER: strapi
      POSTGRES_PASSWORD: strapi
    volumes:
      - ./data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

デプロイ

最後にデプロイして完了です。

npm run cdk deploy

あとがき

マウントするディレクトリからnode_modulesを逃がすという発想が浮かばず結構ハマりました。またmiddlewaresで環境変数が使えないポイントもワイルドカードが使えるかもと気づくまで結構時間がかかりました。

いわゆるJamstackは運用負荷が少ないところが大きな利点だと思っており、自分でHeadless CMSを建ててしまうとRDBMSのバージョンアップなどなど発生するので、極力SaaSに寄せたいなーと今更ながら思いました。

今回のコード全体はこちらに公開しています。https://github.com/intercept6/cdk-strapi-on-ecs

Discussion