Kinstaでは、アプリケーションホスティング、データベースホスティング、マネージドWordPressホスティングのあらゆる規模のプロジェクトを扱っています。

Kinstaのクラウドホスティングソリューションでは、NodeJS、PHP、Ruby、Go、Scala、Pythonなどの多くの言語やフレームワークでアプリケーションをデプロイ可能です。Dockerファイルがあれば、どんなアプリケーションでもデプロイすることができます。コードをKinstaに直接デプロイするには、Gitリポジトリ(GitHub、GitLab、またはBitbucketでホスト)に接続します。

MariaDB、Redis、MySQL、PostgreSQLといったデータベースを簡単にホストし、ホスティングに関わる設定に悩まされることなく、アプリケーションの開発に専念可能です。

また、WordPress専用マネージドホスティングを選択すると、Google Cloud C2マシン、プレミアムティアネットワーク、さらにCloudflare統合によるセキュリティをフル活用し、WordPressサイトを高い性能で運営することができます。

分散チームにおけるクラウドネイティブアプリケーション開発

企業レベルでクラウドネイティブアプリケーションを開発・保守する際の最大の課題の1つは、開発ライフサイクル全体を通じて一貫した利便性を確保することです。これは、複数のプラットフォーム、多種多様なセットアップ、非同期通信で作業する分散チームを抱えるリモート企業ではさらに困難になります。そんな中でも、一貫性があり、信頼性が高く、スケーラブルなソリューションを確保する必要があります。具体的には以下が滞りなく進められる状態が求められます。

  • 開発者と品質保証チームが、オペレーティングシステムに関係なく、簡単かつ最小限のセットアップで機能の開発とテストを行うことができる
  • DevOps、SysOps、およびインフラチームが、ステージング環境と本番環境の設定と保守を行うことができる

Kinstaでは、開発から本番まで、すべてのステップでこの一貫した利便性を実現するために、Dockerに大きく依存しています。この記事では、Dockerを軸にして、以下についてご説明したいと思います。

  • Docker Desktopを活用して開発者の生産性を引き上げる方法
  • CircleCIとGitHub Actionsを使ったCIパイプラインでDockerイメージをビルドし、Google Container Registryにプッシュする方法
  • Dockerイメージ、Google Kubernetes Engine、Cloud Deployを使用して、CDパイプラインを経て本番環境へのインクリメンタルな変更を促進する方法
  • QAチームが様々な環境でシームレスにビルド済みのDockerイメージを使用する方法

Docker Desktopの使用による開発者体験の向上

アプリケーションをローカル環境で実行するためには、綿密に環境を準備、すべての依存関係をインストール、サーバーとサービスをセットアップし、それを丁寧に設定する必要があります。複数のアプリケーションを実行する時、特に複数の依存関係を持つ複雑なプロジェクトになると、これは面倒な作業になり得ます。これに複数のオペレーティングシステムを持つスタッフが何人も介入すると、その先にあるのは往々にして混乱です。Kinstaでは、それを防ぐためにDockerを使っています。

Dockerを使えば、環境設定を宣言、依存関係をインストールし、すべてをあるべき場所に配置した状態でイメージをビルドすることができます。誰でも、どこでも、どんなOSでも、同じイメージを使うことができ、まったく同じ環境が確保可能です。

Docker Composeで環境設定を宣言する

利用を開始するには、Docker Composeファイル(docker-compose.yml)を作成します。YAML形式で記述する宣言的な設定ファイルで、Dockerにアプリケーションの望ましい状態を伝える役割を果たします。この情報を使って、Dockerがアプリケーションの環境をセットアップすることになります。

Docker Composeファイルは、複数のコンテナを実行していて、コンテナ間に依存関係がある場合に非常に便利です。

docker-compose.yml

  1. まず、アプリケーションのベースとしてimageを選択することから始めます。Docker Hubで検索し、好みのアプリの依存関係を含むDockerイメージを探します。エラーを避けるために、必ず特定のイメージタグを使用してください。latestタグを使用すると、アプリケーションに予期せぬエラーが発生する可能性があります。複数の依存関係に複数のベースイメージを使用することができます。たとえば、PostgreSQL用とRedis用です。
  2. 必要であれば、データの永続化のためにvolumesを使うことができます。ホストマシンでのデータ永続化を行うことで、dockerコンテナが削除されたり、再度作成しなければならなくなった場合のデータ損失を防ぐことができます。
  3. networksを使用してセットアップを分離し、ホストや他のコンテナとのネットワークの競合を回避します。また、コンテナ同士を簡単に見つけて通信するのにも役立ちます。

