Kekeの日記

エンジニア、読書なんでも

Concourseで継続的デリバリー。そしてSpinnakerとの比較

f:id:bobchan1915:20181223164839p:plain

本記事

本記事はConcourse Advent Calendarの23日目の記事になっています。

もうすぐ学部を卒業するので、積極的にアウトプットしようと思って、以下のAdvent Calendarも参加したので、もし興味がございましたらご覧ください。

Vue.js Advent Calendar 2018

www.1915keke.com

CircleCI Advent Calendar 2018

www.1915keke.com

Kubernetes Advent Calendar 2018

www.1915keke.com

オフィスや自宅を快適にするIoT byゆめみ③ Advent Calendar 2018

www.1915keke.com

動機

本記事を書こうと思ったのは、Concourseのバージョンアップに伴い、実際に動作させることができるチュートリアルやリファレンスがあまりにも少ないし、日本語ならなおさら少ないと思ったからです。

私はDevOpsやCloud Nativeなどを熱く注目していて、新しいデプロイメントパターンやアーキテクチャを考案し、これから働くであろう組織に最大の恩恵を与えられるようなエンジニアになりたいと思っています。

例えば、宣言的継続的デリバリーをSpinnakerで実現する方法を過去に提案しました。

www.1915keke.com

このような提案も他のエンジニアの知見をまとめたブログや書籍がなければ思いつくことはできなかったでしょう。仮にドキュメントがなかったりすると、ソースコードを読んだり、直接開発者に連絡してみたりしなければならず、全世界がそのように時間を消費していたらもったいないです。

この記事によって現時点でのConcourseの解説を明文化することにより、他の誰かがより簡単にConcourseを使って新しい価値を生んでもらえれば執筆者としては嬉しい限りです。

コンテンツ

Concourseの基礎

いったい何?

f:id:bobchan1915:20181223135123p:plain

Concurceとは

オープンソースなCI/CD

であり、自動化を推進する中ではもってこいのCloud Nativeなツールです。

ここでCI/CDとは日本語だと以下の通りです。

・CI(Continuos Integration): 継続的インテグレーション ・CD(Continuos Delivery): 継続的デリバリー

CIだとCircleCIやTravisCI、Jenkinsなどが、CDだとSpinnakerや同じくCIも得意なJenkinsがあります。

ConcourseはGUIで設定をすることができず、CLIで何もかも設定をするようになります。

特徴

Concourseを使うにあたって、どのような特徴があるのかを説明します。

1. リッチなUI

可視化はCI/CDでは非常に重要です。どのエンジニア、マーケッターなど、あらゆる価値を生む人にフィードバックがなければなりません。

そのような面でも、Concurseは大きな役割を持っています。

f:id:bobchan1915:20181223140239p:plain

2. 再利用可能な、またデバックができるビルド

すべてはコンテナによって実行され、クリーンな環境で実行されることが約束されており、そのような面ではステートレスということができる。

すべてのタスクは自分のイメージを指定し、依存性などを完全に管理することができます。

3. 高速なローカルイテレーション

少し分かりにくい名前ですが、簡単に言うとローカルでも速く開発できるよっということです。

全くパイプラインを(パイプラインの)プロダクション環境で行うようにローカルでも実行できるため、開発をより効率的に行うことがでます。

ちょっと前のCircleCIだとローカル環境で行えるビルドには限界があり(シークレットや環境変数などを設定できないなど)何度もpushしたりして試すこともあったのではないでしょうか。

4. カスタマイズ可能なインテグレーション

簡単なプラグインシステムが用意されてあります。

何もかもが自分で構築する必要はなく、簡潔なシステムで理解がしやすいです。

主な概念・用語

1. パイプライン

分散されて、ハイレベルな、継続的に実行されるMakefileと同様の機能を提供します。

1.1 Resources

resourcesで定義されるオブジェクトは依存性で、リポジドリを刺したり、いわゆる「何を」CI/CDするかに対応します。

Resourcesを定義してみましょう。ここではPrivateなGitリポジトリを選択します。

私はこのConcourseのファイルをホストしているディレクトリを選択しました。

