🧪

サーバレスなCIツール Tektonでスケーラブルなビルドを試す

2022/06/27に公開

はじめに

以前、K8s上で動くサーバレスなCIツールとしてTekton Pipelineを紹介しました。あれから随分間が空いてしまいましたが、今回はGitやDockerとの連携など基本的なビルドを実際に行い、最後にスケーラビリティに関して確認したいと思います。

TL;DR

  • TektonはサーバレスなCIツール
  • ビルダーがコンテナなのでJavaやGoなんでもビルド可能
  • k8sへのビルド/デプロイのみに限定したCIツールではない
  • Autopilotではdind(Docker)よりKanikoでビルド
  • とりあえず21ノード(200 vCPU)までは雑にスケール出来た

Tekton環境の準備

前回と同様にGKE Autopilotを利用して、Tektonの環境を構築します。k8sクラスタの作り方にはTekton自体はあまり依存しないので、GKE StandardでもEKSでもRancherでも好きな環境を使えると思います。

PROJECT_ID=$(gcloud config configurations list | grep True | awk '{print $4}')
CLUSTER=mycluster
REGION=us-central1

gcloud container clusters create-auto mycluster --create-subnetwork name=gke --region us-central1
gcloud container clusters get-credentials $CLUSTER --region $REGION --project $PROJECT_ID
gcloud auth configure-docker

続いてTekton Pipeline, Tekton Dashboardをインストール。

kubectl apply --filename https://storage.googleapis.com/tekton-releases/pipeline/latest/release.yaml
kubectl apply --filename https://github.com/tektoncd/dashboard/releases/latest/download/tekton-dashboard-release.yaml

Tekton CLIは以下の開発環境用コンテナに入れているのでこちらを使います。

$ docker run -it -v $HOME:/home/user -v /var/run/docker.sock:/var/run/docker.sock koduki/env-dev4tekton
user@5297fd802e46:~$ tkn version
Client version: 0.22.0
Pipeline version: v0.32.1

DashboardにアクセスするためのProxyの立ち上げなどはこちらを参考にしてください。

始める前に少しアーキテクチャの話

これからTektonでのビルドを行っていきますが、いくつか新しい概念/単語もでるので、まずはTektonの基本的な構造を少し整理したいと思います。

https://tekton.dev/docs/pipelines/

Tektonではビルドの基本的な単位はTaskです。このYAMLファイルにStepという形で実際のビルドスクリプトを記載していきます。Stepは ひとつひとつが別のコンテナ になっておりJavaでもGoでも任意のビルダーを利用することが出来ます。GCPのCloudBuildをはじめよくある奴ですね。最近のCIっぽい。Taskは実態としてはPodにマッピングされていて、同じTaskの中であれば/workspaceを使ってファイルを共有出来ます。gitで取得したソースコードやビルドの成果物をここに置くことでStep間でデータのやり取りを出来ます。

複数のTaskを束ね複雑な処理を実行するのがPipelineです。いわゆるワークフローエンジンでね。PipelineはTaskを超えて、つまりPodを超えて実行されます。よってファイル連携をするにはPersistentVolumeなどを使う必要がありますが、TektonにはWorkspacesという機能があり、ビルドに紐付いた形で自動的にボリュームを確保、マウントが出来ます。

最後にRunという概念があります。RunはTaskやPipelineにコンテキストを与え実行をします。プログラミング言語っぽく言うなら、TaskやPipelineがクラスでTaskRunPipelineRunがインスタンスですね。Runではビルドを実行するサービスアカウントを指定したり、SpotVMの利用の有無、各種パラメータを指定することが出来ます。
まとめると関係性は以下のようになります。

Entity Description
Step Builder。実態としてはコンテナとなっており任意のイメージを使ってビルド処理を実行する
Task Tektonのビルドの基本単位。実態としてはPod.一つ以上のStepの集合
Pipeline ワークフロー。複数のTaskを束ねて順序や条件に基づいて実行
TaskRun Taskを直接実行する際の実行コンテキストを与える。実行ユーザやリソース、パラメータなどを指定
PipelineRun Pipelineを実行する場合の実行コンテキスト。実行ユーザやリソース、パラメータなどを指定