すべてをまとめると、docker-compose.ymlの中身は以下のようになります。

version: '3.8'services:
  db:
    image: postgres:14.7-alpine3.17
    hostname: mk_db
    restart: on-failure
    ports:
      - ${DB_PORT:-5432}:5432
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: ${DB_USER:-user}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
      POSTGRES_DB: ${DB_NAME:-main}
    networks:
      - mk_network
  redis:
    image: redis:6.2.11-alpine3.17
    hostname: mk_redis
    restart: on-failure
    ports:
      - ${REDIS_PORT:-6379}:6379
    networks:
      - mk_network
      
volumes:
  db_data:

networks:
  mk_network:
    name: mk_network

アプリケーションのコンテナ化

アプリケーションのDockerイメージを構築する

まず、Dockerfileを使ってDockerイメージを構築し、docker-compose.ymlからそれを呼び出します。

Dockerfileファイルを作成するには、以下のことを行います。

  1. ベースとなるイメージを選択することから始めます。アプリに適した最小のベースイメージを使用します。通常、Alpineイメージは、余分なパッケージがほとんどインストールされていない最小限のものです。Alpineイメージから始めて、それをもとにビルドしていくことができます。
    FROM node:18.15.0-alpine3.17
    
  2. コンフリクトを避けるために、特定のCPUアーキテクチャが必要になることもあります。たとえば、arm64-basedプロセッサを使用しながら、amd64イメージをビルドする必要があるとします。この場合には、Dockerfile-- platformを以下のように指定します。
    FROM --platform=amd64 node:18.15.0-alpine3.17
    
  3. アプリケーションディレクトリを定義し、依存関係をインストールして、出力をルートディレクトリにコピーします。
    WORKDIR /opt/app 
    COPY package.json yarn.lock ./ 
    RUN yarn install 
    COPY . .
  4. docker-compose.ymlからDockerfileを呼び出します。
    services:
      ...redis
      ...db
      
      app:
        build:
          context: .
          dockerfile: Dockerfile
        platforms:
          - "linux/amd64"
        command: yarn dev
        restart: on-failure
        ports:
          - ${PORT:-4000}:${PORT:-4000}
        networks:
          - mk_network
        depends_on:
          - redis
          - db
  5. 自動リロードを実装し、ソースコードに手を加えた時に、アプリケーションを手動で再ビルドしなくても、変更内容をすぐにプレビューできるようにしましょう。そのためには、まずイメージをビルドし、それを別のサービスで実行します。
    services:
      ... redis
      ... db
      
      build-docker:
        image: myapp
        build:
          context: .
          dockerfile: Dockerfile
      app:
        image: myapp
        platforms:
          - "linux/amd64"
        command: yarn dev
        restart: on-failure
        ports:
          - ${PORT:-4000}:${PORT:-4000}
        volumes:
          - .:/opt/app
          - node_modules:/opt/app/node_modules
        networks:
          - mk_network
        depends_on:
          - redis
          - db
          - build-docker
          
    volumes:
      node_modules:

さらに一歩進んだコツとして、パッケージのプラットフォーム固有の問題を避けるために、node_modulesも明示的にマウントしていることに注意してください。つまり、ホスト上でのnode_modulesの使用の代わりに、dockerコンテナ自身のものを使用し、別ボリュームでホスト上にマッピングしています。

継続的インテグレーションによる本番イメージのインクリメンタルビルド

Kinstaのアプリやサービスの大半で、デプロイにCI/CDを使用しており、Dockerはこのプロセスで重要な役割を果たしています。メインブランチに変更があるたびに、GitHub ActionsかCircleCIを通してビルドパイプラインが即座にトリガーされます。一般的なワークフローは非常にシンプルで、依存関係をインストールし、テストを実行し、Dockerイメージをビルドし、Google Container Registry(またはArtifact Registry)にプッシュします。この記事で説明しているのは、中でもビルドのステップです。

Dockerイメージのビルド

セキュリティとパフォーマンス上の理由から、複数段階のビルドを使用しています。

ステージ1. ビルダー

このステージでは、すべてのソースと設定を含むコード全体をコピーし、開発依存関係などすべての依存関係をインストールし、アプリをビルドします。これにより、dist/フォルダが作成され、そこにビルドされたバージョンのコードがコピーされます。しかし、このイメージは本番環境で使用するには大きすぎます。また、プライベートNPMレジストリを採用しているため、この段階でもプライベートなNPM_TOKENを使用します。そのため、このステージを外部に公開してしまう事態は絶対に避けたいところです。ここで必要なのは、dist/フォルダだけです。

