🤖

ローカル開発環境を整備すると開発効率が上がる

2022/09/25に公開約12,900字

当たり前のことですね。
ただ、当たり前のことを実践するのは意外と難しい。

難しさの原因は、AWSやGCPなどのクラウド固有のサービスがローカル環境でのエミュレートに対応してないためだ。

私も昔は、Lambda最高!!ServerlessFrameworkでFaasがいけてる!しかも、常時稼働する必要のないAPIやちょっとしたバッチ処理の場合はコストがめっちゃ安い!!
なんておもってました。

以下個人開発の時の話です。個人開発と言っても自分の開発力を高めるための割とちゃんとしたsandboxなので、本当に個人開発やりたい人はrowy x flutterflowとかで爆速開発してほしい。

開発速度の低下

ただ、開発を進めていくうちになんだか開発速度が落ちていってることに気づきました。
私が個人で開発しているときは、基本はデプロイして動作確認をしてました。AWSであればCodeBuild、GCPであればCloud Build、あとはGithub Actionでデプロイしてました。

Build時間は割と長い

CI/CDなんかは、割とシュッと作れるのは作れます。ただ、Build時間はかなり長いです。ローカルである程度動作確認しても、どうしてもクラウドベンダーに依存したマネージドサービスとの連携を確かめるときはひとつ一つの昨日の確認に時間がかかってました。

クラウドベンダー依存のマネージドサービスのテストは難しい。

一例としてLamdbaを使った開発についてちょっとだけ小言を言わせてください。
LamdbaはAWS上のいろんなサービスをトリガーに実行することができます。

ただ、AWS上のサービスはローカルでエミュレートできるものもあればできないものもあります。
DynamoDBやAPIGatewayあたりであれば、ローカルでうまく対応することができるはずです。

ServerlessFrameworkやAWS SAMといったLambdaの開発用のライブラリを使っていたらローカル実行も簡単です。ユニットテストであれば、さほど問題なくテストできます。

では結合テストは、どうするのでしょうか?
まずは、Lamddaの立ち上げをして必要であればDockerなどでデータベースのインスタンスを立ち上げて、Dockerを同一のネットワークに配置してローカルのLambdaから接続可能にして、その後にテストコードを実行します。
同じようなことは別にRailsだって、Nestjsだった必要なのでさほど面倒じゃないと思うかもしれません。
ただ、私にとってストレスだったのは、

  • ローカルでemulateできないAWSのマネージドサービスをトリガーしている場合にどこまでをテストの対象にするかが関数によってまちまちになる
  • ローカルでエミュレートできるサービス自体も完全にクラウドの仕様を完全に反映できているわけではない
  • クラウドサービスをローカルで立ち上げるのは、そのためにいろんなツールの知識を覚えていかなければいけない。

といったことが地理に積もって私の開発生産性に大きな打撃を与えていました。

このようにshellscriptを書いてテスト実行させてました。

  "scripts": {
    "test:integrate": "./scripts/integrate.sh"
  },

./scripts/integrate.shには、https://www.npmjs.com/package/start-server-and-test このmoduleを使ったwait処理とか終了時のprocessのkillとか書いてました。

そう言ったこともあり、最近はローカルで環境を構築しやすい技術スタックを採用しています。

ローカルで環境を構築しやすい技術探し

データベース

使ったことのある技術
DynamoDB、PostgreSQL、MySQL、Redis、Memcache、Firestoreなどです。

ローカルで構築しやすいものはPostgreSQL、MySQL、Rdis、Memcache。
Firestore、DynamoDBも割とローカル環境は成熟してきていますが、
実際にやると大体はまりどころがあるので個人的には避けたいです。

データベースのstream

最近はRDBをlogicalでコーディングして扱うことも多くなってきました。

https://zenn.dev/makumattun/articles/0df57b5ad3f9f0

Debezium ServerもしくはKafkaを使って、Stream処理のローカル環境を構築するのがベストだと言うふうになりました。パフォーマンスの問題もあるので、どのinsert,update, deleteなどの操作と対象のテーブルは最低限にしておくと無題にイベントが発火されなくていいかもしれません。

EventBus(TopicExchangeの機能を持つQueue)