ちなみに今回の記事の中ではWorkspacesやPipelineは扱いません。

Git clone & Build

まずは最も基本的なシナリオであるGitHubからcloneしてBuildをするケースを作成します。Taskは以下のようなマニフェストを書いてtask_sample-java-build.yamlで保存します。

apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: sample-java-build
  description: An example of Java Build with maven
spec:
  params:
  - name: repo-url
    type: string
    description: The git repo URL to clone from.
  steps:
  - name: git-clone
    image: alpine/git
    workingDir: /workspace
    script: | 
      #!/usr/bin/env sh
      echo "repos-url: "$(params.repo-url)
      git clone $(params.repo-url)
  - name: java-build
    image: openjdk
    workingDir: /workspace
    script: | 
      #!/usr/bin/env sh
      cd quarkus-quickstarts/getting-started
      ./mvnw package

params.repo-urlでパラメータとして渡されたURLをcloneして、Mavenでビルドしています。stepsの中のimageを変える事で任意のビルダーが利用出来ます。TaskをTektonに反映するにはマニフェストをApplyします。

$ kubectl apply -f task_sample-java-build.yaml
taskrun.tekton.dev/echo-hello-world-task-run created
$ $ tkn task describe sample-java-build
Name:        sample-java-build
Namespace:   default
⚓ Params
 NAME         TYPE     DESCRIPTION              DEFAULT VALUE
 ∙ repo-url   string   The git repo URL to...   ---
🦶 Steps
 ∙ git-clone
 ∙ java-build
 🗂   Taskruns

つづいて実行のコンテキストであるTaskRunは以下のようになります。

apiVersion: tekton.dev/v1beta1
kind: TaskRun
metadata:
  name: run-sample-java-build
spec:
  taskRef:
    name: sample-java-build
  params:
  - name: repo-url
    value: https://github.com/quarkusio/quarkus-quickstarts.git

taskRefで対応するTaskを記載しparamsで先程Taskの中で宣言したパラメータの具体的な値を渡しています。Taskと同じく以下のようにマニフェストをApplyしますが このタイミングでビルドジョブが走る という点に注意をしてください。クラスを初期化するイメージ。

$ kubectl apply -f taskrun_sample-java-build.yaml
taskrun.tekton.dev/run-sample-java-build created
$ tkn taskrun logs run-sample-java-build -f

さて、本来であればここでただちに実行されるのですが、GKE Autopilotの場合は少し時間がかかるケースが多いかと思います。

user@d2908acc5680:~/git/example-tekton$ kubectl get pods
NAME                        READY   STATUS    RESTARTS   AGE
run-sample-java-build-pod   0/4     Pending   0          9s

というのもノード数が既存のPod数に最適化されているのでTaskを実行するのに必要なリソースが足りないんですよね。ExceededNodeResourcesと以下のように出るはず。

ただ、心配は不要で以下のようにPodのイベントログを見るとTriggeredScaleUpが発生しているはずです。

ここから数分でノードが自動で追加され正常にビルドが完了するかと思います。この辺はAutoScaleの設定をしてる環境なら似たような動きになると思いますが、クイックに実行したいなら最低ノード数に余裕を持たせると良いのかもしれません。

コンテナイメージを作成してDockerHubにPush

GitHubからコードを取得してMavenでビルドするという事が出来ました。ただ、先程のサンプルだと最終的に生成されたJARファイルもコンテナの中に置き去りで実用的ではありません。適当なMavenリポジトリやS3やGCSに格納しても良いのですが、せっかくなのでコンテナイメージとしてDockerHubにPushをしてみます。

Kanikoによるコンテナイメージの作成

コンテナイメージを作るならビルダーはDockerで! と言いたいところですが、ちょっと注意が必要です。というのもビルダー自体がコンテナなのでDockerコマンドを使おうとすると Docker in Docker (dind) になるんですね。TektonにもSidecarでdindを起動してDockerをTekton Taskの中で実行する方法はあります。しかしながら、その場合でも当然privilegedを取る必要が出てきます。残念ながらGKE Autopilotでは2022/06現在ではprivilegedを有効に出来ません。そもそも、buildやpushのためだけにdindするのもちょっとToo Muchな感じがしますよね? そんな時に便利なのがKanikoです。検索候補に某プロレタリア文学が出てくる感じのツールですが、Googleが開発したCI環境に特化したコンテナビルドツールです。DockerlessでBuildとPushを実施できるため、dindを考慮する必要がなく、Tektonのようなコンテナを基盤とするビルド環境非常にマッチしています。