ステージ2. 本番

アプリ実行に必要なものに非常に近いため、ほとんどの人がこのステージをランタイムに使います。しかし、まだ本番用の依存関係をインストールする必要があります。レコードを残すことになり、NPM_TOKENが必要です。そのため、このステージはまだ公開の準備ができていません。また、19行目のyarn cache cleanに注目してください。この小さなコマンドにより、画像サイズを最大60%削減することができます。

ステージ3. ランタイム

最後のステージでは、レコードやログを最小限にし、できるだけスリム化を意識する必要があります。そこで、本番環境から完全に機能する状態のアプリをそのままコピーし、次に進みます。yarnやNPM_TOKENはすべて後回しにし、アプリだけを実行します。

最終的なDockerfile.productionは次のようになります。

# Stage 1: build the source code 
FROM node:18.15.0-alpine3.17 as builder 
WORKDIR /opt/app 
COPY package.json yarn.lock ./ 
RUN yarn install 
COPY . . 
RUN yarn build 

# Stage 2: copy the built version and build the production dependencies FROM node:18.15.0-alpine3.17 as production 
WORKDIR /opt/app 
COPY package.json yarn.lock ./ 
RUN yarn install --production && yarn cache clean 
COPY --from=builder /opt/app/dist/ ./dist/ 

# Stage 3: copy the production ready app to runtime 
FROM node:18.15.0-alpine3.17 as runtime 
WORKDIR /opt/app 
COPY --from=production /opt/app/ . 
CMD ["yarn", "start"]

すべてのステージで、最初にpackage.jsonyarn.lockファイルをコピーし、依存関係をインストールしてから、残りのコードをコピーしている点に注目してください。というのも、Dockerは各コマンドを前のレイヤーの上に重ねてビルドするためです。各ビルドで利用可能であれば前のレイヤーを使用し、パフォーマンスを鑑み新しいレイヤーのみをビルドすることができます。

例えば、src/services/service1.tsの中身に変更を加えたとします。パッケージには触れていません。つまり、ビルダーステージの最初の4つのレイヤーはそのままで、再利用可能です。この概念により、ビルドプロセスが驚くほど高速化します。

CircleCIパイプラインを通してGoogle Container Registryにアプリをプッシュする

CircleCIパイプラインでDockerイメージをビルドするにはいくつかの方法があります。私たちは、circleci/gcp-gcr orbsを使用することにしました。

executors:
  docker-executor:
    docker:
      - image: cimg/base:2023.03
orbs:
  gcp-gcr: circleci/[email protected]
jobs:
  ...
  deploy:
    description: Build & push image to Google Artifact Registry
    executor: docker-executor
    steps:
      ...
      - gcp-gcr/build-image:
          image: my-app
          dockerfile: Dockerfile.production
          tag: ${CIRCLE_SHA1:0:7},latest
      - gcp-gcr/push-image:
          image: my-app
          tag: ${CIRCLE_SHA1:0:7},latest

Dockerのおかげで、アプリをビルドしてプッシュするのに必要な設定は最小限になります。

GitHub ActionsでGoogle Container Registryにアプリをプッシュする

CircleCIの代わりに、GitHub Actionsを使ってアプリケーションを継続的にデプロイすることも可能です。gcloudをセットアップし、Dockerイメージをビルドしてgcr.ioにプッシュします。

jobs:
  setup-build:
    name: Setup, Build
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v3

    - name: Get Image Tag
      run: |
        echo "TAG=$(git rev-parse --short HEAD)" >> $GITHUB_ENV

    - uses: google-github-actions/setup-gcloud@master
      with:
        service_account_key: ${{ secrets.GCP_SA_KEY }}
        project_id: ${{ secrets.GCP_PROJECT_ID }}

    - run: |-
        gcloud --quiet auth configure-docker

    - name: Build
      run: |-
        docker build 
          --tag "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:$TAG" 
          --tag "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:latest" 
          .

    - name: Push
      run: |-
        docker push "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:$TAG"
        docker push "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:latest"

メインブランチに小さな変更がプッシュされるたびに、新しいDockerイメージをビルドしてレジストリにプッシュできます。

Googleデリバリーパイプラインを使ったGoogle Kubernetes Engineへの変更内容のデプロイ