AWSであれば、FIFOのSNSとFIFOのSQSをローカル用に立ち上げます。ステートフルのデータベースと違って、ステートレスなのでローカル専用に立ち上げることで問題ないと判断しました。重複削除を有効にしていると、誰かのローカル実行とコンフリクトするかもしれませんが。。。
そのときは、ローカルだけ重複削除を無効にしてもいいかもしれません。

ちなみにGCPであれば、Cloud Pub/Subです。AWSのSNS、SQSとの違いは、Exactly Onceとイベントの順序を厳密に処理することができないのが違い。
ただ、私はDebeziumServerを使いたいのでCloud Pub/Subを使用して、厳密に順序を保障したい場合とExactlyOnceのSubscribeを分けたらいいと思っています。

EventBusのSubscriber

CloudPubSubやSQSのSubScriberはNestJsで処理をするようにしています。LambdaやCloudFunctionsは極力使わないです。(サービスを構成するものではなく、DWHやマーケティングのためのデータ整形であればその限りではないですが、サービスを構成する場合はローカル開発の進めやすさを考慮してNestJsしか使わないです)

SQSの場合は、複数のSQSをsubscribeしたかったので、OSSにちょっと手を加えた自前のコードを使ってます。
動けばよかったのでめっちゃ雑。。。

https://github.com/GemunIon/nestjs-sqs/compare/master...katakatataan:nestjs-sqs:master

Cloud Pub/Subの場合はこちらを使用しています。

https://www.npmjs.com/package/@algoan/pubsub

ただ、スケールアウトさせる場合はもっと慎重に考えないといけないです。厳密な順序での配信を求められているのに、前の処理が終わる前に別のサーバーがイベントを取得してしまった場合のハンドリングです。

outboxパターンを使っている場合はRDBなどでトランザクション処理するときに正確に順番が記載されているはずなのでそれいいと思ってます。

重複削除に関してはRedisを使って一意になるキーをつかって勧告ロック(簡単)を実装したら良さそうです。

認証 認可

これは非常に悩ましい問題でした。私はフロントエンドはNext.jsを使用しているので、シンプルに考えたらnext-authを採用するべきでした。
すみません。firebase使ってしまいました。
今思うとnext-authの方が絶対によかった。ちょっと後悔してます。

要件1. 認証には、絶対cookie sessionにしたい。だってそっちの方が簡単なんだもん。

  • firebase○
  • nextauth○

要件2. jwtにroleを含めたい。かつjwtはcookieに持ちたい。

  • firebase❌
  • nextauth○

要件3. ローカル開発がしやすい

  • firebase❌
  • nextauth○

firebaseのユーザーのデータって結局自前のデータに複製しないとやっていけないんですが、cloudfunctionでtriggerしてデータベースに入れるしかないです。なので、非常にローカル開発がやりにくい。

結局firebaseのmultitenantの機能を使って、ローカル開発環境でもユーザー作成のtrigger画できるように対応しました。

firebaseはv9系のmoduleを使用しています。

まずは初期化するときにローカル用のtenantをセットします。

      const auth = getAuth(app);
      if (isLocal()) {
        auth.tenantId = 'your-local-tenant-id';
      }

triggerするcloudfunctionの処理で、pubsubを分岐します。

import {PubSub} from '@google-cloud/pubsub';

// Creates a client; cache this for further use

export const main = async (user: any) => {
  const pubsub = new PubSub({});

  try {
    if (user.tenantId && user.tenantId == 'your-local-tenant-id') {
      const topic = pubsub.topic('local.delete');
      await topic.publishMessage({
        json: {
          ...user,
        },
      });
    } else {
      const topic = pubsub.topic('production.delete');
      await topic.publishMessage({
        json: {
          ...user,
        },
      });
    }
  } catch (error) {
    console.log(error);
  }
};

pubsubのsubscriptionも環境変数の中身で対象のtopicを分岐させます。
nestjsのコードになります。

  @EventPattern(
    process.env.DEVELOPMENT_MODE + '.' + 'user.signup',
  )
  public async handleSinUp(
    @Payload() data: EmittedMessage<UserRecord>,
  ): Promise<void> {
    console.log(data);
  }

検索系の処理

algoria elasticsearch。これくらいしか知りませんが。ローカル開発のしやすさだけでelasticsearchを選択。

