迷ったらカレー

技術系の雑多なブログ

Google Kubernetes EngineにNAT付きでConcourse CIをたてる

(この記事は、Recruit Engineers Advent Calendar 2018 16日目の記事です)

こんにちは。リクルートでエンジニアをしている加藤です。 フロントエンドをやったり、バックエンドをやったり、プロダクティビティ的なことをやったりと、いろんなことをやっています。最近は、KubernetesGCPを触る機会が増えてきたのと、あとJenkinsがもういい加減辛くなってきてConcourse CIを触り始めました。つまりこの記事は、 最近やったこと全部盛り な内容になっております。すこし長いですがお付き合いください。

この記事でやること

この記事では、GKE(Google Kubernetes Engine)に、NAT付きでConcourseを立てる方法をご紹介します。今回想定している状況は、

  • ファイヤウォールの内側にいる、プライベートのサービス(GitlabやGithubEnterperiseなど)につなぎに行きたい
  • Concourseには特定のIPからのみ接続を許可したい
  • Concourseへの接続はHTTPSにしたい

というものです。エンタープライズの開発環境では、よくある状況かと思います。 全体構成は以下のようになります。

全体構成
全体構成

この構成を作るため、以下の順で進めていきます。

  1. NAT Gateway付きのGKEクラスタを作る
  2. Let's EncryptのTLS証明書をGKEクラスタで管理する
  3. ConcourseをGKEクラスタ上で動かす
  4. Cloud Armorで接続元IPを制限する

この記事で例示されるコードは、後ほどリポジトリにアップして、記事に追記します。

Concourse CI?

Concourse CIは、pivotalによって開発されている、オープンソースのCIツールです。ググるとJenkins vs Concourseみたいな記事が出るくらいには人気があります。特徴的な点としては、パイプラインの状態可視化と、設定ファイルによる明示的なタスク定義があります。ConcourseCIについては、数ある紹介記事・比較記事にお任せします。個人的にはMediumにある公式の紹介記事が丁寧に書いてあっておすすめです。

参考 - JenkinsとConcourse CIを比較 - Concourse official articles on Medium

0. 前提

⚠️ これ以降の手順は、以下の項目を前提として進みます。ご注意ください ⚠️

  • GCPプロジェクトを持っていること
  • ローカルマシンにGoogle Cloud SDKがインストールされていて、gcloud コマンドが利用可能であること
  • gcloud コマンドでログインしていて、GCPプロジェクトを設定していること
  • ローカルマシンにkubectlがインストールされていて、kubectl コマンドが利用可能であること

1. NAT Gateway付きのGKEクラスタを作る

GKEクラスタからのリクエストの送信元IPを固定するには、NAT Gatewayが必要です。今回、NAT Gatewayの役割を果たしてくれるのは、GCE(Google Compute Engine)のVMインスタンスです。このVMインスタンスNATインスタンスと呼びます。GKEクラスタのアウトバウンドのトラフィックは、全てNATインスタンスを経由するようにします。これによって、GKEクラスタからのリクエストの送信元IPは、NATインスタンスのものになります。プライベートサービスのファイヤウォールには、NATインスタンスのIPを許可する設定を追加してあげれば、GKEクラスタからの接続を許可することができます。

本章では、リソースのデプロイにCloud Deployment Managerを利用します。各リソース定義のフォーマットは、yamlをベースとしてjinja2で書かれています。サポートされているリソース定義については、Cloud Deployment Managerドキュメントのサポートされるリソースタイプから全て辿ることができます。

1.1. ネットワークリソースの定義

ネットワークリソースを定義します。必要なのは、以下の3つです。

構成のベースとなるVPCネットワークは、構成全体を囲むいわゆるグループのようなものです。networkというリソースで表現されます。GKEクラスタのサブネットと、NATインスタンスのサブネットは、ネットワークに属するグループです。subnetworkというリソースで表現されます。

resources:
######## Network ############
- name: devops-nat-network
  type: compute.v1.network
  properties: 
    autoCreateSubnetworks: false
######### Subnets ##########
######### For Cluster #########
- name: devops-subnet 
  type: compute.v1.subnetwork
  properties:
    network: $(ref.devops-nat-network.selfLink)
    ipCidrRange: 172.16.0.0/12
    region: asia-northeast1
