オールアバウトTech Blog

株式会社オールアバウトのエンジニアブログです。

GCPを1ミリも知らなかった新卒が業務で学んだ知識でデプロイできるようになるまで

はじめに

オールアバウトグループの新卒1年目エンジニアが投稿する企画『テックブログ新卒週間2023』2日目の投稿です。

オールアバウトでPrimeAd関連サービスの開発をしているhinakochiです。 サービスの一つのリビルドに関わる中で、アプリをGKEでデプロイする経験をしました。入社するまでGCP自体ほぼ触ったことがなかったので、当時はついていくので精一杯になってしまいました。改めて何が起こっていたのかを理解するため、一度最初から最後までを自分でやりきりたいと思いました。約3ヶ月間、主に平日に一日一時間程度を使って簡単なアプリのデプロイに挑戦してきたので、これを機にその過程と学んだことをまとめてみました。遠回りが多くできたことはごくわずかですが、少しでもどなたかの参考になれば幸いです。

使用したもの

コンテナとアプリ構成: php-fpmコンテナ(appサーバ) + nginxコンテナ (webサーバ) + CloudSQL (dbサーバ)

Laravelのappコンテナとリバースプロキシ用のnginxのwebコンテナを自作イメージを使って立てます。これは業務で関わったものとほぼ同じ構成です。データベースには、本番ではCloud SQL Auth Proxyで接続します(ローカルではMySQLコンテナを立てていました)。 今回はデータベースに保存したテキストを取得して表示する簡単なテストページを用意しました。外部IPアドレスに接続してこれが表示できればゴールとします。

最終的な構成のイメージはこのようになります。

システム構成

GCPで使用した主なサービス

Kubenetes Engine

コンテナの自動管理ツールです。コンテナの集合体の最小単位をポッド、ポッドを実行するマシンをノード、ノード群をクラスターと呼びます。マニフェストファイルでポッドの構成やスケーリングの条件などを定義しておき、リクエストに応じて自動的にスケールさせることができます。

Cloud SQL

クラウドベースのリレーショナルデータベースシステムです。

Container Registory