ローカル環境の構築

といってもdocker-composeを載せるだけ。

 redis:
    build:
      context: ./extra_modules/redis
      dockerfile: Dockerfile
    container_name: redis
    environment:
      PASSWORD: ${UPSTASH_REDIS_PASSWORD}
    restart: "always"
    volumes:
      - redis_data:/data
    env_file:
      - .env
    ports:
      - "6379:6379"

  debezium:
    build:
      context: ./extra_modules/debezium
      dockerfile: Dockerfile
      args:
        - UPSTASH_REDIS_URL=${UPSTASH_REDIS_URL}
        - UPSTASH_REDIS_PASSWORD=${UPSTASH_REDIS_PASSWORD}
        - PUBSUB_TOPIC_ALL=${PUBSUB_TOPIC_ALL}
        - DEBEZIUM_TABLE_LIST=${DEBEZIUM_TABLE_LIST}
        - DEBEZIUM_ROUTING_TO_TOPICL_ALL_REGEXP=${DEBEZIUM_ROUTING_TO_TOPICL_ALL_REGEXP}
        - POSTGRES_HOST=postgres
        - POSTGRES_PORT=${POSTGRES_PORT}
        - POSTGRES_USER=${POSTGRES_USER}
        - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
        - POSTGRES_DATABASE=${POSTGRES_DATABASE}
        - UPSTASH_REDIS_SSL=${UPSTASH_REDIS_SSL}
        - SERVER_NAME=${SERVER_NAME}
    container_name: debezium
    environment:
      GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS}
    depends_on:
      - redis
      - postgres
    volumes:
      - ./service_account.json:${GOOGLE_APPLICATION_CREDENTIALS}
    env_file:
      - .env
    ports:
      - "3006:8080"

  hasura:
    build:
      context: ./packages/hasura
      dockerfile: Dockerfile
    container_name: hasura
    env_file:
      - .env
    restart: always
    environment:
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
      HASURA_GRAPHQL_AUTH_HOOK_MODE: ${HASURA_GRAPHQL_AUTH_HOOK_MODE}
      HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET}
      HASURA_GRAPHQL_DATABASE_URL: ${HASURA_GRAPHQL_DATABASE_URL}
      HASURA_GRAPHQL_AUTH_HOOK: ${HASURA_GRAPHQL_AUTH_HOOK}
    ports:
      - "8080:8080"
    depends_on:
      - debezium
    volumes:
      - ./packages/hasura/configuration/migration:/hasura-migrations
      - ./packages/hasura/configuration/metadata:/hasura-metadata

  # flyway:
  #   image: flyway/flyway:9.1.2
  #   # command: -configFiles=/flyway/conf/flyway.config -locations=filesystem:/flyway/sql -connectRetries=60 migrate
  #   command:  migrate
  #   environment:
  #     FLYWAY_URL: jdbc:postgresql://postgres:${POSTGRES_PORT}/${POSTGRES_DATABASE}
  #     FLYWAY_USER: ${POSTGRES_USER}
  #     FLYWAY_PASSWORD: ${POSTGRES_PASSWORD}
  #     FLYWAY_LOCATIONS: filesystem:/flyway/sql
  #     FLYWAY_CLEAN_DISABLED: "false"
  #     # FLYWAY_INIT_SQL: ${{ inputs.initSql }}
  #   env_file:
  #     - .env
  #   volumes:
  #     - ./extra_modules/database/:/flyway/sql
  #     # - ${PWD}/database/docker-flyway.config:/flyway/conf/flyway.config
  #   depends_on:
  #     - postgres

  postgres:
    build:
      context: ./extra_modules/database
      dockerfile: Dockerfile
      args:
        - DB_LANG=ja_JP
    restart: always
    container_name: postgres
    command: -c 'config_file=/etc/postgresql/my-postgres.conf' # 追加
    env_file:
      - .env
    ports:
    - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
    environment:
      POSTGRES_DB: ${POSTGRES_DATABASE}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - ./extra_modules/database/my-postgres.conf:/etc/postgresql/my-postgres.conf # 追加
      - ./extra_modules/database/data:/var/lib/postgresql/data/pgdata

  setup:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.3.3
    container_name: setup
    volumes:
      - ./extra_modules/elasticsearch/certs:/usr/share/elasticsearch/config/certs
    user: "0"
    command: >
      bash -c '
        if [ ! -f config/certs/ca.zip ]; then
          echo "Creating CA";
          bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
          unzip config/certs/ca.zip -d config/certs;
        fi;
        if [ ! -f config/certs/certs.zip ]; then
          echo "Creating certs";
          echo -ne \
          "instances:\n"\
          "  - name: elasticsearch\n"\
          "    dns:\n"\
          "      - elasticsearch\n"\
          "      - localhost\n"\
          "    ip:\n"\
          "      - 127.0.0.1\n"\
          > config/certs/instances.yml;
          bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
          unzip config/certs/certs.zip -d config/certs;
        fi;
        echo "Setting file permissions"
        chown -R root:root config/certs;
        find . -type d -exec chmod 750 \{\} \;;
        find . -type f -exec chmod 640 \{\} \;;
        echo "Waiting for Elasticsearch availability";
        until curl -s --cacert config/certs/ca/ca.crt https://elasticsearch:9200 | grep -q "missing authentication credentials"; do sleep 30; done;
        echo "Setting kibana_system password";
        until curl -s -X POST --cacert config/certs/ca/ca.crt -u elastic:password -H "Content-Type: application/json" https://elasticsearch:9200/_security/user/kibana_system/_password -d "{\"password\":\"password\"}" | grep -q "^{}"; do sleep 10; done;
        echo "All done!";
      '
    healthcheck:
      test: ["CMD-SHELL", "[ -f config/certs/elasticsearch/elasticsearch.crt ]"]
      interval: 10s
      timeout: 10s
      retries: 120

  elasticsearch:
    depends_on:
      setup:
        condition: service_healthy
    container_name: elasticsearch
    build:
      context: ./extra_modules/elasticsearch
      dockerfile: Dockerfile
    volumes:
      - ./extra_modules/elasticsearch/certs:/usr/share/elasticsearch/config/certs
      - ./extra_modules/elasticsearch/data:/usr/share/elasticsearch/data:rw
    ports:
      - 9200:9200
    environment:
      # - ES_JAVA_OPTS="-Xms1g -Xmx1g"
      - ELASTIC_PASSWORD=password
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=false
      - xpack.security.http.ssl.key=certs/elasticsearch/elasticsearch.key
      - xpack.security.http.ssl.certificate=certs/elasticsearch/elasticsearch.crt
      - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.http.ssl.verification_mode=certificate
      - discovery.type=single-node
      - network.host=0.0.0.0
    ulimits:
      memlock:
        soft: -1
        hard: -1
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'",
        ]
      interval: 10s
      timeout: 10s
      retries: 120

  server:
    depends_on:
      - postgres
    container_name: server
    build:
      context: extra_modules/server
      dockerfile: Dockerfile.local
    ports:
      - 3001:3000
    env_file:
      - .env
    environment:
      - POSTGRESQL_URL=${POSTGRES_URL}
      - POSTGRES_PORT=${POSTGRES_PORT}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - TWI_HIKA_FIREBASE_ADMIN_JSON=${TWI_HIKA_FIREBASE_ADMIN_JSON}
      - BCRYPT_SALT=${BCRYPT_SALT}
      - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
      - JWT_SECRET_KEY=${JWT_SECRET_KEY}
      - JWT_EXPIRATION=${JWT_EXPIRATION}


  kibana:
    depends_on:
      - elasticsearch
    container_name: kibana
    image: docker.elastic.co/kibana/kibana:8.3.3
    volumes:
      - ./extra_modules/elasticsearch/certs:/usr/share/kibana/config/certs
    ports:
      - 5601:5601
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=password
      - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt

frontendのローカル開発環境

自分はmonorepoでnextjsを使用しています。nextjsに関してはほとんど話すことないので一旦省略します。
これでもかって言うくらい準備しすぎたので。。。。

まとめ

ローカル環境は整備することで、その後の開発の時間の削減に役立つので気合やれてやった方がいいと感じました。そしてローカル環境を構築しやすい技術スタック大事。ベンダーロックインでの開発やりづらみ。

Discussion

ログインするとコメントできます