DockerHubへのクレデンシャルの準備

今回、DockerHubへのPushを想定しているので、例えばPublicなレジストリだとしても認証情報が必要です。Tektonではk8sのSecretServiceAccountを利用することでセキュアに認証情報を運用出来ます。

まず、以下のようにDockerHubの認証情報をSecretに登録します。

kubectl create secret docker-registry docker-credential
--docker-server=https://index.docker.io/v1/
--docker-username={ID}
--docker-password={パスワード}

パスワードはDokerHubのセキュリティ設定からTekton向けにアクセストークンを払い出すのがお勧めです。公式ページにあるようにYAMLにすることも出来ますが、オペミスでGitに登録してしまうのも怖いのであえてコマンドに留めています。また、公式ではdocker-serverhttps://gcr.ioになっているのですが、今回試した限りでは実際にアクセスしているのはindex.docker.ioなので修正してやる必要がありました。

続いて上記を使うサービスアカウントを作成します。sa-tektonbot.yamlの名前で保存します。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: build-bot
secrets:
  - name: docker-credential

以下のように反映します。先程作成したdocker-credentialもマッピングされていることが分かりますね。

$ kubectl apply -f sa-tektonbot.yaml
$ kubectl describe sa build-bot
kubernetes/kubectl-auth-changes-in-gke
Name:                build-bot
Namespace:           default
Labels:              <none>
Annotations:         <none>
Image pull secrets:  <none>
Mountable secrets:   build-bot-token-hp2g8
                     docker-credential
Tokens:              build-bot-token-hp2g8
Events:              <none>

ここで作ったサービスアカウントをTaskRunやPipelineRunに指定することで認証情報を紐づける事が出来ます。

DockerHub:PushをTaskとTaskRunへ追加

それではTaskとTaskRunを修正してコンテナのBuildとPushに対応させましょう。まずはTaskです。
Javaのビルドまでは変わらないのですが一応全部を載せています。またcontainer-preは今回アドホックにDockerfileを作ったので追加したStepですが通常はDockerfileはコードと同じ場所に格納済みだと思うので不要なステップとなります。container-build-and-pushのみが本質的な追加部分です。

apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: sample-java-build
  description: An example of Java Build with maven
spec:
  params:
  - name: repo-url
    type: string
    description: The git repo URL to clone from.
  steps:
  - name: git-clone
    image: alpine/git
    workingDir: /workspace
    script: | 
      #!/usr/bin/env sh
      echo "repos-url: "$(params.repo-url)
      git clone $(params.repo-url)
  - name: java-build
    image: openjdk
    workingDir: /workspace
    script: | 
      #!/usr/bin/env sh
      cd quarkus-quickstarts/getting-started
      ./mvnw package
  - name: container-pre
    image: debian
    workingDir: /workspace
    script: | 
      #!/usr/bin/env sh
      cd quarkus-quickstarts/getting-started
      cat << EOF > ./Dockerfile
        FROM openjdk
        RUN mkdir /app
        ADD target/quarkus-app /app
        CMD [ "java", "-jar", "/app/quarkus-run.jar"]
      EOF
  - name: container-build-and-push
    image: gcr.io/kaniko-project/executor:latest
    workingDir: /workspace
    env:
      - name: "DOCKER_CONFIG"
        value: "/root/.docker"
    command:
      - /kaniko/executor
    args:
      - --dockerfile=Dockerfile
      - --destination=koduki/getting-started
      - --context=quarkus-quickstarts/getting-started/

ビルダーにはgcr.io/kaniko-project/executor:latestを利用しています。今までとは違いstepscriptを書くのではなくcommandargsを指定しています。すでに整ったビルダーであればこちらの方がブレがなくて良いと思います。