resources:
- name: concourse-pipelines
  type: git
  source:
          uri: git@github.com:KeisukeYamashita/test-concourse.git
          branch: master
          private_key: ((private-repo-key))

このときに変数をデプロイ時に渡すことになります。

1.2のJobsセクションで解説をします。

1.2 Jobs

Jobがトリガーされた時に実行される計画を記述します。

「どのように」CI/CDをしていくかを示します。

もちろんResourcesをあぁだ、こうだ処理していくわけで、Rosourcesで指定した依存性を使うことができ、またべつのJobから処理が終わったものと使うことができます。

つまり、Jobの連続的に繋がった全体は依存性グラフで結ぶことができ(CircleCIだとWorkflowを想像してください)、ソースコードからプロダクション環境まで一気通貫にデプロイできます。

例えば、以下のようにJobは定義されます。インラインでタスクを定義しています。

---
jobs:
  - name: job-sample
    plan:
      - task: test-task
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: alpine
              tag: 3.4
          run: 
            path: /bin/sh
            args:
              - -c
              - -x
              - |
                uname -a

もっとも大事なのはplanであり、Jobを構成するタスクからの配列になっていることです。

また、このJobにはたくさんのステップやフックがあります。

たとえば

ステップ 役割
get resourcesを取得して利用可能にする
put resourcesをpushする。例えばdevelopブランチをmasterブランチpushしたり。
task タスクを実行する。インラインで定義されたものや、他のステップでfetchしたものも使える
... まだまだあります。

詳しくはこの公式サイトをご覧ください。

実際にPipelineをセットしてみます。

fly -t main set-pipeline -c pipelines/pipelines.yml -p atomic-pipeline

すると以下のようにGUIで可視化をすることができます。

f:id:bobchan1915:20181223001414p:plain

何もinputもなく、何もoutputもありません。

また、以下のコマンドで一覧を確認することができます。