########## For NAT Instance ##########
- name: devops-nat-subnet
  type: compute.v1.subnetwork
  properties: 
    network: $(ref.devops-nat-network.selfLink)
    ipCidrRange: 10.1.1.0/24
    region: asia-northeast1

GCPでは、ネットワークはCIDRとリージョンを持たず、サブネットがCIDRとリージョンを持っています。同じネットワークに属するサブネット同士は、CIDRの範囲が被ってはいけないので注意してください。この辺りは少し混乱するかもしれませんが、一旦はグルーピングがしたいんだな、くらいの理解で大丈夫です。さらに詳しくはVirtual Private Cloud(VPC)ネットワークの概要を読んでください。AWSと比較して違いが知りたいという方は【AWSしかやったことない人向け】AWSとGCPのネットワークの違いを理解してみようが非常に丁寧でおすすめです。

この設定で、以下のように枠だけの状態ができました。

枠だけの状態
枠だけの状態

各リソースのnameは適当につけました。今回はConcourseのための構成なので、全てにdevopsというprefixをつけています。後からどんな用途のリソースかわかればいいと思いますので、状況に応じて好きなものをつけてください。

1.2. NATインスタンスとGKEクラスタの定義

########## NAT Instance ##########
- name: devops-nat-vm
  type: compute.v1.instance 
  properties:
    zone: asia-northeast1-a
    canIpForward: true
    tags:
      items:
      - nat-to-internet
    machineType: https://www.googleapis.com/compute/v1/projects/{{ env["project"] }}/zones/asia-northeast1-a/machineTypes/f1-micro
    disks:
      - deviceName: boot
        type: PERSISTENT
        boot: true
        autoDelete: true
        initializeParams:
          sourceImage: https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-9-stretch-v20181113
    networkInterfaces:
    - network: projects/{{ env["project"] }}/global/networks/devops-nat-network
      subnetwork: $(ref.devops-nat-subnet.selfLink)
      accessConfigs:
      - name: External NAT
        type: ONE_TO_ONE_NAT
        ##asia-northeast1リージョンに外部IPアドレスを予約してから入れる
        natIP: 
    metadata:
      items:
      - key: startup-script
        value: |
          #!/bin/sh
          # --
          # ---------------------------
          # Install TCP DUMP
          # Start nat; start dump
          # ---------------------------
          apt-get update
          apt-get install -y tcpdump
          apt-get install -y tcpick 
          iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
          nohup tcpdump -e -l -i eth0 -w /tmp/nat.pcap &
          nohup tcpdump -e -l -i eth0 > /tmp/nat.txt &
          echo 1 | tee /proc/sys/net/ipv4/ip_forward
########## FIREWALL RULES FOR NAT Instance ##########
## アウトバウンドトラフィック
- name: devops-nat-vm-firewall 
  type: compute.v1.firewall
  properties:
    allowed:
    - IPProtocol : tcp
    - IPProtocol : udp
    - IPProtocol : icmp
    - IPProtocol : esp
    - IPProtocol : ah
    - IPProtocol : sctp
    sourceTags: 
    - route-through-nat
    network: $(ref.devops-nat-network.selfLink)
## インバウンドトラフィック
- name: devops-nat-vm-ssh
  type: compute.v1.firewall
  properties: 
    allowed:
    - IPProtocol : tcp
      ports: [22]
    sourceRanges: 
    ## デバッグのため、特定のネットワーク内からのみNATへのSSHを許可
    - YYY.YYY.YYY.YYY/ZZ
    network: $(ref.devops-nat-network.selfLink)
########## GKE CLUSTER CREATION ##########
- name: devops
  type: container.v1.cluster
  metadata: 
   dependsOn:
   - devops-nat-network 
   - devops-subnet
  properties: 
    cluster: 
      name: devops 
      network: devops-nat-network
      subnetwork: devops-subnet
      nodePools:
      - name: default-pool
        initialNodeCount: 1
        config:
          tags:
          - route-through-nat
          # マシンタイプについてはhttps://cloud.google.com/compute/docs/machine-types?hl=ja
          machineType: n1-highmem-2 
          imageType: ubuntu
        autoscaling:
          enabled: true
          minNodeCount: 1
          maxNodeCount: 3
    zone: asia-northeast1-a

この設定で、以下のように枠に箱が入った状態ができました。

枠に箱が入った状態
枠に箱が入った状態

1.3. ネットワークルーティングの定義