注意したいのがenvDOCKER_CONFIGを指定しているところです。TaskRunに先程作成したサービスアカウントをマッピングすると、step毎に$HOME/.docker/config.jsonが作成され、認証情報が書き込まれます。しかしKaniko自体はデフォルトでは/kaniko/.docker/config.jsonを見に行こうとするため認証エラーになります。なのでTektonによって作られたconfig.jsonを見るように環境変数のDOCKER_CONFIGを変更してやる必要があります。
この辺りKaniko自体の挙動に慣れてないと分かりづらいのでね。個人的にはちょっとハマりました。以下のように手元の環境でKanikoのデバックイメージを実行して中身を確認するのも良いと思います。

$ docker run -it -v `pwd`:/workspace --entrypoint=/busybox/sh gcr.io/kaniko-project/executor:debug

TaskRunは以下のように修正します。先程との違いはサービスアカウントを指定してるだけなので、修正は簡単ですね。

apiVersion: tekton.dev/v1beta1
kind: TaskRun
metadata:
  name: run-sample-java-build
spec:
  serviceAccountName: build-bot
  taskRef:
    name: sample-java-build
  params:
  - name: repo-url
    value: https://github.com/quarkusio/quarkus-quickstarts.git

それでは実行してみます。確実に反映されるように念のため先ほどのTaskRunを削除しておきます。

tkn taskrun delete run-sample-java-build
kubectl apply -f task_sample-java-build.yaml -f taskrun_sample-java-build.yaml

Dashboadで実行完了が確認できます。

DockerHubにも登録されていることが確認できるかと思います。

スケーラビリティの実験

Tektonの特徴はサーバレスでスケーラブルな所です。GKE Autopilotでノード周りのスケールもお任せなので、その辺の挙動を試してみたいと思います。
今まで、ビルドジョブを1ジョブずつ流していましたがもっと大量に同時実行してみます。とりあえず100ジョブくらい同時に流してみましょう。

実行前にnodeの状態を確認します。どうやら5ノード起動しているようです。

$ kubectl get nodes
NAME                                       STATUS   ROLES    AGE   VERSION
gk3-mycluster-default-pool-adc39573-6w23   Ready    <none>   14h   v1.22.8-gke.202
gk3-mycluster-default-pool-adc39573-kh42   Ready    <none>   14h   v1.22.8-gke.202
gk3-mycluster-default-pool-afaf435e-l1zq   Ready    <none>   14h   v1.22.8-gke.202
gk3-mycluster-default-pool-afaf435e-zkd6   Ready    <none>   14h   v1.22.8-gke.202
gk3-mycluster-nap-sa06pmyq-92873cab-fxl5   Ready    <none>   11h   v1.22.8-gke.202

それでは実行をします。流石に手でポチポチ作るのは面倒なので、既存のTaskRunの名前をスクリプトで変換して100個ほど投げます。

$ echo {1..100}| tr ' ' '\n'|awk '{print "cat taskrun_sample-java-build.yaml | sed q;qs/run-sample-java-build/run-sample-java-build"$1"/gq;q > taskrun_sample-java-build$1".yaml"}'|sed 's/q;q/"/g'|sh
$ ls -1 *.yaml|xargs -n1 -I {} kubectl apply -f {}

ノードの状態を見てみましょう。流石に100ノードは出来ませんでしたが21ノード程作られました。203.25 vCPUを作ったせいで何かのリミットに引っかかっているようですね。この辺はGKEの設定しだいかな、と。

Pendding中のものも多いですが、以下のように一気に実行されています。