fly -t main pipelines`

すると以下のように表示されます。

name             paused  public
atomic-pipeline  yes     no

トリガーも何も設定していないので、paused(=停止状態)です。Pausedとは、意図しない動作を防ぐためのものでset-pipelineしたら、必ずunpausedしないと実行できないようになっています

なので、CLIからunpauseをしてあげます。

fly -t main unpause-pipeline -p atomic-pipeline

すると以下のように灰色になり、実際に実行すると実行中は黄色の波紋みたいなものがでます。

f:id:bobchan1915:20181223002504g:plain

エラーなくJobを完了すると緑で色付けがされます。

f:id:bobchan1915:20181223002543p:plain

ここで1.1 Resourcesをつかってみましょう。以下のようにパイプラインとして統合します。

resources:
- name: concourse-pipelines
  type: git
  source:
          uri: git@github.com:KeisukeYamashita/test-concourse.git
          branch: master
          private_key: |
                  ((private-repo-key))
---
jobs:
  - name: say-hello-from-repo
    plan:
      - task: test-task
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: alpine
              tag: 3.4
          run: 
            path: /bin/sh
            args:
              - -c
              - -x
              - |
                uname -a

そして再びPipelineをデプロイします。

fly -t main set-pipeline -c pipelines/pipelines.yml --var "private-repo-key=$(cat ~/.ssh/id_rsa)" -p atomic-pipeline

すると以下のようにエラーがでます。

error: invalid configuration:
invalid resources:
    resource 'concourse-pipelines' is not usedで

これはResourceを使っていないためエラーが出てきます。使わないものは削除する必要がありますが、今回はResource(Private Repository)の検証のために無理やり使ってみます。このようなときはget stepです。

get: concourse-pipelines

と指定すると、以下のようにPipelineを設定することができました。

f:id:bobchan1915:20181223011030p:plain

注意点としては、仮にファイルなどをResourcesから参照するときのパスは[RESOURE名]/path/to/your/fileになります。決してリポジトリ名ではないので注意してください。を

もちろん、masterブランチの参照を持っていることがわかります。

f:id:bobchan1915:20181223133600p:plain

Executeしてみます。

f:id:bobchan1915:20181223134157g:plain

結果は以下のようになります。

f:id:bobchan1915:20181223134250p:plain

1.3 Task

Jobなどが一連の過程を指すのに対して、Taskとはアトミックな構成単位です。つまり、TaskがJobの中で再利用できます。

関数型プログラミングをやったことがある人ならわかりますが、HaskellやElixirなどと同様に、純粋な関数として副作用のなく、入力に対して一意の結果を返します。

オブジェクト型志向を知っている人でも関数と言えば、同じようなものをイメージするでしょう。それが結局は、ユニットテストをはじめとするテストを書いて恩恵を受けること、またテスト駆動開発への礎でもあります。

実際にどのように定義するのかというと、以下のように、「どこで(platform)」、「どんな環境(image_resource)」、「どんな入力で(input)」、「どのような出力(output)」になるのかを記述します。

  • platform: 一般的にはwindows, linux, darwinが指定されますが、一般的にはlinuxでいいでしょう。
  • image_resource: 1.1 Resourcesで定義されたリソースを使ってベースイメージを作ることができます。
  • input: どのような入力があるのかを設定できる。パスを指定して渡せる。例えばタスクが依存するファイルや実際に実行されるファイルを渡せる
  • output: 次のステップに使えるように出力を設定できます。

例えばHello Worldを出力するサンプルを作ってみましょう。

platform: linux

image_resource:
  type: docker-image
  source: {repository: busybox}

run:
  path: echo
  args: [hello world]

そして実行してあげます。

fly -t main execute -c hello-world-task.yml

すると以下のように実行された結果が返されます。

executing build 1 at http://127.0.0.1:8080/builds/1
initializing
waiting for docker to come up...
Pulling busybox@sha256:2a03a6059f21e150ae84b0973863609494aad70f0a80eaeb64bddd8d92465812...
sha256:2a03a6059f21e150ae84b0973863609494aad70f0a80eaeb64bddd8d92465812: Pulling from library/busybox
90e01955edcd: Pulling fs layer
90e01955edcd: Verifying Checksum
90e01955edcd: Download complete
90e01955edcd: Pull complete
Digest: sha256:2a03a6059f21e150ae84b0973863609494aad70f0a80eaeb64bddd8d92465812
Status: Downloaded newer image for busybox@sha256:2a03a6059f21e150ae84b0973863609494aad70f0a80eaeb64bddd8d92465812

Successfully pulled busybox@sha256:2a03a6059f21e150ae84b0973863609494aad70f0a80eaeb64bddd8d92465812.

running echo hello world
hello world
succeeded

ここで以下のページを開いてみます。

open http://localhost:8080/builds/1

すると以下のように結果が表示されています。Dockerイメージも同じものは2回目以降はすでにPullしているイメージを使うようになります。

f:id:bobchan1915:20181222230951p:plain

もちろん外部ファイルをアップロードしたり(input)、出力を取得できたり(output)でき、実行ファイルを作ればそれを実行することもできます。

run:
    path: ./hello/test.st

このようにしてファイルをコンテナに渡し合い、Taskを定義できます。

Pipelineでワンライナーで定義する方法もあります。先ほど定義したジョブのオブジェクトをそのままplan[]に渡すといいです。

---
jobs:
  - name: say-hello-from-repo
    plan:
      - task: test-task
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: alpine
              tag: 3.4
          run: 
            path: /bin/sh
            args:
              - -c
              - -x
              - |
                uname -a

のような感じです。別の方法でPipelineで使うにはtask.ymlのようにTaskのYAMLで設定するのがいいでしょう。

2. Target

Targetを指定することは、指定するConcourseでKubernetesのContextの切り替えによく似ています。

例えばMasterクラスタのConcourseや、DevクラスタのConcurseなど様々な可能性があるので、ターゲットと名前をつけて指定します。つまり、デプロイメント単位でしょう。

(Configファイルに書き込まない限り)毎回-tをつける必要があり、間違えずに済みます。

f:id:bobchan1915:20181223135018p:plain

インストール方法

Helmの準備をする

まずHelmのサーバー側であるTillerをクラスタにインストールします。

helm init

もしかしたらTillerにロールを渡さないといけないかもしれないので、必要になれば適宜渡してください。

Concourseをデプロイする

以下のようにHelmのValueを定義します。

 cat > concourse.yaml <<EOF
concourse:
        password: concourse
        baggageclaim: 
                driver: overlay
secret:
        localUsers: "test:test"
web:
        service:
                type: LoadBalancer
 EOF

次にHelmを使ってConcourseチャートをデプロイします。

helm install stable/concourse --name concourse -f concourse.yaml

デプロイ直後に確認してみます。

kubectl get pods

NAME                                    READY     STATUS              RESTARTS   AGE
concourse-postgresql-58557c5fbc-c89fw   0/1       ContainerCreating   0          39s
concourse-web-649cdd6fc4-x97xw          0/1       Running             0          39s
concourse-worker-0                      0/1       ContainerCreating   0          39s
concourse-worker-1                      0/1       ContainerCreating   0          39s

となっています。数分後に確認すると以下のようになっていました。

kubectl get pods

NAME                                    READY     STATUS    RESTARTS   AGE
concourse-postgresql-58557c5fbc-c89fw   1/1       Running   0          3m
concourse-web-649cdd6fc4-x97xw          1/1       Running   0          3m
concourse-worker-0                      1/1       Running   1          3m
concourse-worker-1                      1/1       Running   1          3m

Kubernetesのコンピュータ的なリソースもみたかったので、チェックしました。

f:id:bobchan1915:20181223141152p:plain

Servicesも確認してみましょう。ここに書いてあるIPアドレスは実際のIPアドレスではありませんが、以下のようになっています。

kubectl get services

NAME                   TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)                         AGE
concourse-postgresql   ClusterIP      10.31.248.221   <none>           5432/TCP                        8m
concourse-web          LoadBalancer   10.31.241.126   45.134.114.132   8080:30463/TCP,2222:31905/TCP   8m
concourse-worker       ClusterIP      None            <none>           <none>                          8m

ここでconcource-webのIPアドレスにアクセスします。

open $(kubectl get services -l app=concourse-web -o=jsonpath="{.items[0].spec.clusterIP}"):$(kubectl get services -l app=concourse-web -o=jsonpath="{.items[0].ports[0].port}")

すると以下のような画面がでてflyCLIツールをインストールできます。

f:id:bobchan1915:20181222053633p:plain

ご自身の環境にあったものを選択してください。

そして以下のようにPATHを通して、バージョンを確認してください。本記事で使っているのは4.2.2です。

install ~/Downloads/fly /usr/local/bin

which fly
/usr/local/bin/fly

fly -v
4.2.2

次にローカル環境のflyの認証を済ませます。これはgcloud auth loginと同じ役割です。

まずはローカル環境で開発できるようにポートフォワードをします。

kubectl port-forward $(kubectl get pods -l app=concourse-web)

そして、以下のコマンドを叩いて認証します。

fly --target main login --concourse-url http://127.0.0.1:8080

or

fly -t main login -p $PASSWORD -c http://$(kubectl get services -l app=concourse-web -o=jsonpath="{.items[0].spec.clusterIP}"):8080

するとOAuthから一時的なキーを取得するようにリンクへアクセスするように促されます。だいたいはhttp://YOUR_WEB_IP:8080/sky/login?redirect_uri=http://127.0.0.1:62502/auth/callbackです。

このリンクにアクセスして、ユーザー名とパスワードを入力します。

f:id:bobchan1915:20181222164623p:plain

ログインすると以下のように承認されます。

f:id:bobchan1915:20181222164553p:plain

これでflyからKubernetesクラスタにアクセスすることができるようになります。

私の場合はmainで使うクラスタです。Target一覧は以下のコマンドで見られます。

fly targets

name  url                    team  expiry
main  http://127.0.0.1:8080  main  Sun, 23 Dec 2018 07:05:12 UTC

type: LoadBalancerにしなくてもポート転送でできれば安全です。他にもIngressでHTTPS対応をしたり、今までKubernetesでやってきた知識はHelmに渡すValueさえ適切に設定できればフルに活かせます。

GUIの方では右上にユーザー名が表示されるようになっています。

f:id:bobchan1915:20181222164934p:plain

Pipelineを定義していく

ほとんどタームのセクションで解説してしまいましたが、いくつかのシチュエーションに分けて少し深めてみようかなと思います。

1. Resourcesの変更をトリガーにしたい

基本的には設定をしなければ手動でトリガーをしなければ、JobやPipelineは実行されません。

自動でするには、変更によってトリガーをするように設定します。

今まではリポジトリからHello WorldをするTaskをplanの一つとして使っていました。そして、それはgithubのPrivateリポジトリにあったResourceを使っています。

jobs:
  - name: say-hello-from-repo
    plan:
      - get: concourse-pipelines
      - task: task-sample
        file: concourse-pipelines/task/hello-world/hello-world.yml

これをリポジトリに変更があるたびにするのはgetステップのtrigger: trueをつけるだけです。

デフォルトではfalseになっています。

再度、Pipelineをデプロイします。

fly -t main set-pipeline -c pipelines/pipelines.yml -l credentials.yml  -p atomic-pipeline

変更点は以下の通りです。

jobs:
  job say-hello-from-repo has changed:
  name: say-hello-from-repo
  plan:
  - get: concourse-pipelines
+   trigger: true
  - task: task-sample
    file: concourse-pipelines/task/hello-world/hello-world.yml

今までは点線でした。

f:id:bobchan1915:20181223152716p:plain

すると実線になります。実線=トリガーに設定されてあるということです。

f:id:bobchan1915:20181223154304p:plain

試しにGithubにPushしてみましょう。

Commit IDとかを見れば変更はわかりますが、わかりやすくするためにHello WorldHello Japanに変更します。

git diff

diff --git a/task/hello-world/hello-world.yml b/task/hello-world/hello-world.yml
index d312998..53fec91 100644
--- a/task/hello-world/hello-world.yml
+++ b/task/hello-world/hello-world.yml
@@ -7,4 +7,4 @@ image_resource:

 run:
   path: echo
-  args: [hello world]
+  args: [hello Japan]

です。それではPushしてみます。

f:id:bobchan1915:20181223153944g:plain

知っとくと便利なこと

ローカルからPipelineの出力を取得する

fly watchを使えばローカルからPipeline中のJobのコンテナの出力を取得することができます。

わざわざブラウザを開かなくても取得できるので便利ですね。[パイプライン名]/[ジョブ名]で指定します。

fly -t main watch atomic-pipeline/say-hello-from-repo

結果は以下の通りです。

initializing
hello-world: 751.69 KiB/s 0s
running ls -alR
.:
total 12
drwxr-xr-x    3 root     root          4096 Dec 22 10:41 .
drwxr-xr-x    3 root     root          4096 Dec 22 10:41 ..
drwxr-xr-x    2 root     root          4096 Dec 22 10:41 hello-world

./hello-world:
total 24
drwxr-xr-x    2 root     root          4096 Dec 22 10:41 .
drwxr-xr-x    3 root     root          4096 Dec 22 10:41 ..
-rw-r--r--    1 502      staff          239 Dec 22 10:32 ._hello-world.yml
-rw-r--r--    1 502      staff          239 Dec 22 10:41 ._ls-input.yml
-rw-r--r--    1 502      staff          131 Dec 22 10:32 hello-world.yml
-rw-r--r--    1 502      staff          153 Dec 22 10:41 ls-input.yml
succeeded

特定のJobをトリガーする

trigger: trueにしなければ手動でトリガーしなければJobは実行されません。

fly -t main trigger-job -j hello-world/job-hello-world

のように手動でトリガーをすることもできます。

先ほどのJobの出力を確認する方法と合わせるならば以下のように-wをつけるだけで取得できます。

fly -t main trigger-job -j hello-world/job-hello-world -w

これで同時に出力を取得できます。

VendorをTask間でキャッシュする

例えばgoだとvendorやフロントエンドだとnode_modules/など依存パッケージ用のディレクトリが管理され、容量が大きいためgitignoreされて、CI上でinstallすることが多いでしょう。

Taskごとに毎回毎回やるのは時間がかかり、継続的インテグレーション、デリバリーの観点からは疎遠されます。

そのようなためにもキャッシュする機能があり、以下のようにcache[]で設定します。

caches:
  - path: vendor/

まだいろんな機能がありますが、本記事では書ききれないのでここで終わります。


Spinnakerとの比較

Spinnakerを運用した経験から比較します。

Immutableの実現性

SpinnakerではPipeline TemplateというテンプレートにValueを渡してPipelineを構築し、Immutableにできます。

以下が私が定義したPipeline Templateです。

f:id:bobchan1915:20181223163012p:plain

そして以下のような値を渡します。

f:id:bobchan1915:20181223163056p:plain

するとPipelineを定義できます。

f:id:bobchan1915:20181223162948p:plain

もちろんGUIでSpinnakerならばデプロイメントパイプラインを構築できますが、Muttableになってしまいます。

このSpinnakerのImmutableな機能に対応するConcourseの機能はごく普通のset-pipelineです。ConcourseではGUIで設定を何もできないのでデプロイ時からImmutableです。

fly -t main set-pipeline -c pipelines/pipelines.yml --var private-key=(cat ~/.ssh/id_rsa) -p atomic-pipeline

すると以下のようなものができます。

f:id:bobchan1915:20181223163323p:plain

どっちがよくて、どっちが悪いかはあまりないと思いますが、Spinnakerの方はAPIを通して可逆にパイプラインを構築できる点が強みではないでしょうか。

クラスタ自体の可視化

Spinnakerはクラスタ自体の可視化をすることができます。スクショを撮り忘れたので、公式サイトから拝借します。

f:id:bobchan1915:20181223164240p:plain

それに対してConcourseはクラスタ自体を可視化することはできません。

必要なのかはさておき、違いがあります。

flyCLIツールの強さ

Spinnakerには現在、二つのCLIツールが存在しています。

一つはメンテナンスバージョンであるroerであり、もう一つは開発中のspinです。

github.com

github.com

roerしか使っていませんが、できることコマンドは以下の三つだけです。

command 概要
pipeline パイプラインそれ自体に関するもの
pipeline-template テンプレートに関すること
app アプリケーションに関すること

それに対してConcourseのflyはあらゆることができます。短絡的に「できることが多いからよい」と言っているわけではなくて特徴のセクションでも説明したとおり「開発速度」に影響しています。

バリデーションや特定のジョブを実行できたりすることをはじめ、とても開発しやすいです。

Concourse独自の世界観

これは機能の話ではありませんが、好きな部分です。

KubernetesやSpinnaker、Helmなど、いつも何かしら船だったり、海図だったり、航海に関する用語がプロジェクトの名前になったりします。

しかし、ConcourseはCloud Nativeとターゲットにしていると思います。「アプリケーションが浮遊している」というこような感じで。

コマンドの命名もhijackやCLIツールの名前もflyだったりします。

一番、かわいいなと思ったのが、クラスタからポート転送していて、接続を切った時に以下のようにシートベルトを外したアイコンがでました。完全に虜にされました。

f:id:bobchan1915:20181223164839p:plain


まとめ

Concourseで自らKubernetesの上でCI/CDを管理できるのは、非常に楽しいと思う半分、それ以外の管理が面倒そうだなと思いました。

例えば、Pipelineの管理をどのようにするかです。SpinnakerにはPipeline Templateという機能があり、モジュールのように管理しつつも、Immutableにすることができます。

Pipelineをアプリケーションリポジトリごとに管理していって、共通部分はconcourse-utlisなどの共通リポジトリを使って管理したり、Kuberneteや他の技術もそうですが、オペレーションコストはかかるでしょう。

またリソースの問題もあります。以前、IstioやLinkerdを使ってサービスメッシュを構築しました。サービスメッシュでレイテンシの原因を追求していました。今となってはGCPでは「マネージドIstio」なるものがあるので、記事はあまり参考にはならないかもしれません。

www.1915keke.com

www.1915keke.com

特にIstioはリソースを取るので、マシンタイプやNodeの状態そのものを注意する必要があるのかなと思います。Prometheus + Grafanaなどを使って、可視化し、管理するのも一苦労でしょう。

悩ましい限りです。まだまだ知りたいことが多いConcourseですが、最低限のことはできるようになりました。

これからもCloud Nativeを追っていこうと思います。ありがとうございました!

参考文献

  1. Concourse Pipeline (Kubernetes)

8. Concourse Pipeline (Kubernetes)