########## GKE MASTER ROUTE ##########
- name: devops-master-route
  type: compute.v1.route
  properties:
    destRange: $(ref.devops.endpoint)
    network: $(ref.devops-nat-network.selfLink)
    nextHopGateway: projects/{{ env["project"] }}/global/gateways/default-internet-gateway
    priority: 100
    tags:
    - route-through-nat

########## NAT ROUTE ##########
- name: devops-route-through-nat
  metadata: 
    dependsOn:
    - devops  
    - devops-nat-network
  type: compute.v1.route
  properties: 
    network: $(ref.devops-nat-network.selfLink)
    destRange: 0.0.0.0/0
    description: "route all other traffic through nat"
    nextHopInstance: $(ref.devops-nat-vm.selfLink)
    tags:
    - route-through-nat
    priority: 800

この設定で、以下のように箱の間に道を通すことができました。

箱の間に道が通った状態
箱の間に道が通った状態

1.4. 静的IPアドレスを取得する

静的なIPアドレスを取得します。GCEのVMにデフォルトで割り振られるIPアドレスは動的なもので、インスタンスを再起動したりすると切り替わってしまいます。NATインスタンスの場合、これではあまり嬉しくありません。

静的なIPアドレスを取得する詳しい方法は公式のドキュメントを参照してください。アドレスを取得する際には、先ほど定義したGKEクラスタと同じリージョンを選択します。静的なIPアドレスが取得できたら、1.2 で空欄にしていた、NATインスタンス設定のnatIPに追記します。

取得したIPを許可するよう、プライベートのサービスのファイヤウォールに設定します。これで、クラスタからプライベートのサービスに繋がるようになりました。

プライベートサービスに繋がる状態
プライベートサービスに繋がる状態

1.5. 設定をデプロイする

ここまでで、基本的な構成の設定が完了しました。あとは、これをデプロイするだけです。デプロイには、gcloud コマンドを使います。cluster-with-nat-route.yamlというファイルに上記の設定がひとまとめにされていて、このデプロイにdevops-with-natという名前をつける場合、以下のようなコマンドになります。

$ gcloud deployment-manager deployments create devops-with-nat --config cluster-with-nat-route.yaml

処理には少し時間がかかります。処理が完了したらgcloudコマンドで、ローカルマシンのkubectlをGKEクラスタに繋げます。

$ gcloud container clusters devops get-credentials

1.6. Helmをインストールする

この章の仕上げとして、GKEクラスタHelmが使えるようにセットアップします。 Helmは、Kubernetes用のパッケージマネージャです。これ以降の2章、3章では、GKEクラスタへのデプロイに、Helmを利用します。

まず、ローカルマシンにHelmクライアントをインストールします。手順はリンク先を参照してください。完了すると、helmコマンドが使えるようになります。

GKEクラスタでHelmを利用するには、適切な権限が付与されたサービスアカウントが必要です。Helmのデフォルト設定に従い、tillerという名前でサービスアカウントを作成します。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system

上記設定がhelm.yamlという名前の場合、GKEクラスタへの適用は次のように行います。

$ kubectl apply -f helm.yaml

サービスアカウントが作成できたら、ローカルマシンのhelmコマンドを初期化します。

$ helm init --upgrade --service-account tiller

2. Let's encryptのTLS証明書をGKEクラスタで管理する

GKEクラスタができたので、TLS証明書の準備に移ります。TLS証明書は、Let's Encryptで取得します。GKEクラスタで証明書管理を行う場合、cert-manager という超便利ツールを使います。

2.1. cert-managerをインストールする

cert-managerは、すでにhelmのパッケージが公開されており、コマンド一つでデプロイすることができます。今回は、cert-manager用に新しくnamespaceを切ります。

$ kubectl apply -f k8s/namespace.yaml
$ helm install --name cert-manager --namespace cert-manager stable/cert-manager

2.2. Let's EncryptへTLS証明書作成依頼を発行する

今回、ドメインAWSのRoute53で取得しているという想定です。その他のドメインプロバイダの場合、Issuerspec.acme.dns01の書き方が変わります。dns01でのチャレンジに使えるドメインプロバイダと設定の書き方は、cert-managerのドキュメントに書いてあります。

apiVersion: v1
kind: Secret
metadata:
  name: prod-route53-credentials-secret
  namespace: cert-manager
type: Opaque
data:
  secret-access-key: YOUR_AWS_SECRET_ACCESS_KEY(base64 encoded) 