各変更に対してすぐに使えるDockerイメージを持つことで、本番環境へのデプロイや、何か問題が発生した場合のロールバックも簡単になります。私たちはGoogle Kubernetes Engineを使用してアプリを管理・提供し、Google Cloud Deployとデリバリーパイプラインを継続的デプロイプロセスに使用しています。

(上記のCIパイプラインを使用し)細かな変更ごとにDockerイメージがビルドされると、さらに一歩進み、gcloudを使用して開発クラスタに変更をデプロイしています。CircleCIパイプラインでそのステップを見てみましょう。

- run:
    name: Create new release
    command: gcloud deploy releases create release-${CIRCLE_SHA1:0:7} --delivery-pipeline my-del-pipeline --region $REGION --annotations commitId=$CIRCLE_SHA1 --images my-app=gcr.io/${PROJECT_ID}/my-app:${CIRCLE_SHA1:0:7}

これにより、開発用Kubernetesクラスタに変更内容をロールアウトするプロセスがトリガーされます。テストを行い、承認後に、変更内容をステージング、そして本番環境へと昇格させていきます。このようなことが可能なのは、各変更に対して、必要なものをほぼすべて搭載したスリムな分離型Dockerイメージを用意しているためです。これにより、デプロイメントにどのタグを使用するかを指示するだけでOKになります。

品質保証チームにおけるプロセスの恩恵

品質保証(QA)チームに主に必要になるのは、テスト対象アプリのプリプロダクションのクラウドバージョンです。しかし、特定の機能をテストするために、(依存関係をすべて含む)ビルド済みのアプリをローカル環境で実行することもあります。このような場合、プロジェクト全体を複製、npmパッケージをインストール、アプリをビルドし、エラーへの直面後、アプリを稼働させるために開発プロセス全体を見直すというような面倒なことはしたくないですし、そうする必要もありません。Google Container RegistryにDockerイメージとしてすべてがすでに用意されているため、あとはDocker composeファイルのサービスだけで事足ります。

services:
  ...redis
  ...db
  
  app:
    image: gcr.io/${PROJECT_ID}/my-app:latest
    restart: on-failure
    ports:
      - ${PORT:-4000}:${PORT:-4000}
    environment:
      - NODE_ENV=production
      - REDIS_URL=redis://redis:6379
      - DATABASE_URL=postgresql://${DB_USER:-user}:${DB_PASSWORD:-password}@db:5432/main
    networks:
      - mk_network
    depends_on:
      - redis
      - db

このサービスを使えば、Dockerコンテナを使用してローカルマシン上でアプリケーションを起動することができます。

docker compose up

これは、テストプロセスを簡素化する上での大きな一歩です。QAがアプリの特定のタグをテストすると決めたとしても、6行目のイメージタグを簡単に変更し、Docker composeコマンドを再び実行できます。複数バージョンのアプリを同時に比較する場合でも、微調整で簡単に実現できます。最大の利点は、QAチームを開発者の課題から遠ざけることにあります。

Dockerを使うメリット

  • 依存関係の制約がほぼゼロに:RedisやPostgresのバージョンをアップグレードすることになっても、1行変更するだけでアプリを再び実行できます。システム上の何かを変更する必要はありません。さらに、Redisを必要とする2つのアプリがある場合(バージョンが異なることも)、互いに競合することなく、隔離された各環境で両方を実行できます。
  • アプリの複数インスタンス:同じアプリを別のコマンドで実行するケースは多々あります。例えば、DBの初期化、テストの実行、DBの変更の監視、メッセージのリスニングなど。このような場合、すでにビルド済みのイメージが用意されているので、Docker composeファイルに別のコマンドで別のサービスを追加するだけで完了します。
  • より簡単なテスト環境:多くの場合、アプリを実行するだけで事足ります。コードもパッケージも、ローカルのデータベース接続も必要ありません。アプリが正しく動作することを確認したい、あるいは自分のプロジェクトに取り組んでいる間、バックエンドサービスとして実行中のインスタンスが必要となるだけです。QAやプルリクエストのレビュアー、あるいはデザインが適切に実装されているかどうかを確認したいUX担当者なども、このケースに当てはまります。私たちの実践するDockerセットアップを使えば、技術的な問題に煩わされることなく、円滑に作業を進めることができます。

この記事はDockerに掲載されたものです。

Amin Choroomi

Kinstaのソフトウェア開発者。DockerとKubernetesに情熱を注ぎ、アプリケーション開発とDevOpsを専門としている。