コンテナイメージを保存・管理できるサービス。ただしイメージのストレージ自体はCloud Storageが担っています。(現在はOSパッケージなども保存できるよう拡張されたArtifact Registoryが推奨されています。参考:Container Registry のドキュメント

IAM

サービスというより、GCPの各サービスを使用するためのアカウント体系です。個人、組織、サービスなどの固有のアドレスをプリンシパルという概念で並列に扱い、様々な権限を付与します。個人的にはここで一番よく引っかかりました。具体的には、足りない権限が何か、それをどう与えるのかがわからない、IAMのサービスアカウントとKubernetesなどサービス特有のアカウントとの関係がわからないなどの点で悩みました。

ちなみに90日間30$分の無料トライアルを利用しました。実は間に合わず期限後少しだけアップグレードすることになったのですが、クレジットが残っている分は引き継げるので大した負担にならなくてよかったです。

その他

GitHub Actions

CI/CDツール。コードもGitHubで管理しています。

Kustomize

Kubernetesマニフェストファイルを複数まとめて管理できるツールです。参考にしたチュートリアルに登場したので試しに触ってみました。今回はデプロイ環境を一種類しか用意していないのでそこまでメリットはないのですが、ベースのマニフェストファイルを動的に書き換えて使う方法を学べました。

やったこと

実際の手順を辿りながらハマった部分をピックアップしていきます。ローカルで動くアプリは一旦完成している状態です。

デプロイ前のGCP側の準備

プロジェクト作成

ブラウザの管理コンソールで作成しました。 プロジェクトはKubernetesに限らずGCP内の様々なサービスに跨がります。

API有効化(Container Registory, Kubernetes Engine)

gcloud services enable \
    containerregistry.googleapis.com \
    container.googleapis.com

これで該当サービスが使えるようになります。 この時点でデータベースの実現方法は一旦置いていたのでCloud SQL周りはまだ何もしていません。 このあたりの工程から下記の記事を参考にさせていただきました。

GitHub Actions を使用してGKEにアプリケーションをデプロイする

Kubernetesクラスタ作成

Kubernetesクラスタは作成の際、標準モードとAutopilotモードを選択できます。Autopilotモードは設定しなくとも自動スケーリングやノード管理をある程度やってくれるようです。せっかくなので使ってみましたが、小規模なアプリなので特に恩恵はなかったと思われます。

クラスタは以下のチュートリアルを参考に作成できます。

コンテナ化されたウェブ アプリケーションのデプロイ

Autopilot クラスタの作成

クラスタ作成の過程で誤って飛ばしてしまい、その結果長時間悩むことになったステップを紹介します。

# クラスタの認証情報を取得する
gcloud container clusters get-credentials sample-cluster-name \
    --zone zone-name --project sample-project-name

KubernetesCLIツールであるkubectlを使用するにはクラスタを選択して接続する必要があります。 この設定ができていないうちは下記のようなエラーが出ており、次のセクションで出てくるような個別のロールの設定が必要なのかとしばらく誤解していました。

Error from server (Forbidden): deployments.apps "deploy-***" is forbidden: User "***" cannot get resource "deployments" in API group "apps" in the namespace "default": requires one of ["container.deployments.get"] permission(s).

サービスアカウントの設定

# サービスアカウント新規作成
gcloud iam service-accounts create sample-project-service-account
# プロジェクトに対してサービスアカウントとロールを紐づける
gcloud projects add-iam-policy-binding sample-project-name \
    --member=serviceAccount:sample-project-service-account@sample-project-name.iam.gserviceaccount.com \
    --role=roles/container.admin
    --role=roles/storage.admin
# サービスアカウントキーを作成&出力
gcloud iam service-accounts keys create key.json \
    --iam-account=sample-project-service-account@sample-project-name.iam.gserviceaccount.com
cat key.json | base64

サービスアカウントは各サービスを利用するためのアカウントです。サービスアカウントにプロジェクトにおけるロールを与えると、ロールによってできることが追加されます。container.adminはKubernetes Engineのオブジェクト全般に対する管理者、storage.adminはCloud Storageに対する管理者ロールです。Cloud Storageはプロジェクト内のオブジェクトを保持するストレージサービスなのでこちらの権限も必要になります。 出力したサービスアカウントキーはGitHub Actions側でsecretsに登録し、Actionsから操作できるようにします。 ※今回は個人的な学習目的の環境なので大きな問題はありませんでしたが、最近はサービスアカウントキーをCI/CDツールに登録することのリスクが指摘されています。GitHub Actions OIDCや後述のWorkload Identityなどを利用して直接サービスアカウントキーを登録せずに運用することができます。弊社がGitHubで管理しているサービスはGitHub Actions OIDCを利用しています。

appコンテナとwebコンテナのデプロイ

Kustomizeの設定

ファイル構成は以下の通りです。

deploy/
    - kustomization.yaml
    - deployment.yaml
    - service.yaml

kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- manifest.yaml
- service.yaml

images:
- name: web-image
- name: app-image
# kustomization.yamlのあるディレクトリに移動
cd deploy
# app-imageというイメージ名変数に使用したいイメージ名を設定
kustomize edit set image app-image=gcr.io/$PROJECT_ID/$IMAGE-app:latest
# web-image
kustomize edit set image web-image=gcr.io/$PROJECT_ID/$IMAGE-web:latest
# deployment等を作成してそれをもとにリソースを更新
kustomize build . | kubectl apply -f

kustomize edit set imageでkustomization.yamlで用意した変数にイメージ名を設定し、変数が置き換わった状態でbuildすることができます。

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-project
  labels:
    app: app-name
spec:
  replicas: 
  selector:
    matchLabels:
      app: app-name
  template:
    metadata:
      labels:
        app: app-name
    spec:
      serviceAccountName: sample-ksa
      containers:
      - name: web
        image: web-image # kustomize edit set imageで更新
        ports:
        - containerPort: 81
        volumeMounts:
          - mountPath: /var/run/php-fpm
            name: nginx-sock
      - name: app
        image: app-image # kustomize edit set imageで更新
        volumeMounts:
          - mountPath: /var/run/php-fpm
            name: nginx-sock
# 後略

ちなみに、次のコマンドもbuildができるのですが、buildした結果のdeploymentが出力されるのでデバッグに役立ちました。

oc kustomize

正しいイメージを使用できているかの確認

デプロイが成功したらまずこれが大事だと振り返ってみて思います。私の場合は、Podが動いていなくてPodの設定まわりを確認するのに時間を費やしてしまったのですが、実はイメージがpushできていない、イメージを指定できていない、古いイメージを使ってしまっているなどのミスがありました。 前セクションのocコマンドで最新のdeploymentを確認できるほか、ターミナルから次のコマンドで実際のPodで使用されているイメージを含む様々な情報を確認できます。

kubectl describe pods pod-name

コンテナ間通信

今回はUnixドメインソケットを使用しています。Unixドメインソケットを用いた通信はTCP通信より高速で、同じマシン内であればデータ通信用のファイルパスを通すことで通信できます(今回はappコンテナとwebコンテナが同一のマシンに乗っている状態なのでこの方法が可能です)。GCPとは話がずれますが、こちらもうまく接続できるまで非常に時間がかかった部分なので、どなたかの参考になることを願い簡潔に残しておきます。

deployment.yaml

apiVersion: apps/v1
kind: Deployment
# 中略
      containers:
      - name: web
        image: web-image
        ports:
        - containerPort: 81
        volumeMounts:
          - mountPath: /var/run/php-fpm
            name: nginx-sock
      - name: app
        image: app-image
        volumeMounts:
          - mountPath: /var/run/php-fpm
            name: nginx-sock
# 中略
      volumes:
        - name: nginx-sock
          emptyDir: {}

上記の設定では、nginx-sockというボリュームを確保し、コンテナごとに設定したパスでマウントします。 次はnginx側の設定です。

web/default.conf

server {
    listen 81; # webコンテナ containerPortの数字
    server_name example.com;
    root /data/public;
# 中略
    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php-fpm/nginx.sock; # ソケットファイルパス
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
# 中略
}

appコンテナ側では、ソケットファイルにアクセス可能なシステムユーザーを用意する必要があります。

app/www.conf

[www]
listen = /var/run/php-fpm/nginx.sock # ソケットファイルパス
user = nginx # ユーザー設定
group = nginx
listen.owner = nginx # listenに使用するユーザーを設定
listen.group = nginx
listen.mode = 0660
# 後略

app/dockerfile

FROM php:8.2-fpm

ENV TZ Asia/Tokyo

RUN apt-get update && \
  apt-get install -y git unzip libzip-dev libicu-dev libonig-dev && \
  docker-php-ext-install intl pdo_mysql zip bcmath

# ユーザーを作成する
RUN addgroup --system nginx && adduser --system --ingroup nginx nginx

COPY ./docker/app/php.ini /usr/local/etc/php/php.ini
# confファイルを配置
COPY ./docker/app/www.conf /usr/local/etc/php-fpm.d/zz-www.conf
COPY . /data

RUN chmod 777 /data/storage -R

COPY --from=composer:2.0 /usr/bin/composer /usr/bin/composer

WORKDIR /data

confファイルをzz-www.confという名前に変更していることに注意してください。ここで使用している公式イメージでは、ビルド時にデフォルトのwww.confで上書きをしてしまう仕様があるようです。辞書順で後ろになるファイルを用意すればその内容が優先になるため、名前を変えて対応できるということのようです。 参考: PHPの公式DockerイメージでUNIXソケット通信しようとして罠にハマる

外部アクセス

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: sample-project-service
  labels:
    app: app-name
spec:
  type: NodePort
  ports:
  - port: 81
    nodePort: 30080
  selector:
    app: app-name

次のコマンドで得られる外部IPとnodePortの値をコロンで繋いで接続できます。

kubectl get nodes -o wide

Cloud SQLの初期設定

API有効化

gcloud services enable sqladmin.googleapis.com

Cloud SQLインスタンス作成

これだけ管理コンソールで作成しました。MySQLを選択したほかはデフォルト設定です。 データベースはLaravel側でマイグレーションファイルを用意しています。

Cloud SQLのユーザー作成

gcloud sql users create sample-db-user \
    --instance=sample-db-instance --password=sample-db-password

Cloud SQLの接続設定

サービスアカウントにロール追加

# Kubernetes Engine管理者ロールのあるサービスアカウントにCloud SQL接続クライアントのロールを追加
gcloud projects add-iam-policy-binding sample-project-name \
    --member=serviceAccount:sample-project-service-account@sample-project-name.iam.gserviceaccount.com \
    --role=roles/cloudsql.client

ここからのステップは以下の公式ドキュメントの「Secret オブジェクトを作成する」「Cloud SQL Auth Proxy にサービス アカウントを提供する > Workload Identity」を参考にしています。

Google Kubernetes Engine から Cloud SQL への接続について

Cloud SQL Auth ProxyとはIAMを用いた認証でCloud SQLに接続するためのコネクタです。今回はこのコネクタの公式イメージを使用してPodにコンテナを生成して接続します。

Secret オブジェクトを作成する

Kubernetesで使えるkey-value型のオブジェクトを作成します。後ほどマニフェストに使用します。

kubectl create secret generic sample-db-secret \
  --from-literal=username=sample-db-user \
  --from-literal=password=sample-db-password \
  --from-literal=database=sample-db-name

Workload Identityを有効化する

Workload Identityとは、Kubernetesクラスタ内のサービスアカウントにIAMで作成したサービスアカウントを紐づけて接続する仕組みです。認証回数が減る、サービスアカウントキーを保持する必要がなくなる、アクセス範囲を狭められるなどのメリットがあります。appコンテナとwebコンテナはまさにサービスアカウントキーを使用してしまったので、Workload Identityを用いた改善の余地があります。こちらは今後の課題とします。

Autopilotモードでクラスタを作成すると、デフォルトで有効になっています。

Kubernetesのサービスアカウントを作成する

kubernetes-service-account.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: sample-ksa

deploymentなどと同様にマニフェストファイルを作成し、applyすることで作成されます。

サービスアカウント同士を紐づける

# KubernetesのサービスアカウントにWorkload Identityのユーザーとしてのロールを付与
# それをプロジェクトのサービスアカウントがバインドする
gcloud iam service-accounts add-iam-policy-binding \
    --role="roles/iam.workloadIdentityUser" \
    --member="serviceAccount:sample-project.svc.id.goog[sample-namespace/sample-ksa]" \
    sample-project-service-account@sample-project-name.iam.gserviceaccount.com
# Kubernetesのサービスアカウントに、プロジェクトのサービスアカウントがどれかをアノテーションする
kubectl annotate serviceaccount sample-ksa \
    iam.gke.io/gcp-service-account=sample-project-service-account@sample-project-name.iam.gserviceaccount.com

これによりプロジェクトのサービスアカウントが様々なサービスに認証なしでアクセスできるようになります。 アノテーションに間違いがあった場合、--overwriteオプションをつけて再実行することで更新できます。

Cloud SQLコネクタの設定

deployment.yaml

apiVersion: apps/v1
kind: Deployment
# 中略
    spec:
      serviceAccountName: sample-ksa
      containers:
      - name: web
# 中略
      - name: app
# 中略
        env: # appコンテナの環境変数を設定
        - name: DB_USERNAME
          valueFrom:
            secretKeyRef:
              name: sample-db-secret
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: sample-db-secret
              key: password
        - name: DB_DATABASE
          valueFrom:
            secretKeyRef:
              name: sample-db-secret
              key: database
      - name: cloud-sql-proxy
        image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.0
        args:
          - "--structured-logs"
          - "--port=3306"
          - "sample-project-name:zone-name:sample-db-instance"
        securityContext:
          runAsNonRoot: true
        resources:
          requests:
            memory: "2Gi"
            cpu:    "1"
      volumes:
        - name: nginx-sock
          emptyDir: {}

KubernetesのSecretに登録した値をappコンテナの環境変数に設定します。Laravelの.envに同名の環境変数があれば、それを上書きします(つまり.env側の記述が不要になります)。 cloud-sql-proxyのargsの3番目に入る値は、Cloud SQLのコンソールの概要 > lこのインスタンスとの接続 > 接続名 から取得できます。

データベースに接続する

gcloud sql connect sample-db-instance --user=sample-db-user

これで直接データベースを操作できます。appコンテナに入ってマイグレーションを実行してから接続すれば、テーブルができています。

終わりに

GCPに関してほぼ無知な状態から、業務で触れる中で多少は理解できてきたと思っていました。しかし、自分で全てをやってみるうちに、氷山の一角でしかないという実感が強くなる一方でした。本当はもっと業務で触れなかったサービスも使ってみようと思っていたのに既知サービスの時点で難航したこと、Workload Identityの件に気づくのが遅すぎて手が回らなかったことなど、悔しい面はたくさんありますが、自分の力量を知る良い機会になりましたし、曲がりなりにもデプロイに漕ぎ着けたことはある程度自信になったと思います。今後もGCPに限らず実践的な学習を続けていき、業務に活かしていきたいです。

参考資料

コンテナ化されたウェブ アプリケーションのデプロイ

Autopilot クラスタの作成

GitHub Actions を使用してGKEにアプリケーションをデプロイする

PHPの公式DockerイメージでUNIXソケット通信しようとして罠にハマる

Google Kubernetes Engine から Cloud SQL への接続について

images | Kustomize