---
apiVersion: certmanager.k8s.io/v1alpha1
kind: Issuer
metadata:
  name: letsencrypt-prod
  namespace: cert-manager
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your_email_here@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    dns01:
      providers:
        - name: prod-dns
          route53:
            region: ap-northeast-1
            accessKeyID: YOUR_AWS_ACCESS_KEY_ID
            secretAccessKeySecretRef:
              name: prod-route53-credentials-secret
              key: secret-access-key
---
apiVersion: certmanager.k8s.io/v1alpha1
kind: Certificate
metadata:
  name: mycert
  namespace: cert-manager
spec:
  secretName: mydomain-net-tls
  issuerRef:
    name: letsencrypt-prod
  commonName: '*.mydomain.net'
  acme:
    config:
      - dns01:
          provider: prod-dns
        domains:
          - '*.mydomain.net'

上記設定がcert.yamlという名前の場合、GKEクラスタへの適用は次のように行います。

$ kubectl apply -f cert.yaml

証明書の発行には少し時間がかかります。申請状況の経過は次のコマンドで確認できます。

$ kubectl describe cert mycert -n cert-manager

Failed等の表示が途中でみられるかもしれませんが焦らず待ってみてください。完了するとmydomain-net-tlsというSecretが作成されます。作成されたかどうかは、以下のコマンドで確認できます。

$ kubectl get secret -n cert-manager

2.3. namespaceを跨いでワイルドカード証明書を使い回す

ワイルドカード証明書を、クラスタ内のnamespaceを跨いで使いまわしたい、というのは非常にわかりやすい要求です。Let's EncryptのAPIコールには件数制限があるということと、namespaceごとに証明書を取る手間を考えると、誰もがそうしたいと思うでしょう。しかし、cert-managerで取得した証明書をnamespaceを跨いで使うには、少し工夫が必要です。

cert-managerにはClusterIssuerというのがあり、これを使うとクラスタ内の異なるnamespaceで証明書を使いまわせそうに見えるのですが、悲しいことにそうはいきません。このIssueにもある通り、ClusterIssuer自体は使いまわせても、取得した証明書は使いまわせないという悲しい仕様になっています。

そこで、kubedを使います。一度Issuerで取得した証明書の変更を監視しておいて、対象となるnamespaceに都度連携する方式です。

kubedは、helmでインストールすることができます。

$ helm repo add appscode https://charts.appscode.com/stable/
$ helm install appscode/kubed --name kubed --namespace kube-system \
  --set apiserver.enabled=false \
  --set config.clusterName=devops

kubedのインストールが完了したら、先ほど作成されたmydomain-net-tlsに、kubedが監視するためのアノテーションを貼ります。

$ kubectl annotate secret mydomain-net-tls -n cert-manager kubed.appscode.com/sync="app=kubed"

この章の仕上げとして、Concourseをデプロイするnamespace を作り、そこにkubedで証明書を連携します。

apiVersion: v1
kind: Namespace
metadata:
  name: concourse
  labels:
    app: kubed

kubedが連携先として認識するため、labelにapp=kubedを設定しています。kubectlで設定を反映します。

$ kubectl apply -f concourse-namespace.yaml

証明書が連携されているかどうか確認して完了です。

$ kubectl get secret mydomain-net-tls -n concourse

ここまでで、構成は以下のようになっています。だいぶできてきました。

cert-managerで証明書を管理
cert-managerで証明書を管理

3. ConcourseをGKEクラスタにデプロイする

3.1. 静的なIPアドレスを取得してドメインに紐づける

Concourse用に静的なIPアドレスを取得します。1.4.の手順と同じ方法で取得し、concourseという名前をつけます。この名前はConcourseの設定で使います。取得したIPアドレスを、AWS Route53でドメインに紐づけます。今回は、concourse.mydomain.netという名前を紐づけた想定で進めます。

3.2. ソースをfetchして設定を書く

Helmで提供されているConcourseのパッケージに、設定を追加してデプロイします。まずソースをfetchしてきます。

$ helm fetch --untar stable/concourse

新しくconcourseというディレクトリが作られます。この中にある、values.yamlがデフォルトの設定ファイルになります。コピーして持ってきましょう。

$ cp ./concourse/values.yaml ./myvalues.yaml

この中に、変更する箇所がいくつかあります。設定ファイル全体をここで見るのは長すぎるので、ピックアップして見ていきます。設定ファイルには大きく分けてconcourseという項目と、webという項目があります。concourseにはアプリケーション内の設定を書き、webにはkubernetesリソース周りの設定を書くようになっています。