user@d2908acc5680:~/git/example-tekton/tmp$ tkn taskrun list
NAME                       STARTED         DURATION    STATUS
run-sample-java-build99    6 minutes ago   ---         Running
run-sample-java-build98    6 minutes ago   ---         Running
run-sample-java-build97    6 minutes ago   ---         Running
run-sample-java-build96    6 minutes ago   5 minutes   Succeeded
run-sample-java-build95    6 minutes ago   5 minutes   Succeeded
run-sample-java-build94    6 minutes ago   5 minutes   Succeeded
run-sample-java-build92    6 minutes ago   4 minutes   Succeeded
run-sample-java-build93    6 minutes ago   5 minutes   Succeeded
run-sample-java-build91    6 minutes ago   3 minutes   Succeeded
run-sample-java-build90    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build9     6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build89    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build87    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build88    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build86    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build85    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build84    6 minutes ago   3 minutes   Succeeded
run-sample-java-build83    6 minutes ago   3 minutes   Succeeded
run-sample-java-build82    6 minutes ago   3 minutes   Succeeded
run-sample-java-build81    6 minutes ago   4 minutes   Succeeded
run-sample-java-build80    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build8     6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build79    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build78    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build77    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build76    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build75    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build74    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build73    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build72    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build71    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build70    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build7     6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build69    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build68    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build67    6 minutes ago   4 minutes   Succeeded
run-sample-java-build66    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build65    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build64    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build63    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build62    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build61    6 minutes ago   4 minutes   Succeeded
run-sample-java-build60    6 minutes ago   3 minutes   Succeeded
run-sample-java-build6     6 minutes ago   3 minutes   Succeeded
run-sample-java-build59    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build58    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build57    6 minutes ago   ---         Running
run-sample-java-build56    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build55    6 minutes ago   4 minutes   Succeeded
run-sample-java-build54    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build53    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build52    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build51    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build50    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build5     6 minutes ago   ---         Running
run-sample-java-build49    6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build48    6 minutes ago   ---         Running
run-sample-java-build47    6 minutes ago   ---         Running
run-sample-java-build46    6 minutes ago   ---         Running
run-sample-java-build45    6 minutes ago   ---         Running
run-sample-java-build44    6 minutes ago   ---         Running
run-sample-java-build43    6 minutes ago   4 minutes   Succeeded
run-sample-java-build42    6 minutes ago   ---         Running
run-sample-java-build41    6 minutes ago   ---         Running
run-sample-java-build40    6 minutes ago   ---         Running
run-sample-java-build4     6 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build39    6 minutes ago   ---         Running
run-sample-java-build38    6 minutes ago   ---         Running
run-sample-java-build37    6 minutes ago   ---         Running
run-sample-java-build36    7 minutes ago   ---         Running
run-sample-java-build35    7 minutes ago   4 minutes   Succeeded
run-sample-java-build34    7 minutes ago   4 minutes   Succeeded
run-sample-java-build33    7 minutes ago   4 minutes   Succeeded
run-sample-java-build32    7 minutes ago   4 minutes   Succeeded
run-sample-java-build31    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build30    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build3     7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build29    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build28    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build27    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build26    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build25    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build24    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build23    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build22    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build21    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build20    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build2     7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build19    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build18    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build17    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build16    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build15    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build13    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build14    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build12    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build11    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build100   7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build10    7 minutes ago   ---         Pending(ExceededNodeResources)
run-sample-java-build1     7 minutes ago   5 minutes   Succeeded
run-sample-java-build      7 minutes ago   3 minutes   Succeeded

最終的には無事にすべての実行完了が確認できました。100並列とはさすがにいきませんでしたが、10や15の同時実行でも実際には十分でしょうし、GKEの設定次第ではもっと拡張出来そうですしね。

まとめ

今回はTektonを使ってGitHubからのClone、Javaのビルド、そしてコンテナイメージにビルドしてDockerHubへのPushを実施しました。GCPのCloudBuildと同じくビルダーをコンテナで作成 出来るので、JavaだろうがGoだろうがなんでもビルド出来るのが良いですね。k8sネイティブというとk8s以外へのビルドやデプロイが苦手そうなイメージですが、特にそんなことは無さそうです。

GKE Autopilotの相性もあって簡単にスケール出来るのも便利そうです。ただ、テストでコンテナでの実行を多用している場合はDocker in Dockerになってしまうので、AutopilotではなくStandardクラスタを利用するか、Dockerでは無くk8sでテストを実行するようにテストスクリプトの修正等が要りそうです。例えばJavaであれば TestContainerの代わりにk8sを使うコード とかもあるので、その辺を参考に対応するのもありかもです。

今度時間がある時にトリガーの挙動とかも見てみたいと思います。
それではHappy Hacking!

Discussion