concourse:
  web:
    logLevel: error
    bindPort: 8080
    # IPを紐づけたドメイン
    externalUrl: https://concourse.mydomain.net 
    tsa:
      logLevel: info
      bindPort: 2222
      peerIp: #ここはあとで追加する
web:
  service:
    type: NodePort
  ingress:
    enabled: true
    annotations:
      #取得した静的IPの名前
      kubernetes.io/ingress.global-static-ip-name: concourse
    hosts:
      #IPを紐づけたドメイン
      - concourse.mydomain.net
    tls:
      #cert-managerで取得してkubedで連携した証明書
      - secretName: mydomain-net-tls
      #IPを紐づけたドメイン
        hosts:
          - concourse.mydomain.net

3.2. デプロイして設定をアップデートする

$ helm install --name concourse --namespace concourse -f concourse.yaml ./concourse

concourseのworkerはwebのpeerIpがないとエラーを吐き続けるのですが、これはデプロイ後でないとわかりません。

$ kubectl get svc concourse-web -n concourse
NAME                   TYPE        CLUSTER-IP        EXTERNAL-IP   PORT(S)                         AGE
concourse-web          NodePort    XXX.XXX.XXX.XXX   <none>        8080:30979/TCP,2222:31674/TCP   18s

XXX.XXX.XXX.XXXを、myvalues.yamlpeerIpに設定して再度デプロイします

$ helm upgrade -f myvalues.yaml concourse ./concourse

ここまでで、以下のような状態まできました。あと一息です。

LB経由でConcourseにアクセスできる
LB経由でConcourseにアクセスできる

3.3. 動作確認

Concourse自体はすぐに立ち上がるのですが、LBが立ち上がるまで少し時間がかかります。GCPのコンソール上で状態を確認しつつ、全てのリソースが立ち上がったら、接続できるかどうか確認します。

今回、ユーザ名とパスワードは変更していないので、デフォルト設定のままです。ユーザ名:test、パスワード:testでログインしてみましょう。

webから

https://concourse.mydomain.netにアクセスして、右上にあるloginボタンからフォームを開きます。

Concourse Web ログイン
Concourse Web ログイン

flyから

Concourseでは、flyというCLIを使って設定の操作を行います。ローカルマシンにflyをインストールして、以下のコマンドでログインします。

$ fly -t myci login -c https://concourse.mydomain.net -u test -p test

4. Cloud Armorで接続元IPを制限する

最後に、Cloud Armorを使って、接続元のIPを制限します。Cloud Armorは現在Beta版として提供されているセキュリティサービスです。LBに組み込むことで、GCP上のリソースをDDoS攻撃から守ってくれる他、ファイヤウォールの設定を書くこともできます。本来、kubernetesnetwork policyを書くのが王道だと思いますが、今後設定を使いまわしたいというのと、あと単純に使ってみたかったという理由なので許してください。

設定は、GCPのコンソール上から行うことができます。メニューから、ネットワークセキュリティ > Cloud Armorを選択します。

メニューからCloud Armorを選択
メニューからCloud Armorを選択

ポリシーの作成ボタンを押すと、設定編集画面が開きます。デフォルトアクションを拒否にして、許可ルールを追加していきます。ターゲットへのポリシーの適用で、先ほど作られたLBを選択したら完了です。

Cloud Armor ポリシーの作成
Cloud Armor ポリシーの作成

実際に、許可されたアドレス外からアクセスしようとすると、403 Forbiddenが表示されるので試してみてください。

さて、ここまでで全ての構成が完成しました! 長かった...。お付き合いいただきありがとうございました。

完成
完成

まとめ

この記事では、エンタープライズの開発環境によくある状況を想定して、GKEにConcourse CIを立てる方法をご紹介しました。GCPのネットワーク設定や、cert-managerとkubedの合わせ技による証明書の管理などは、かなりややこしい部分だと思います。かなり駆け足になってしまいましたので、随時記述を補足していきたいと思います。

この記事が何かのお役に立てば幸いです。

※残課題※

以下残課題は、対応完了次第追記します。

  • 公開リポジトリへのソースコードのアップ。この記事で例示したコードをアップします。
  • この問題の対処。今はクライアント証明書に頼りきりなので、ちゃんとIP制限の設定をします。