Kubernetes on AWS: LoadBalancer型 Service との決別

LoadBalancer型 Service (type: LoadBalancer) は、Pod群にアクセスするための ELB を自動的に作ってくれて便利なのだが、ELB に関する全ての設定をサポートしているわけではなく、Service を作り直す度に、k8s の外側でカスタマイズした内容もやり直さなければならないのはつらい。というわけで、type: LoadBalancer を利用するのは止めて、ELB は Terraform で管理し、そこから NodePort型 Service に接続する方法を試してみた。

Kubernetes がサポートする ELB 設定

「ELB に関する全ての設定をサポートしているわけではなく」と書いたが、今現在どれぐらいサポートされているのだろうか? 改めて調べてみた。

Service 定義の metadata.annotations に、以下の値を書くことで ELB の設定を行うことが出来る (v1.5現在)。

  • Backend protocol
    • TCP – default
    • HTTP – service.beta.kubernetes.io/aws-load-balancer-backend-protocol: http
    • HTTPS – service.beta.kubernetes.io/aws-load-balancer-backend-protocol: https
  • SSL Certificate
    • service.beta.kubernetes.io/aws-load-balancer-ssl-cert: "[cert-arn]"
  • SSL Port
    • service.beta.kubernetes.io/aws-load-balancer-ssl-ports: "443"
    • The SSL certificate will only be used for 443 and not 80.
  • Internal ELB
    • service.beta.kubernetes.io/aws-load-balancer-internal: 0.0.0.0/0
  • Security group
    • service.beta.kubernetes.io/load-balancer-source-ranges: [a comma separated list of CIDRs]
  • Idle timeout
    • service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: [seconds]
  • Access logs
    • Enabled – service.beta.kubernetes.io/aws-load-balancer-access-log-enabled: [true|false]
    • Emit interval – service.beta.kubernetes.io/aws-load-balancer-access-log-emit-interval: [minutes]
    • s3://bucket/prefix
      • S3 bucket – service.beta.kubernetes.io/aws-load-balancer-access-log-s3-bucket-name: [bucket-name]
      • S3 prefix – service.beta.kubernetes.io/aws-load-balancer-access-log-s3-bucket-prefix: [prefix]
  • Cross-Zone Load Balancing
    • service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: [true|false]
  • Connection draining
    • service.beta.kubernetes.io/aws-load-balancer-connection-draining-enabled: [true|false]
    • service.beta.kubernetes.io/aws-load-balancer-connection-draining-timeout: [seconds]
  • Proxy protocal
    • service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: '*'
  • Route53

こうやってまとめてみると、v1.5 においては、ELB の設定項目はほとんど網羅されてるような印象を受ける。Route53 についても Third party の拡張を使えばなんとかなるようなので、ほとんどのケースではわざわざ LoadBalancer を別管理にする必要はないのかもしれない。

それでもあえて、LoadBalancer を k8s の外側で管理する理由があるとすれば、

  • k8s に問題が起きた時に、通常の EC2方式に戻せるようにしておきたい。
  • Application Load Balancer (ALB) を使いたい。
  • ELB に分かりやすい名前を付けたい。
    • k8s側から作ると a5902e609eed711e69a1986001d7b1fb みたいなランダムな名前になる。
    • Tag kubernetes.io/service-name みれば、どの Service のものかは分かるのだけど。
  • Cloudwatch Alarm を Terraform で管理したい。
    • ELB に Cloudwatch Alarm を設定する場合は、ELB も Terraform で管理しておいた方がやりやすい。
  • k8s の LoadBalancer 管理に一抹の不安がある。

ぐらいだろうか。

移行手順

AWS 上の Kubernetes クラスタが kops で構築されていることを前提に、ELB を Terraform で 管理する運用に移行してみる。

  • クラスタの名前を仮に k8s.example.com とする。

1. Terraform ELB から k8sノードへアクセス出来るように追加のセキュリティグループを作る

k8sノード用とELB用の二つのセキュリティグループを作る。

# For nodes
resource "aws_security_group" "nodes" {
  vpc_id = "${module.environment.vpc_id}"
  name = "additional.nodes.k8s.example.com"
  description = "Additional security group for nodes"
  tags {
    Name = "additional.nodes.k8s.example.com"
  }
  # ELB から NodePort経由のアクセスを受け付ける
  ingress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    security_groups = ["${aws_security_group.service_elb.id}"]
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# For ELB
resource "aws_security_group" "service_elb" {
  vpc_id = "${module.environment.vpc_id}"
  name = "k8s-service-elb"
  description = "Security group for k8s service ELBs"
  tags {
    Name = "k8s-service-elb"
  }
  ingress {
    from_port = 80
    to_port = 80
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port = 443
    to_port = 443
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

kops 1.5.x 以降では、マスターあるいはノードに対して、デフォルトのセキュリティグループの他に、独自のセキュリティグループを追加することができる。

上で作ったセキュリティグループ(additional.nodes.k8s.example.com)が、ノード起動時に適用されるようにクラスタの設定を更新する。

$ kops edit ig nodes
...
spec:
  additionalSecurityGroups:
  - sg-xxxxxxxx  # additional.nodes.k8s.example.com
...

$ kops update cluster k8s.example.com --yes

Sticky session を必要としない場合

2. Service を NodePort 型で立ち上げる

通常 NodePort の番号は自動で決定されるが、Terraform ELB から接続できるように固定の番号を設定しておく。NodePort に設定出来る番号の範囲は 30000-32767

apiVersion: v1
kind: Service
metadata:
  name: example-app
spec:
  selector:
    app: example-app
  type: NodePort
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
    nodePort: 30000

3. Terraform で ELB を立ち上げる

  • instance_porthealth_checktarget に NodePort を指定する。
  • instances は設定しない(後ほど Auto Scaling group と紐付けるため)。
  • k8sノードへアクセス出来るように、先ほど作ったセキュリティグループ(k8s-service-elb)を設定する。
resource "aws_elb" "example_app" {
  name = "k8s-example-app"
  cross_zone_load_balancing = true
  subnets = ["${split(",", module.environment.subnets)}"]
  security_groups = ["sg-xxxxxxxx"]   # k8s-service-elb
  listener {
    instance_port = 30000
    instance_protocol = "http"
    lb_port = 80
    lb_protocol = "http"
  }
  listener {
    instance_port = 30000
    instance_protocol = "http"
    lb_port = 443
    lb_protocol = "https"
    ssl_certificate_id = "${module.environment.ssl_certificate_id}"
  }
  health_check {
    healthy_threshold = 3
    unhealthy_threshold = 2
    timeout = 5
    target = "HTTP:30000/healthcheck"
    interval = 30
  }
}

4. ELB にドメイン名を設定する

サービスのドメイン名が、たった今作った ELB を参照するように Route53 を設定する。

resource "aws_route53_record" "example_app" {
  zone_id = "${module.environment.zone_id}"
  name = "app.example.com"
  type = "CNAME"
  ttl = "300"
  records = ["<elb-dns-name>"]
}

5. ELB をノードの Auto Scaling Group に紐付ける

3 で立ち上げた ELB を、k8sノードの Auto Scaling Group (nodes.k8s.example.com) の Load Balancers に追加する。これによりk8sノードがELBに追加される。

6. 全ての k8sノードが ELB に追加されて、InService になることを確認する

サービスのURLにアクセスして、サービスが問題なく稼動していることを確認する。

Sticky session を必要とする場合

アプリケーションが sticky session を必要とする場合は、予め ingress controller をクラスタにインストールしておき、そこ経由でサービスにアクセスさせる。なので、このケースではアプリケーション用の ELB は立てない。

参考: Kubernetes on AWS で sticky session を実現する | ゆびてく

今回の移行では、ingress controller の入り口となる Service を NodePort で作っておいて、そこにアクセスする ELB を Terraform で作った。

2. Service を ClusterIP 型で立ち上げる

apiVersion: v1
kind: Service
metadata:
  name: example-app
spec:
  selector:
    app: example-app
  type: ClusterIP
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

3. Ingress controller にドメイン名を追加する

サービスのドメイン名が ingress controllerの ELB を参照するように Route53 を設定する。

resource "aws_route53_record" "example_app" {
  zone_id = "${module.environment.zone_id}"
  name = "app.example.com"
  type = "CNAME"
  ttl = "300"
  records = ["<ingress-controller-elb-dns-name>"]
}

4. Ingress を追加する

Ingress controller から対象のサービスにアクセス出来るように ingress rule を追加する。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: sticky-session-ingress
spec:
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /
        backend:
          serviceName: example-app
          servicePort: 80

5. サービスが問題なく稼動していることを確認する

サービスのURLにアクセスして、サービスが問題なく稼動していることを確認する。

Kubernetes Secrets の紹介 – データベースのパスワードやその他秘密情報をどこに保存するか?

なんてこったい(棒)

GitHub の Public リポジトリには、太っ腹な開発者によって大量の Credentials(外部サービスに接続するための秘密キーなど)が公開されており、賢い人たちが日夜クローラーを走らせてそれらを回収し、5万件もの Uber ドライバーの個人情報を頂戴するために利用したり高価な AWS のインスタンスを沢山立ち上げて、もの凄い勢いでビットコインを発掘したりしているらしい。

ウチのリポジトリはプライベートだから問題ないよねって思われる方もおられるかもしれないが、ほんの5分間違って公開しただけで流出したケースもあるらしいので、そもそもコードリポジトリに秘密情報を入れること自体が太っ腹行為の可能性を高めていることを理解しておきたい。

というわけで、Kubernetes でサービスを運用する場合、そういった秘密情報をどこに保存すれば良いかという要求に応えるのが Secrets という仕組みである。

kube-secrets

秘密情報を Secrets というデータベースで集中管理し、それぞれの情報はそれらを必要とする Pod/Container のみに送られる。Docker Image や Container を作るプロセスから秘密情報を切り離せるので、その過程で情報を漏洩させるリスクは少なくなる。Container に送られた秘密情報は tmpfs 上に置かれるので、ノード上のディスクに書き込まれることもない。

Kubernetes 自体がまだ若いプロジェクトなので、この Secrets にも注意しなければならない点がいくつかある。

  • Secrets のデータは etcd の中に平文で保存されているので、etcd には管理者ユーザーだけがアクセス出来るようにセットアップする必要がある。
    • etcdは kubernetes のあらゆるデータを保管しているデータストア。
  • 現在のところ、Secrets に対するユーザーごとのアクセスコントロールは出来ない(将来的にはサポート予定)。

以下は、社内向けに書いた Secrets の簡単なチュートリアル。


Secret のデータ構造

Secret の中身は単純な Key-Value ペアのリスト:

secret-structure

kubectl コマンドで登録されている Secret のリストを見る:

$ kubectl get secrets
NAME                  TYPE                                  DATA      AGE
default-token-pb7ls   kubernetes.io/service-account-token   3         21m
mysecret              Opaque                                2         38s

その中から一つの Secret を選んで中身を見てみる:

$ kubectl describe secret mysecret
Name:       mysecret
Namespace:  sandbox
Labels:     <none>
Annotations:    <none>

Type:   Opaque

Data
====
password:   12 bytes
username:   5 bytes

mysecret の内容を図に書くと以下のような感じ:

mysecret

Secret を登録する

Secret は、以下の二種類のファイルのいずれかを経由して登録できる。

  1. 中身が Value になっているファイル(便宜的に「Secret Value ファイル」と呼ぶ)
  2. YAML あるいは JSON 形式の Kubernetes Manifest ファイル

以下のような Secret を、

[Secret: test-secret] => [Key: password] => [Value: this-is-a-password]

それぞれのファイル形式で登録してみよう。

1. Secret Value ファイル経由

1) ファイルを作る

$ echo -n "this-is-a-password" > ./password

2) --from-file オプションを使って登録

$ kubectl create secret generic test-secret --from-file=./password
secret "test-secret" created

3) 中身を見てみる

$ kubectl describe secrets/test-secret
Name:       test-secret
Namespace:  sandbox
Labels:     <none>
Annotations:    <none>

Type:   Opaque

Data
====
password:   18 bytes

--from-file に指定したファイルの名前が Key になっていることが分かる。

2. Kubernetes Manifest ファイル経由

1) Value を base64 でエンコードする

$ echo -n "this-is-a-password" | base64
dGhpcy1pcy1hLXBhc3N3b3Jk

2) ファイルを作る

以下の内容を secret.yaml に保存:

apiVersion: v1
kind: Secret
metadata:
  name: test-secret
type: Opaque
data:
  password: dGhpcy1pcy1hLXBhc3N3b3Jk

3) -f オプションを使って登録

$ kubectl create -f ./secret.yaml

4) 中身を見てみる

$ kubectl describe secrets/test-secret
Name:       test-secret
Namespace:  sandbox
Labels:     <none>
Annotations:    <none>

Type:   Opaque

Data
====
password:   18 bytes

Secret Value ファイル経由のときと全く同じ Secret が出来ていることが分かる。

Secret の中身を取得する

$ kubectl get secret test-secret -o yaml
apiVersion: v1
data:
  password: dGhpcy1pcy1hLXBhc3N3b3Jk
kind: Secret
metadata:
  creationTimestamp: 2017-03-01T08:49:49Z
  name: test-secret
  namespace: sandbox
  resourceVersion: "12535581"
  selfLink: /api/v1/namespaces/sandbox/secrets/test-secret
  uid: 0747637f-fe5c-11e6-8f7a-0674330dcd09
type: Opaque

Value をデコードする:

$ echo "dGhpcy1pcy1hLXBhc3N3b3Jk" | base64 --decode
this-is-a-password

Secret を使う

Container から Secret を使うには、

  1. 環境変数
  2. Volume としてマウント

の二種類の経路がある。

1. 環境変数

Container の Manifest から以下のような感じで参照:

containers:
- name: http-debug-server
  image: cotoami/http-debug-server:latest
  ports:
  - containerPort: 3000
  env:
    - name: PASSWORD
      valueFrom:
        secretKeyRef:
          name: test-secret
          key: password

Container にログインして、環境変数を見てみる:

$ kubectl get pods
NAME                                 READY     STATUS    RESTARTS   AGE
http-debug-server-4073343574-ro8s8   1/1       Running   0          2m

$ kubectl exec -it http-debug-server-4073343574-ro8s8 /bin/sh

# echo $PASSWORD
this-is-a-password

2. Volume としてマウント

Container 内のディレクトリに、Key をファイル名、Value をファイルの中身としてマウントできる。

以下のように、volumes を定義しておいて、それを volumeMounts でマウントする:

containers:
- name: http-debug-server
  image: cotoami/http-debug-server:latest
  ports:
  - containerPort: 3000
  volumeMounts:
  - mountPath: /tmp
    name: test-secret
    readOnly: true
volumes:
- name: test-secret
  secret:
    secretName: test-secret

Container にログインして、マウントされたファイルを見てみる:

$ kubectl get pods
NAME                                 READY     STATUS    RESTARTS   AGE
http-debug-server-2197190929-q6lke   1/1       Running   0          1m

$ kubectl exec -it http-debug-server-2197190929-q6lke /bin/sh

# cat /tmp/password
this-is-a-password

マウントされた Secret を後から更新した場合は、Kubernetes が自動的に検出してリフレッシュしてくれる。

参考

Kubernetes の登場でインフラ担当の仕事はますます曖昧になっていくような気がする

アプリケーション開発者は Dockerfile を作るところまで、インフラ担当はサービス構成とデプロイメントパイプラインを整備する、という分担を考えていたけれど…

マニフェストファイルを作ってサービスの構成をデザインしたり、ビルドスクリプトを書いて自動デプロイを実現するのも、もうアプリケーション開発者のレベルで出来る。ここを無理に分業すると、むしろ k8s が提供するサイクルの短さを享受出来なくなる可能性がある。サービスが出来上がってからインフラを移行するというのは k8s の考え方に合わないので、開発の初期から k8s 上で育てるのが標準的なモデルになるが、そのモデルに則って開発して行けば、開発者は無理なくそのプラットフォームを使いこなせるようになる。

つまり、DevOps の問題が、Kubernetes によるマイクロサービス化によって、ますます深刻になる可能性がある。アプリケーション更新のサイクルとインフラ更新のサイクルが限りなく近づいたとき、そこにどこまでのコミュニケーションコストを許容出来るだろうか。

チームとして分かれていることのコミュニケーションコストは決して過小評価することは出来ない。

では、インフラチームは Kubernetes クラスタの管理やモニタリングを担当するようにしたらどうか?

筋的には悪くない気もするが、GKE のような環境が充実してきた時に、アプリケーション開発者が自身で出来る領域はさらに広がって行くのではないかと思う。

さらには、Kubernetes のような環境に適応出来る・出来ない、という形でも大きな分断が起きて行きそうな予感もある。

Kubernetes に限らず、インフラの自動化を進めるためには、アプリケーションパッケージのポータビリティが重要になってくる。しかし、そのようなことを意識して開発しているプロジェクトは案外少ないのではないだろうか。例えば、Twelve-Factor App みたいな指針を全く知らないというのも珍しくないのではないか。そうなると、インフラ担当はアーキテクチャやソフトウェアデザインを指導する立場になるが、これは結構広範な指導を必要とするし、そのような規律を快く思わないエンジニアも多い。

一方で、Twelve-Factor App みたいな指針に慣れているエンジニアは、上に書いたように、自分でアーキテクチャや自動化の基盤を作って行けるので、自律してサービスを開発出来る。

アプリ開発もインフラも、一つのチームに閉じることに越したことはないけれど、多くの組織ではそんな贅沢は許されないだろう。組織全体でインフラを刷新していくためには、どうしても独立したインフラチームが必要になる。でも、マイクロサービス開発のサイクルの短さに合わせて、チーム間のコミュニケーションコストを下げて行くのは至難の業のように思える。

DevOpsの起源とOpsを巡る対立 | ゆびてく

Docker(コンテナ型仮想化)と Kubernetes についての簡単な紹介

社内向けに書いた文書です。

コンテナ型仮想化とは何か?

OS上に、コンテナと呼ばれる、隔離されたアプリケーションの実行環境を作り、一台のホスト上であたかも複数のホストが動いているかのような環境を実現するのが、コンテナ型仮想化技術です。

コンテナ型仮想化と従来の VM 型仮想化を比較したものが以下の図です(Xen は AWS EC2 などの仮想化を実現する実装で、Docker はコンテナ型仮想化の代表的な実装)。

container

これらの仮想化技術に共通するのは、

  1. アプリケーションの実行環境(ホスト)を仮想化すること
  2. それらのスナップショットをイメージとして保存することで、アプリケーションを環境ごとパッケージング出来るようにすること

という二つの目的です。

VM 型仮想化ではハードウェアのレベルで仮想化が実現されているので、ホスト上で動く一つ一つの VM の中でそれぞれ別々の OS を動作させることができます。しかしその一方で、実行環境(VM)のサイズが大きくなり、アプリケーションの起動にかかる時間も長くなります。

コンテナ型仮想化では OS 上の実行環境を仮想化するので、OS 自体は限定されますが、その分実行環境(コンテナ)のサイズはコンパクトになり、起動にかかる時間も短くなります。

基本的に VM 型仮想化からコンテナ型仮想化への移行は、一つ一つのアプリケーションの粒度が小さくなる、いわゆるマイクロサービス化を促進します。インフラ上の更新の単位はより小さくなり、変更のサイクルは短くなります。

パッケージング技術の変遷

packaging

Kubernetes とは何か?

1. Docker 実行環境をクラスタ化する

通常、Docker の実行環境は一台のホストに閉じています。

docker-network

同一ホスト内で動くコンテナ同士は、プライベートネットワーク経由でやり取りができますが、ホストの外側とやり取りする場合は NAT (IP Masquerade) を経由する必要があります。

このように、標準の Docker 実行環境では、ホスト間の連携が煩雑になるため、コンテナの数が増えて要求されるリソースが大きくなった時に、容易にスケールアウトすることが出来ません。

この問題を解決するのが Kubernetes です。

Kubernetes によって、複数台のホストから構成される実行環境を あたかも一台の実行環境のように 扱うことができるようになります。

kube1

コンテナを起動する際は、イメージと台数を指定するだけでよく、クラスタのどこにどのように配置するかは Kubernetes 側で面倒を見てくれます(スケジューリング)。

そして、クラスタのリソース(CPU、メモリ、記憶領域など)が足りなくなった場合は、単純にノードを増やすだけで、既存のサービスに影響を与えることなく、いくらでも拡張することができます。

2. Self-healing – 耐障害性

kube2

上図はデプロイプロセスの詳細です。

  1. オペレーターが、どのようなコンテナを何台起動するかといった情報を Spec として Kubernetes 側に渡すと、
  2. Scheduler が、空きリソースを見ながらそれらをどのように配置するかを決定し、
  3. 各ノードに常駐している Kubelet というプログラムがその決定に従ってコンテナを起動します

オペレーターが直接コンテナを起動するのではなく、必要とする状態を Spec として渡すと、Kubernetes 側がクラスタの状態を Spec に合わせようする、というこの挙動が重要です。

仮に、運用中のコンテナに不具合があってサービスがダウンしたとします。Kubernetes はこの状態変化を察知し、Spec の状態に合わせようとして、そのコンテナを自動的に再起動します(Self-healing)。

多少の不具合であれば問題なく運用出来てしまう反面、問題の発覚するタイミングが遅れてしまう可能性もあるのでモニタリングが重要になります。

コンテナではなく、ノードとなるホストマシンに障害があった場合はどうなるか? Kubernetes 環境のセットアップによりますが、AWS の場合は Auto Scaling グループでクラスタが組まれているので、自動的にインスタンスが作り直され、その中に元の状態が復元されます。

3. Pod – 管理上の基本単位

Kubernetes 上で動作するプログラムの最小単位はコンテナですが、管理上の基本単位は Pod というものになります。

pod

Pod は、Volume という記憶領域を共有するコンテナの集まりで、Volume の他には一つのIPアドレスを共有しています。つまり、Pod は Kubernetes 上でホストに相当する単位です。

VM イメージによるパッケージングでは、一つのホストがデプロイの単位になっていました。あるいは、Java EE の WAR パッケージだと、必要最低限のプログラムだけをデプロイの単位に出来て軽量ですが、上図で言うと、デプロイの対象は webapp の部分だけになります。Docker/Kubernetes 環境でマイクロサービス化を推進すると、ホストを構成する様々な部品(webapp, nginx, log collectorなど)全てを、独立してデプロイ出来るようになります。

kubectl というコマンドがセットアップされていれば、以下のようにして Pod の一覧を見ることができます。

$ kubectl get pods
NAME                                 READY     STATUS    RESTARTS   AGE
cotoami-2660026290-81y2g             1/1       Running   0          9d
cotoami-2660026290-xzua4             1/1       Running   0          9d
default-http-backend-t4jid           1/1       Running   0          6d
grafana-807516790-8pszk              1/1       Running   0          63d
http-debug-server-2355350955-38jht   1/1       Running   0          6d
http-debug-server-2355350955-c64y1   1/1       Running   0          6d
http-debug-server-2355350955-g3rjq   1/1       Running   0          6d
nginx-ingress-controller-ka0od       1/1       Running   0          6d
node-exporter-556yr                  1/1       Running   0          63d
node-exporter-euprj                  1/1       Running   0          63d
node-exporter-hzdqk                  1/1       Running   0          63d
prometheus-1314804115-9v0yk          1/1       Running   0          54d
redis-master-517881005-ceams         1/1       Running   0          70d

コンテナのデバッグ

Pod 内のコンテナにログインしたい場合は、リストにある Pod の名前をパラメータにして以下のコマンドを実行します。

$ kubectl exec -it http-debug-server-2355350955-38jht /bin/sh
/app # ls
Dockerfile    README.md     circle.yml    index.js      node_modules  package.json

Pod に複数のコンテナが含まれる場合は、-c オプションでコンテナの名前を指定します。

$ kubectl exec -it POD -c CONTAINER /bin/sh

あるいは、以下のコマンドでコンテナが出力するログを見ることができます。

$ kubectl logs redis-master-517881005-ceams
1:M 16 Feb 03:27:35.080 * 1 changes in 3600 seconds. Saving...
1:M 16 Feb 03:27:35.081 * Background saving started by pid 226
226:C 16 Feb 03:27:35.084 * DB saved on disk
226:C 16 Feb 03:27:35.084 * RDB: 0 MB of memory used by copy-on-write
1:M 16 Feb 03:27:35.181 * Background saving terminated with success

# 複数のコンテナがある場合
$ kubectl logs POD CONTAINER 

4. Deployment – Pod の配備と冗長化

Pod の配備と冗長化を担当するのが Deployment という仕組みです。

deployment

ある Pod について、Spec で定義されたレプリカの数を維持する責任を負うのが Replica Set、Replica Set の配備・更新ポリシーを定義するのが Deployment です。

以下は、Deployment 定義の例です。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: http-debug-server
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: http-debug-server
    spec:
      containers:
      - name: http-debug-server
        image: cotoami/http-debug-server:latest
        ports:
        - containerPort: 3000

この内容を、例えば http-debug-server.yaml というファイルに保存し、以下のコマンドを実行すると Deployment として定義された Replica Set がクラスタ内に出来上がります。

$ kubectl create -f http-debug-server.yaml

$ kubectl get deployments
NAME                DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
http-debug-server   3         3         3            3           6s

Rolling Update

Deployment には標準で Rolling Update(無停止更新)の機能が備わっています。

例えば、以下のようなコマンドで Docker イメージのバージョンを更新すると、Replica Set の中のコンテナ全てを一斉に更新するのではなく、稼働中の Pod を何台か維持したまま、一つずつ順番に更新を行います。

$ kubectl set image deployment/DEPLOYMENT CONTAINER=IMAGE_NAME:TAG

5. Service – Pod へのアクセス

Pod への安定的なアクセス手段を提供するのが Service です。

Deployment で配備した Pod にアクセスしようと思っても、実際に Pod がどのノードに配備されているかは分かりません。仮に分かったとしても、Pod は頻繁に作り直されるので、いつまでも同じ Pod にアクセスできる保証はありません。Replica Set に対するロードバランシング機能も必要です。

ClusterIP

そこで Service は、Pod の集合(一般的には Replica Set)に対して安定的にアクセスできる仮想の IP アドレスを割り当てます。これを cluster IP と呼び、Kubernetes クラスタ内だけで通用するアドレスです。cluster IP にアクセスすると、Service の対象となる Pod 群の中のいずれかの Pod にアクセスできます。

NodePort

さらに Service は、クラスタの外部から Pod にアクセスするための経路も開いてくれます。具体的には、各ノードの特定のポートを経由して Pod にアクセス出来るようになります。

service

上図のように、Service の実体は、cluster IP のルーティングとロードバランシング機能を実現する kube-proxy というプログラムです。

クラスタの外部から Service が用意してくれたポートを経由してノードにアクセスすると、kube-proxy 経由で適切な Pod に接続することができます。

Kubernetes on AWS で sticky session を実現する

  • 対象の環境
    • Kubernetes 1.4.x(筆者の環境は 1.4.6)
    • AWS

AWS 上で稼動する Kubernetes (k8s) で LoadBalancer 型の Service を作ると、自動的に入り口となる ELB (Elastic Load Balancer) を立ち上げてくれてとても便利なのだが、AWS では当たり前に出来ていた sticky session が、この構成だとどうも実現できないらしい。

k8s で新規に開発しようという場合なら、sticky session が必要な実装はなるべく避けるのかもしれないが、稼働中の Web アプリを k8s に移行したいとなった場合に sticky session が必要になるケースもあるのではないだろうか。

というわけで、k8s on AWS における sticky session の実現方法について調べてみた。

諸悪の根源 kube-proxy

まず、単に ELB の sticky session を有効にしたらどうなるだろうか?

[ELB] = sticky => [Node] => [kube-proxy] => [Pod]

ELB から kube-proxy までが、Service の内部構造である。まず ELB が選択するのは k8s クラスタの各ノードである。ELB が sticky に設定されている場合、ノードの選択が sticky になるだけで、その先の Pod の選択は kube-proxy に委ねられている。

もう少し詳しく見てみると、

k8s-sticky-session

こんな感じで、ELB が sticky になってても意味はなく、Pod の選択を行う kube-proxy が sticky にならないといけない。

Service の sessionAffinity 機能

実は k8s の Service (kube-proxy) には sessionAffinity という機能がある。これを有効にすれば IP アドレスベースの stickiness を実現できるらしいのだが、なんと type が LoadBalancer の場合はこの機能を利用出来ないという。

ClientIP value for sessionAffinity is not supported when service type is LoadBalancer.

IPアドレスが隠れちゃう問題

では、LoadBalancer 型を利用するのはやめて、NodePort 型の Service にして、sessionAffinity を有効にし、自前で ELB を立てたらどうなるか?

その場合も、ELB がクライアントの IP アドレスを隠してしまうので sessionAffinity は正しく動作しない。現状の kube-proxy は、ELB がサポートしている X-Forwarded-For ヘッダや、proxy protocol を解釈しないので、[ELB] → [kube-proxy] の構成だとどう頑張っても sticky session を実現できないことになる。LoadBalancer 型の Service が sessionAffinity をサポートしない所以だ。

Proxy protocol のサポートについては多くのリクエストがあるようなので、将来的にはサポートされる可能性が高い。もしサポートされれば IP アドレスベースの stickiness については標準構成で利用出来るようになる。

解決策 1) ELB と kube-proxy の間に nginx を立てる

kube-proxy は X-Real-IP ヘッダを解釈するらしい。これを利用して、ELB と kube-proxy の間に nginx を立てて、そこで X-Real-IP ヘッダを追加するという手段が紹介されていた(ちなみに、ELB は X-Real-IP を追加してくれない)。

以下のような構成になる。

[ELB] = proxy protocol => [nginx] = X-Real-IP => [kube-proxy]

解決策 2) Nginx Ingress Controller

解決策 1) も悪くないように思えるが、IP アドレスベースの stickiness だと Web アプリとしてはあんまり嬉しくないので、なんとか諸悪の根源である kube-proxy をバイパスする方法がないだろうかということで見つけたのが、最近サポートされ始めたばかりの Ingress を使う方法だ。

Ingress とは、Service に対する柔軟なルーティングやロードバランシング機能を提供する k8s では比較的新しいリソースで、機能的には LoadBalancer 型の Service を切り出して、独立したリソースにしたような感じである。

Ingress を利用するためには、まず Ingress Controller というものを k8s 内に立ち上げておく必要がある。Ingress Controller の実体は proxy server であり、その proxy server に適用するルールのセットを Ingress リソースと呼ぶ。

ingress-controller

Ingress Controller には色々な実装が提供されていて、その中でも k8s の公式実装と思われる Nginx Ingress Controller が sticky session をサポートするために kube-proxy をバイパスするらしい。まさに今回のニーズにぴったしな実装だと言える。

The NGINX ingress controller does not uses Services to route traffic to the pods. Instead it uses the Endpoints API in order to bypass kube-proxy to allow NGINX features like session affinity and custom load balancing algorithms. It also removes some overhead, such as conntrack entries for iptables DNAT.

Nginx Ingress Controller のセットアップ

実際に Nginx Ingress Controller をセットアップする手順は以下の通り。必要なマニフェストファイル一式は、cotoami-infraのリポジトリ に置いてある。

1) まず、ルーティング先が見つからなかった場合にリクエストを送るための、デフォルトのバックエンドサービスを立てる。このサービスは単に 404 を返すだけである。

$ kubectl create -f https://raw.githubusercontent.com/cotoami/cotoami-infra/master/kubernetes/ingress/default-backend.yaml

2) Ingress Controller を作る前に、ConfigMap による設定を先に作っておく。

$ kubectl create -f https://raw.githubusercontent.com/cotoami/cotoami-infra/master/kubernetes/ingress/nginx-load-balancer-conf.yaml

この ConfigMap の中に、sticky session を有効にする設定 enable-sticky-sessions: "true" が含まれている。

3) 設定が登録出来たら、主役の Ingress Controller を作る。

$ kubectl create -f https://raw.githubusercontent.com/cotoami/cotoami-infra/master/kubernetes/ingress/ingress-controller.yaml

上のマニフェストファイル ingress-controller.yaml では、Ingress Controller に外部からアクセス出来るように、LoadBalancer 型の Service をくっつけてある。これで、ELB 経由で Ingress Controller にアクセス出来るようになる。

(※) Controller本体は、公式の例に倣って ReplicationController で作成しているが、Daemonset で作ることも出来る。

以下のように Service の一覧を見ると、nginx-ingress-controller へアクセスするための Service が登録されているのが分かる。

$ kubectl get svc
...
nginx-ingress-controller   100.66.116.210   a5902e609eed6...   80/TCP     20s
...

a5902e609eed6... と省略表示されているのが ELB の DNS name である。試しにこのアドレスにアクセスしてみると、

default backend - 404

という感じで、先ほど立ち上げておいたデフォルトサービスの応答が得られるはずだ。

4) Ingress をテストするための、テスト用 Web アプリを立ち上げる。

Ingress 経由でアクセスする Web アプリを立ち上げる。ここでは、HTTPやサーバーに関する情報を表示するだけの http-debug-server を立ち上げてみる。

$ kubectl create -f https://raw.githubusercontent.com/cotoami/cotoami-infra/master/kubernetes/ingress/http-debug-server.yaml

マニフェストファイルにあるように、Nginx Ingress Controller 経由でアクセスする場合、Service の type は ClusterIP にしておく。

5) Ingress リソースを登録する

最後にルーティングルールを登録する。

$ kubectl create -f https://raw.githubusercontent.com/cotoami/cotoami-infra/master/kubernetes/ingress/ingress.yaml

このルールでは、ホスト debug.example.com に対するアクセスを、http-debug-server Service にルーティングするように設定している。以下のように、curl コマンドでアクセスすると、デバッグ用の情報がレスポンスとして返されるはずだ。

$ curl <ELB-DNS-name> -i -H 'Host: debug.example.com'

sticky session をテストするためには cookie が必要になるので、以下のようにしてレスポンスヘッダーを表示させる。

$ curl <ELB-DNS-name> -I -H 'Host: debug.example.com'

以下のような感じで、ヘッダーに cookie の情報が含まれているので、

...
Set-Cookie: route=acfc2f2f4d692d7a90b9797428615e29d3936e95; Path=/; HttpOnly
...

この cookie をリクエストに含めて送信する。

$ curl -b route=acfc2f2f4d692d7a90b9797428615e29d3936e95 <ELB-DNS-name> -H 'Host: debug.example.com' -s | python -mjson.tool

レスポンスの JSON データが出力されると思うが、hostname のところに注目して欲しい。何度リクエストを送っても、同じホスト名が返ってくるはずだ。試しに-b オプションを外して送信すると、送信するたびにホスト名が変わる。

大分ややこしい感じになったが、以上が Ingress で sticky session を実現するための手順である。

解決策 3) service-loadbalancer

その他、k8s が提供している service-loadbalancer を使うことで sticky session を実現する事例が紹介されていた。

Elm沼とKubernetes沼で交互に溺れているうちに1月が終わっていた

ElmCotoami のユーザーインタフェースを作るために選択したプログラミング言語で、そのデザインは JavaScript 界で一世を風靡している Redux の原型になったと言われている。その原型を体験してみたいという単純な好奇心だけで Elm を選択し、分かりやすいチュートリアルに感心したのも束の間、実際にアプリケーションを作り始めると、入り口ではあんなに優しかった Elm の顔がみるみる般若のようになり、気がついたら底なしの泥沼に足を取られていた。

Elm のような、いわゆる純粋な関数型と呼ばれる環境では、言語からコンピューターを直接操作することが出来ない。出来るのはデータの変換だけだ。この変換がいわゆる「関数」で、関数型でプログラマーに許されているのは関数を書くことだけである。しかし、コンピューターを操作出来ないと何も出来ないのでどうするのかと言うと、コンピューターへの命令をデータとして表現して、それを返す関数を作って環境に渡すみたいなことをする。

関数型にはこのような制限があるので、従来の命令型言語でプログラムを書く場合に比べて、かなり回りくどい書き方をしなければならない。しかしその代償として、プログラムは必ず「データの変換」という形に落とし込まれるので、実行時に意図しない動作をすることが格段に少なくなり、デバッグやテストも容易になるというわけだ。

関数型の回りくどさに加えて、Elm には厳密な型システムがある。実は今、開発スピードを減退させている最大の原因はこの型システムである。代数的データ型とか、今まで遭遇したことのないコンセプトに純粋な感動を覚える一方、曖昧さを許さない型システムのおかげで、型を合わせるためにどうすれば良いかというのを考えてるだけで膨大な時間が過ぎて行く。

動的型が、書く時にいかに楽をするか(少ない記述でいかに多くを実現するか)を主眼に置いているとすれば、静的型は、書いた後にいかに楽をするか(起こりうることを明示的に示しておく)ことに主眼を置く。しかし、動的型でも、曖昧に書いて楽をしつつも、危ないと思うところは慎重に書いたり、テストでフォローしたりするわけなので、単純に上のような図式が当てはまるとも思えず、どちらが良いと判断するのはなかなか難しい。動的型の言語であれば、場所によって手綱を締めたり緩めたり出来るのが、静的型だと一律で同じような書き方をしなければならない、というのがなかなか辛いところである。

まだ言語自体に慣れてないということもあるし、これから開発が進んで、規模が大きくなり、複数人で連携するようになったら関数型や型システムの恩恵を実感出来るのかもしれないなと思いつつ、最初の敷居はやっぱり高かったということだけここに記録しておく。

一方の、Kubernetes 沼の方はと言えば、こちらは Cotoami で試行錯誤した成果を仕事の方にフィードバックしたいということで、プロダクションへの導入を目指して色々と準備しているため、Elm よりもより深い深い泥沼となっている。Elm の場合はとりあえずユーザーインタフェースが動けばなんとかなるのに対して、インフラの場合はその辺のごまかしが効かないので、あらゆる側面から検証を行わなければならず、人材の不足も手伝ってかなり余裕のない状況に陥ってる今日この頃(もし、同じように Kubernetes で試行錯誤している方がいたら、情報交換したいなあと思うのですが… @marubinotto までご連絡お待ちしております(切実))。

Kubernetes は未来のインフラだとの意を強くする一方(やっと10年ぐらい前のGoogleに追いついただけとも言える)、アジャイルでない組織に導入してもメリットはあまりないだろうなとも思う。Kubernetes は単なるインフラというよりも、サービスがどのように開発・運用されるべきかという思想が強烈に埋め込まれた環境だと言った方が良いかもしれない。

Kubernetes によって、いわゆるデリバリーサイクルは極限まで短縮され、システムの柔軟性もかつてないレベルで実現出来るようになる。しかしその一方、運用やモニタリングにかかるコストは従来より高いように感じられるし、Kubernetes 自体も高速で進化していくので、そこにキャッチアップ出来る人材も確保する必要がある。例えば、運用において、MTTR (Mean-Time-To-Recovery) よりも、MTBF (Mean-Time-Between-Failures) の方を重視するような組織だと、なかなかこの機動性をメリットだと感じるのは難しいだろう。

既に開発が落ち着いていて安定運用されているサービスを Kubernetes に移行するメリットは、おそらくほとんどない。基本的には開発の初期から導入するのが望ましい。Cotoami はリスクのないオープンソース開発なので、その辺の事情で迷う必要がなく、一番始めのハリボテアプリの段階から Kubernetes で本番 ( https://cotoa.me/ ) の運用を始めて、自動デプロイなどの仕組みも整備出来た。

去年まで、ウチのチームでは、AMI (Amazon Machine Image) のように VM 上で動くホストがパッケージングの単位になっていた。パッケージを作るのも、パッケージをデプロイするのもうんざりするほど時間がかかる。それが Kubernetes/Docker によって、ホストの中で動く一つ一つのプロセスがパッケージングの単位となり、それらの小さな部品を組み合わせてホスト(Kubernetes では Pod と呼ばれている)を作り、それらをまた組み合わせて大きなWebサービスを作るという形に変わり、一度の更新も最小限で高速、しかも無停止ということで、新しい時代の到来を感じずにはいられない。

Kubernetes で実現する Phoenix/Elm アプリのホットデプロイ自動化完全詳解(2016年12月版)

今年の初頭に「Phoenixアプリのホットデプロイ完全自動化」の記事を書いてから一年が過ぎようとしている。この自動化は Elixir/Erlang の Hot swapping 機能を利用していて、git push から10分以内でデプロイが完了するという、当時としてはそこそこ満足のいく達成だったのだが、こんな不具合や、exrm (Elixir Release Manager) 作者の「hot upgrades はあんまりオススメ出来ない発言」などを見るにつけ、これを本番で使うのはちょっと辛いかもしれないと思うようになった。

今回、Cotoami プロジェクト を始めるに当たって、前々から気になっていた Google の Kubernetes(クバネテス)を試してみようと思い立った。そして実際に自動化の仕組みを構築してみて、その簡単さと仕組みの先進さに驚いた。言語に依存しないマイクロサービスのパッケージングと、それらを組み合わせて簡単にスケーラブルなWebサービスを構築できる仮想環境。これで本格的にコンテナの時代が来るんだなという新しい時代の訪れを感じずにはいられない。

というわけで、以下では Kubernetes を使った自動化の詳細について紹介したいと思う。この仕組みの全貌は Cotoami プロジェクトの一部として公開しているので、興味のある方は以下の GitHub プロジェクトを覗いて頂ければと思う。



 

Kubernetes とは何か?

Kubernetes が提供する仕組みは Container Orchestration と呼ばれている。Container Orchestration とは、Docker のようなコンテナ(アプリケーションを実行環境ごとパッケージングする仕組み)で実現されている小さなサービス(マイクロサービス)を組み合わせて、より大きなサービスを作るための仕組みである。

今では、Webサービスを複数のサービス(プロセス)の連携として実現することが当たり前になって来ている。次第に細かくなりつつあるこれらのサービスを扱う時の最大の障害が従来型の重い仮想化だ。例えば、Amazon Machine Images (AMI) のような従来型の仮想化技術を使ってサービスを更新する場合、イメージをビルドするのに20分から30分程度、更にそれを環境にデプロイするのに10分以上かかってしまう。自動化も容易ではない。サービスの数が多くなるほどに時間的なペナルティが積み重なってしまい、マイクロサービスのメリットを享受するのは難しくなる。なので、実際はマシンイメージをデプロイの単位にすることはせずに、言語やフレームワーク固有のパッケージに頼ったデプロイを行っている現場が多いのではないだろうか。

これらの問題を一挙に解決しようとするのが、Docker のような軽い仮想化と、それらをまるでソフトウェアモジュールのように組み合わせることを可能にする Container Orchestration 技術である。

 

Kubernetes を最短で試す

複数サービスの連携を、ローカルマシンで簡単に試せるというのも Kubernetes のようなツールの魅力だ。Kubernetes には Minikube というスグレモノのツールが用意されていて、ローカルマシン上に、お手軽に Kubernetes 環境を立ち上げることが出来る。

以下では、Mac OS X での手順を紹介する。

1. VirtualBox をインストールする

筆者の環境:

$ vboxmanage --version
5.1.8r111374

2. Minikube をインストールする

$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/v0.12.2/minikube-darwin-amd64 && chmod +x minikube && sudo mv minikube /usr/local/bin/

$ minikube version
minikube version: v0.12.2

3. Kubernetes を操作するためのコマンドツール kubectl をインストールする

$ curl -Lo kubectl https://storage.googleapis.com/kubernetes-release/release/v1.3.0/bin/darwin/amd64/kubectl && chmod +x kubectl && sudo mv kubectl /usr/local/bin/

4. Minikube を起動する

$ minikube start
Starting local Kubernetes cluster...
Kubectl is now configured to use the cluster.

以下のような情報を見れれば、準備は完了。

$ kubectl cluster-info
Kubernetes master is running at https://192.168.99.101:8443
KubeDNS is running at https://192.168.99.101:8443/api/v1/proxy/namespaces/kube-system/services/kube-dns
kubernetes-dashboard is running at https://192.168.99.101:8443/api/v1/proxy/namespaces/kube-system/services/kubernetes-dashboard

$ kubectl get nodes
NAME       STATUS    AGE
minikube   Ready     5d

5. サンプルプロジェクトをデプロイしてみる

Kubernetes には色んなサンプルプロジェクトが用意されているが、ここでは Guestbook という簡単なアプリを試してみる。

以下のファイル(guestbook-all-in-one.yaml)を適当な場所に保存して、

https://github.com/kubernetes/kubernetes/blob/master/examples/guestbook/all-in-one/guestbook-all-in-one.yaml

以下のコマンドを実行してデプロイする。

$ kubectl create -f guestbook-all-in-one.yaml 
service "redis-master" created
deployment "redis-master" created
service "redis-slave" created
deployment "redis-slave" created
service "frontend" created
deployment "frontend" created

これによって、以下の3つの Deployments と、

$ kubectl get deployments
NAME           DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
frontend       3         3         3            3           5m
redis-master   1         1         1            1           5m
redis-slave    2         2         2            2           5m

それぞれの Deployments に対応する3つの Serviceskubernetesはシステムのサービスなので除く)が出来上がっていることが分かる。

$ kubectl get services
NAME           CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
frontend       10.0.0.118   <none>        80/TCP     7m
kubernetes     10.0.0.1     <none>        443/TCP    6d
redis-master   10.0.0.215   <none>        6379/TCP   7m
redis-slave    10.0.0.202   <none>        6379/TCP   7m

簡単に説明すると、Deployment は一つのマイクロサービスのクラスタに対応し、Service はそのクラスタへのアクセス手段を提供する。

たったこれだけの手順で、冗長化された Redis をバックエンドにした、アプリケーションの環境が出来上がってしまった。構成の全ては guestbook-all-in-one.yaml というテキストファイルに定義されている。

早速ブラウザでアクセスして試してみたいところだが、デフォルトの設定だとサービスが Kubernetes の外部には公開されていないので、frontendサービスの設定をちょっと書き換えて(guestbook-all-in-one.yaml に以下のような感じで type: NodePort の行を追加する)、

apiVersion: v1
kind: Service
metadata:
  name: frontend
  labels:
    app: guestbook
    tier: frontend
spec:
  ports:
  - port: 80
  type: NodePort
  selector:
    app: guestbook
    tier: frontend

以下のコマンドを実行して設定ファイルの更新を環境に適用する。

$ kubectl apply -f guestbook-all-in-one.yaml 

更新が完了したら、以下のコマンドでアプリケーションのURLを知ることが出来る。

$ minikube service frontend --url
http://192.168.99.101:31749

以下のようなページが表示されただろうか?

guestbook

6. お片づけ

先ほどのサンプルプロジェクトで作ったリソースは、以下のコマンドで全部削除出来る。

$ kubectl delete -f guestbook-all-in-one.yaml

Minikube の停止は以下。

$ minikube stop

 

Phoenix/Elm アプリの Docker イメージを作る

さて、Cotoami の話に戻ろう。Cotoami では、以下のような構成で自動化を実現しようとしている。

cotoami-auto-deploy

CircleCI 上のビルドで Docker イメージをビルドして Docker Hub にリリース。その後、AWS上に構築した Kubernetes に更新の命令を出して、新しいイメージでアプリケーションの Rolling Update(無停止デプロイ)を行う。

この仕組みを構築するためには、まず Phoenix/Elm アプリケーションを Docker でパッケージングするための Dockerfile を用意する必要がある。しかし、ここで気をつけなければならないのは、パッケージングそのものよりも、CircleCI 上でどうやって Phoenix/Elm アプリケーションをビルドするかという問題である。

Elixirアプリケーションは、クロスコンパイル・ビルドが出来るという説明もあるが、実行環境とビルド環境は合わせておいた方が良いというアドバイスもよく見かけるので、Cotoami ではよりトラブルが少なそうな、環境を合わせるアプローチを取ることにした。

今回の例では、実行環境も Docker 上になるので、まずビルド用の Docker イメージを用意しておき、それを使ってアプリケーションのコンパイルとテストを行い、その後、そのイメージをベースにしてアプリケーションをパッケージングするという、docker build の二段構え方式でビルドを実施する。

まずは、以下の Dockerfile で Phoenix/Elm アプリのビルド環境を作る。

一度作ったイメージは、CircleCI のキャッシュディレクトリに入れておき、後々のビルドで使い回せるようにしておく。この辺の設定は全て circle.yml に書く。

アプリケーションのコンパイルとテストが終わったら、ビルド用のイメージをベースにして、アプリケーションのパッケージングを行う。そのための Dockerfile が以下である。

これらの組み合わせで、git push する度に、Docker Hub にアプリケーションのイメージがリリースされるようになる(Docker Hub に docker push するために、CircleCI に 認証用の環境変数を設定しておくこと: DOCKER_EMAIL, DOCKER_USER, DOCKER_PASS)。

参考: Continuous Integration and Delivery with Docker – CircleCI

 

AWS上に Kubernetes 環境を作る

アプリケーションの Docker イメージが用意出来たら、それを動かすための Kubernetes 環境を作る。今回は AWS 上に Kubernetes 環境を構築することにした。

Kubernetes から kops という、これまた便利なツールが提供されていて、これを使うと簡単に環境を構築出来る。

1. kops のインストール

Mac OS の場合:

$ wget https://github.com/kubernetes/kops/releases/download/v1.4.1/kops-darwin-amd64
$ chmod +x kops-darwin-amd64
$ mv kops-darwin-amd64 /usr/local/bin/kops

2. Kubernetes 用のドメイン名を用意する

ここが比較的厄介なステップなのだが、kops による Kubernetes 環境はドメイン名を名前空間として利用する仕組みになっている。具体的には、Route 53 内に Kubernetes 環境用の Hosted zone を作る必要がある。

例えば、立ち上げようとしているWebサービスのドメインが example.com だとすれば、k8s.example.com のような専用の Hosted zone を用意する(k8s は Kubernetes の略称)。

Cotoami の場合、AWS のリソースは出来るだけ Terraform を利用して管理することにしているので、Terraform で Hosted zone を設定する際の例を以下に置いておく。

resource "aws_route53_zone" "main" {
  name = "example.com"
}

resource "aws_route53_zone" "k8s" {
  name = "k8s.example.com"
}

resource "aws_route53_record" "main_k8s_ns" {
  zone_id = "${aws_route53_zone.main.zone_id}"
  name = "k8s.example.com"
  type = "NS"
  ttl = "30"
  records = [
    "${aws_route53_zone.k8s.name_servers.0}",
    "${aws_route53_zone.k8s.name_servers.1}",
    "${aws_route53_zone.k8s.name_servers.2}",
    "${aws_route53_zone.k8s.name_servers.3}"
  ]
}

主ドメインとなる example.com の Hosted zone について、サブドメイン k8s の問い合わせを委譲するような NS レコードを登録しておくのが味噌。

以下のコマンドを叩いて、DNSの設定がうまく行っているかを確認する。

$ dig NS k8s.example.com

上で設定した4つの NS レコードが見えれば OK。

3. kops の設定を保存するための S3 bucket を作る

kops は、Amazon S3 上に保存された構成情報に基づいて環境の構築・更新などを行う。というわけで、予めそのための S3 bucket を作っておき、その場所を環境変数 KOPS_STATE_STORE に設定する。

$ aws s3 mb s3://kops-state.example.com
$ export KOPS_STATE_STORE=s3://kops-state.example.com

これで、準備は完了。いよいよ Kubernetes の環境を立ち上げる。

4. Kubernetes の設定を生成する

新しい環境の名前を staging.k8s.example.com として、以下のコマンドで新規環境の設定を生成する。生成された設定は先ほどの S3 bucket に保存される。

$ kops create cluster --ssh-public-key=/path/to/your-ssh-key.pub --zones=ap-northeast-1a,ap-northeast-1c staging.k8s.example.com

Kubernetes ノードにログインするための ssh キーや、ノードを展開する Availability Zone などを指定する。細かいオプションについては、以下を参照のこと。

デフォルトでは、以下のような構成の環境が立ち上がるようになっている。

  • master (m3.medium)
  • node (t2.medium * 2)

5. Kubernetes 環境を立ち上げる

Kubernetes 環境を AWS 上に立ち上げる。単純に以下のコマンドを実行すれば良いのだが、

$ kops update cluster staging.k8s.example.com --yes

Terraform の設定ファイルを生成するオプションもあるので、Cotoami ではその方法を取ることにした。

$ kops update cluster staging.k8s.example.com --target=terraform

$ cd out/terraform
$ terraform plan
$ terraform apply

生成されたデフォルトの構成から、セキュリティグループなどをより安全な設定にカスタマイズすることもあると思われるが、これらのファイルは自動生成によって更新される可能性があることに注意する必要がある。ファイルを直接編集すると、新しく生成したファイルに同じ変更を施すのを忘れてしまう可能性が高い。なので、AWS のコンソール上で直接カスタマイズした方が良いかもしれない(新しい設定ファイルとの齟齬は terraform plan の時に気づける)。

どのようなファイルが生成されるか興味のある方は、Cotoami のリポジトリを覗いてみて欲しい。

環境を立ち上げる過程で、kop によって kubectl の設定も自動的に追加されている。以下のコマンドを実行すれば、AWS上の環境に接続していることが確認できるはずだ。

$ kubectl cluster-info
Kubernetes master is running at https://api.staging.k8s.example.com
KubeDNS is running at https://api.staging.k8s.example.com/api/v1/proxy/namespaces/kube-system/services/kube-dns

 

Kubernetes 上にアプリケーションをデプロイする

Kubernetes の準備は整ったので、後はアプリケーションをデプロイするだけである。Minikube のところでサンプルアプリをデプロイしたのと同じように、サービスの構成情報を YAML ファイルに定義しておき、kubectl create コマンドでデプロイを行う。

Cotoami の構成ファイルは以下に置いてある。

$ kubectl create -f deployment.yaml
$ kubectl create -f service.yaml

設定ファイルの仕様については Kubernetes のサイトを参照して頂くとして、内容自体は単純だということはお分かり頂けると思う。deployment.yaml では、アプリケーションの Docker イメージ名やクラスタを構成するレプリカの数、ポート番号などが指定されている。service.yaml では、そのサービスを外部にどのように公開するかという設定がされており、面白いのは type: LoadBalancer と書いておくと、AWS の ELB が自動的に作成されてアプリケーションのエンドポイントになるところだろうか。

 

デプロイ自動化をビルド設定に組み込む

最初のデプロイが無事に成功すれば、無停止更新の仕組みは Kubernetes 上に用意されている。後はそれを利用するだけである。

CircleCI から Kubernetes にアクセスするためには、以下のような準備が必要になる。

  1. kubectl のインストール
  2. kubectl の設定
    • ensure-kubectl.sh では、環境変数 S3_KUBE_CONF に設定された Amazon S3 のパスから kubectl の設定ファイルをビルド環境にコピーする。
    • Kubernetes on AWS を構築する過程でローカルに出来上がった設定ファイル ~/.kube/config を S3 にコピーして、その場所を CircleCI の環境変数 S3_KUBE_CONF に設定する。
      • この設定ファイルには、Kubernetes にアクセスするための credential など、重要な情報が含まれているので、取り扱いには注意すること!
    • CircleCI 側から S3 にアクセスするためのユーザーを IAM で作成して最低限の権限を与え、その credential を CircleCI の AWS Permissions に設定する。

これらの設定が完了すれば、ビルド中に kubectl コマンドを呼び出せるようになる。Cotoami の場合は、circle.ymldeployment セクションに、以下の二行を追加するだけで自動デプロイが行われるようになった。

https://github.com/cotoami/cotoami/blob/auto-deployment/circle.yml

- ~/.kube/kubectl config use-context tokyo.k8s.cotoa.me
- ~/.kube/kubectl set image deployment/cotoami cotoami=cotoami/cotoami:$CIRCLE_SHA1

長くなってしまったが、以上が自動化の全貌である。

組織やチームの話を追いかけると、プログラミングは基礎教養なんだって話に辿り着いてしまう件

まさにそんな感じの話がInfoQで紹介されていた。

Elixir試飲録 (5) – Elixir/Phoenixのホットデプロイ完全自動化(2016年1月版)

いよいよ先月から、たまチームの新規プロジェクトとして Elixir/Phoenix によるWebサービスの開発が始まった。というわけで、今回はたまチームでの事例第一弾として、この半月で構築したホットデプロイ自動化の仕組みについて紹介したい。まだプロジェクトも初期段階なので未熟な部分も多いが、これまで Elixir/Phoenix に触れたことがない人でも、その雰囲気を感じて頂ければ幸いである。

 

自動化の全体像

開発を始めるに当たって、まず最初にやっておきたいのが CIとデプロイの仕組みを整備することである。これまでたまチームは Java系の言語で開発を行っていたのだが、サービスを停止させずにプログラムを更新する方法として Blue-Green Deployment 手法を採用していた。CIのビルドでマシンイメージを作り、デプロイの際はそれらのイメージから新しいサーバーを立ち上げて、ロードバランサーの向き先を旧バージョンのサーバー群から新バージョンに切り替えるという手順だ(「Immutable Infrastructureを導入する」)。この、マシンレベルで切り替える方法であれば、言語や環境に依存せずに無停止デプロイを実現できる。

blue-green

Immutable Infrastructureの恩恵もあり、これまでこの方法で比較的安全に運用出来てはいたのだが、マシンイメージのビルドやデプロイに時間がかかるのが悩みの種であった(ビルドからデプロイまでトータルで20分ぐらいかかる)。また、マシンイメージを使った Blue-Green Deployment を完全に自動化するのも難しかったため、誰でも気軽にデプロイというわけにもいかず、若干の手順を共有する必要があった。仮にUIを微調整するだけで20分かつ若干の手作業となると、そういった微調整的な更新を本番に適用する機会は先延ばしされることが多くなる。そして、そのようにしてデプロイの粒度が大きくなって行くと、プロジェクトは減速してしまう。

Elixir/Phoenix で是非とも試したいと思っていたのが、Hot Code-Swapping によるアプリケーションのホットデプロイ(無停止更新)である。アプリケーション(のラインタイム)にホットデプロイの仕組みが備わっているなら、わざわざビルドの度にマシンイメージを作ってマシンごと入れ替える必要もなくなるし、自動化も遥かにやりやすいだろう。

結論から言えば、半月ほど試行錯誤した結果、ホットデプロイ完全自動化の形はなんとか出来上がった。ビルドからデプロイまでにかかる時間も簡単なPhoenixアプリで5、6分程度にまで削減された。今のところステージング環境だけの運用でトラブル無く更新出来ているが、本番で同じように運用出来るかは開発を進めながら様子を見ないとなんとも言えないところである。

以下が今回の自動化の全貌である。

elixir-phoenix-deploy

Developer Machine(左下)でプログラムを更新して、git push すると、CircleCI上でビルドが行われ、最終的には App Server 上で稼働中のアプリケーションが停止すること無く更新されるところまで、全自動で行われる。

図中の赤文字の部分が今回の自動化を実現しているパーツである。以降のセクションでは、(1) から順番に、これらのパーツを一つ一つ仔細に見て行く。

(※) 今回の環境構築に利用した言語やフレームワークのバージョンは以下の通りである。

  • Elixir 1.2.1 (実行環境はUbuntu用の最新版パッケージが用意されてなかったので 1.2.0)
  • Phoenix 1.1.3
  • exrm 1.0.0-rc7
  • conform 1.0.0-rc8
  • Node.js 5.1.0 (CircleCIで利用出来る最新版)

 

(1) mix.ex – バージョン番号の自動生成と Elixir Release Manager の導入

Elixirには標準で Mix というビルドツールが同梱されている。色んなタスクコマンドが提供されていて、Elixirのコードをコンパイルしたり、依存関係を自動で取得してくれたりと、Java の Maven や、Node.js の npm に相当するツールである。プロジェクトをビルドするためには、Maven の POMファイル、あるいは npm の package.json のようなプロジェクト定義ファイルが必要となる。それが以下の mix.exs ファイルだ。

defmodule ExampleApp.Mixfile do
use Mix.Project
def project do
[
app: :example_app,
version: version(),
elixir: "~> 1.2.0",
elixirc_paths: elixirc_paths(Mix.env),
compilers: [:phoenix, :gettext] ++ Mix.compilers,
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
aliases: aliases,
deps: deps
]
end
defp version do
{{year, month, day}, {hour, minute, _}} = :calendar.local_time()
version = :io_lib.format("~4..0B.~2..0B~2..0B.~2..0B~2..0B", [year, month, day, hour, minute])
|> List.flatten
|> to_string
File.write! "VERSION", version
version
end
def application do
[
mod: {ExampleApp, []},
applications: [
:phoenix,
:phoenix_html,
:cowboy,
:logger,
:gettext,
:phoenix_ecto,
:postgrex,
:conform,
:conform_exrm
]
]
end
defp elixirc_paths(:test), do: ["lib", "web", "test/support"]
defp elixirc_paths(_), do: ["lib", "web"]
defp deps do
[
{:phoenix, "~> 1.1.3"},
{:phoenix_ecto, "~> 2.0"},
{:postgrex, "0.10.0"},
{:phoenix_html, "~> 2.3"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.9"},
{:cowboy, "~> 1.0"},
{:exrm, "~> 1.0.0-rc7", override: true},
{:conform, "~> 1.0.0-rc8", override: true},
{:conform_exrm, "~> 0.2.0"}
]
end
defp aliases do
["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"]]
end
end

view raw
mix.ex
hosted with ❤ by GitHub

mix.exs で定義されている内容は他言語の場合とほとんど変わりない。ここでは今回の自動化に関連する箇所に絞って説明して行く。

バージョン番号の自動生成

7行目で、このプロジェクト(:example_app という名前が付いている)のバージョンを定義しているのだが、通常 0.0.1 のように固定のバージョン番号を書くところを、自前のversionという関数(18行目)を指定して、ビルド時点でのタイムスタンプからバージョン番号を自動生成するようにしている。git pushする度にデプロイ可能な新しいパッケージをビルドするので、その都度手動でバージョン番号を書き換えるのはめんどうだし、ライブラリと違ってアプリのバージョンを人力で決定する意義は少ないように思う。

Elixirアプリのバージョン番号は、Semantic Versioning Specification (SemVer) に従わなければならないので、version 関数を見ると、タイムスタンプを無理矢理その形式に変換しているのが分かるだろう。このやり方で本当に問題ないか、いささか心許ないところもあるのだが、今のところは問題なく機能しているようだ。

バージョン番号を生成する時に、その番号を VERSION というファイルに書き出している(23行目)。このファイルはビルド用のシェルなどがバージョン番号を参照する時などに利用する。

Elixir Release Manager

47行目の deps 関数でプロジェクトが利用する外部モジュール(依存関係)を定義する。ここに指定されている :exrm が、今回のホットデプロイの主役を務める Elixir Release Manager だ。exrm を依存関係に追加して mix do deps.get, compile すれば、リリース用のパッケージをビルドするためのタスク mix release が利用出来るようになる。

$ mix help | grep release
mix release             # Build a release for the current mix application
mix release.clean       # Clean up any release-related files
mix release.plugins     # View information about active release plugins

実は、今回の自動化環境を作る際の苦労の多くは、この exrm に起因している。現時点ではまだ version 1.0 がリリースされてないので、成熟してないが故の制限がいくつかあった:

  • プロジェクトの依存関係を漏れなく application 関数にも指定しておかなければならない
    • exrm – Common Issues – https://hexdocs.pm/exrm/extra-common-issues.html
    • application に指定しておかないと、リリースパッケージをビルドする時に依存モジュールを知る術が無いらしい。しかし、既に書いたように deps にも依存関係を定義しているので、これはDRY原則に反する。
    • しかも困ったことに、推移的な依存についても application に指定しなければならないケースがある(依存先のモジュールの application に指定されてない場合)
  • configの内容を動的に決定することができない
    • 今回最も悩まされた問題がこれである。通常、Elixirでは設定ファイルの中で、System.get_env("HOGEHOGE") のような形で関数を使い、環境変数を参照することが出来る。ところが、exrmでビルドされたパッケージだとこれが実行時に評価されず、ビルド時に評価されてしまうという問題がある。この問題の対策については以降のセクションで説明する。

参考文献

Conform – 設定ファイルのテキストファイル化

deps 関数には、:conform:conform_exrm という依存モジュールが追加されている(57, 58行目)。この Conform は、exrm と同じく Paul Schoenfelder 氏によって開発されている Elixir のコンフィグレーションを支援するためのツールだ。

基本的にElixirプロジェクトの設定情報は config/config.exs というファイルに書くことになっている。拡張子から分かるように設定内容は Elixir のコードとして表現される。以下は config.exs の簡単なサンプルだ。

use Mix.Config
config :example_app,
key1: "value1",
key2: "value2"
import_config "#{Mix.env}.exs"

view raw
config.exs
hosted with ❤ by GitHub

最後の import_config で環境ごとの設定ファイルを読み込んでいる。具体的には、開発環境用の dev.exe と自動テスト実行時用の test.exs、そして本番用の prod.exs が、それぞれ config ディレクトリの中に用意されている。

設定内容をElixirコードで表現出来るので、単なる値だけではなく式などを指定することも出来る。Elixirのコーディングに慣れているプログラマーにとってはとても便利な仕組みであるが、 Elixirに慣れていないオペレーターがコンフィグレーションを行う場合、あまり理解しやすい形式とは言えないかもしれない。この問題に対処するためのツールが Conform である。

Conformには、以下の二つの機能がある。

  1. Elixirの設定ファイルを Key-Value 形式のシンプルなテキストファイルに変換する(その際、変換ルールを定義したスキーマファイルも同時に生成される)
  2. Key-Value 形式の設定ファイルをアプリケーション起動時に読み込んで Erlang の設定に変換する

:conformdeps に追加して、mix do deps.get, compile すれば、mixに以下のような関連タスクが追加される。

$ mix help | grep conform
mix conform.configure   # Create a .conf file from schema and project config
mix conform.effective   # Print the effective configuration for the current project
mix conform.new         # Create a new .schema.exs file for configuring your app with conform

 

Conformを利用して、アプリケーション起動時(あるいはアップグレード時)に設定を適用する

さて、今回のプロジェクトで Conform を採用したのは Key-Value 形式の設定ファイルが欲しいからではなく、exrm のセクションで言及した、設定内容を実行時に決定出来ない(環境変数を利用出来ない)問題に対処するためである。

exrm の作るリリース用パッケージというのは、結局のところ、Erlang用のパッケージである。リリースやデプロイの仕組みが Erlang によって提供されているので、当然と言えば当然のことなのだが、このパッケージをビルドする際に、Elixirの設定ファイルをErlang用の設定ファイル(sys.config)に変換する必要があり、このときにElixirの設定ファイルが評価されてしまうのである。

設定内容を環境変数で与えたいと思っても、それがビルド時に評価されてしまうため、本番デプロイ時に設定することができない。これはかなり重大な問題である。ビルド時に評価されるとなると、データベースのパスワードのような機密情報をElixirの設定ファイルに直接書かなければならず、しかもビルド対象とするためにそれを GitHub 上に上げなければならなくなる。

上記リンクの三番目、Issue #90 では exrmへの機能追加として、環境変数経由で設定出来るような仕組みを提供するという予告がなされているが、そこから一年が経過して、この Issue はまだ Open のままである。

Erlang用の設定ファイル(sys.config)の中では、Elixirのように関数などを利用することは出来ず、基本的に静的な値しか保持出来ない。ところが、Issue #90 の中で RELX_REPLACE_OS_VARS という環境変数を設定すれば、sys.config の中でも環境変数を参照出来るようになるよ、という情報を見つけ、これだ! と思って試してみると、確かにアプリケーションを起動した時には環境変数を参照出来ているが、ホットデプロイの時には参照出来ない…

最終手段として、デプロイ時にリリースパッケージ内にある sys.config を直接編集してしまえば良いのだが、そのままでデプロイできるパッケージ(*.tar.gz形式のアーカイブ)を展開して処理するのには抵抗があった。色々と調べ回って Conform の設定ファイルはリリースパッケージと同じディレクトリに置いておけば起動時に適用されることが分かり、最終的にこの Conform を利用する方法に辿り着いた。

コンフィグレーションの経路を図示すると以下のような感じになる。

elixir-config

設定内容は、アプリケーションサーバーを立ち上げる時に Cloud-init 経由で渡すようにし、新しいバージョンのリリースをデプロイする度にその設定を適用する。設定内容を変更したい場合は、直接 app.conf を編集するか、cloud-boothook.sh を変更してアプリケーションサーバーを作り直す(基本的に後者の方が好ましい)。cloud-boothook.sh は、Terraform の構成ファイルと共に同じ GitHub のリポジトリで管理されているが、リポジトリ内では暗号化されている。アプリケーションサーバーの更新をする際に、作業マシン上で復号化して Cloud-init に渡す、という流れである。

実際には以下のような内容のファイルを暗号化してリポジトリに入れている。

#cloud-boothook
#!/bin/sh
# Application config
cat <<EOF > /opt/example_app/app.conf
logger.level = info
Endpoint.url.host = "example.com"
Endpoint.url.port = 443
Endpoint.secret_key_base = "rRy7Q2mUhmcgYPJfN06DiQKaUM99CDvnQGVyP4b2Zm47bHalfjZbkCe/l2oHGzmC"
Repo.username = "dbuser"
Repo.password = "dbpass"
Repo.database = "exampledb"
Repo.hostname = "database-host.com"
Repo.pool_size = 20
EOF

view raw
cloud-boothook.sh
hosted with ❤ by GitHub

 

(2) circle.yml – ビルド全体の流れ

ビルド・デプロイ自動化の中心にあるのが CircleCI というCIサービスで、そこでのタスクを定義するのが circle.yml というファイルである。この circle.yml の内容を見れば、今回の自動化の全体的な流れが分かる。

machine:
timezone:
Asia/Tokyo
services:
docker
node:
version: 5.1.0
dependencies:
cache_directories:
~/docker
override:
cp ~/.ssh/id_circleci_github ci/docker/github.pem
if [[ -e ~/docker/image.tar ]]; then docker load -i ~/docker/image.tar; else cd ci/docker && docker build -t trusty .; fi
if [[ ! -e ~/docker/image.tar ]]; then mkdir -p ~/docker; docker save trusty > ~/docker/image.tar; fi
cd deploy && bundle install
test:
override:
docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'service postgresql start && cd /build && ./ci/test.sh'
deployment:
master:
branch: master
commands:
./ci/deploy-to-staging.sh # ensure that the latest version is running in staging
./ci/get-prev-rel.sh
docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'cd /build && ./ci/build-release.sh'
|
PACKAGE="./rel/example_app/releases/`cat VERSION`/example_app.tar.gz"
DEST="s3://example-app-packages"
aws s3 cp "${PACKAGE}" "${DEST}/`cat VERSION`.tar.gz"
aws s3 cp "${PACKAGE}" "${DEST}/latest.tar.gz"
aws s3 cp ./VERSION "${DEST}/VERSION"
./ci/deploy-to-staging.sh

view raw
circle.yml
hosted with ❤ by GitHub

 

(3) Dockerfile – ビルド環境と本番環境を合わせる

exrm が生成するリリースパッケージは、Erlangのランタイムを含む完全な All-In-One パッケージである。つまり、このパッケージファイルさえあれば、Erlang や Elixir の環境を用意しなくてもそのままアプリケーションを動かすことが出来る。これは環境構築の手間を省けて便利なのだが、一方でマルチプラットフォームには対応できない。例えば、MacでビルドしたものがLinuxで動かないのはもちろんだが、同じLinuxであってもディストリビューションやバージョンが異なると動かない可能性がある。試しに、CircleCIの環境(Ubuntu 12.04 (precise))でビルドしたものを Amazon Linux 上で動くかどうかテストしてみたが OpenSSLバージョンの違いが原因で動かなかった。

というわけで、exrm のリリースパッケージをビルドする環境は、アプリケーションが稼働する環境と合わせる必要がある。ビルドはCircleCIに任せたいが、本番環境をCircleCIに依存させるのは嫌なので、CircleCI上で本番環境に近いDockerコンテナを立ち上げ、その中でビルドを行うことにした。

今のところ、本番環境には Ubuntu 14.04 (trusty) を利用している。その本番向けのビルドを行うための Dockerfile が以下である。

FROM ubuntu:trusty
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update -q
RUN apt-get -y install language-pack-ja openssl libssl-dev ncurses-dev curl git
RUN update-locale LANG=ja_JP.UTF-8
# SSH
RUN mkdir -p /root/.ssh
RUN echo "Host github.com\n\tStrictHostKeyChecking no\n" >> /root/.ssh/config
ADD github.pem /root/.ssh/id_rsa
RUN chmod 700 /root/.ssh/id_rsa
# Node.js
RUN apt-get -y install rlwrap && \
curl -o /tmp/nodejs.deb https://deb.nodesource.com/node_5.x/pool/main/n/nodejs/nodejs_5.5.0-1nodesource1~trusty1_amd64.deb && \
dpkg -i /tmp/nodejs.deb && \
rm -rf /tmp/nodejs.deb
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
# Erlang and Elixir
RUN curl -o /tmp/erlang.deb http://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb && \
dpkg -i /tmp/erlang.deb && \
rm -rf /tmp/erlang.deb && \
apt-get update -q && \
apt-get install -y erlang-base=1:18.2 erlang-dev=1:18.2 erlang-eunit=1:18.2 erlang-parsetools=1:18.2 erlang-xmerl=1:18.2 elixir=1.2.0-1 && \
apt-get clean -y && \
rm -rf /var/cache/apt/*
RUN mix local.hex –force && mix local.rebar –force
# PostgreSQL
RUN apt-key adv –keyserver keyserver.ubuntu.com –recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get -y -q install python-software-properties software-properties-common \
&& apt-get -y -q install postgresql-9.3 postgresql-client-9.3 postgresql-contrib-9.3
# Alter postgres user for test
USER postgres
RUN /etc/init.d/postgresql start \
&& psql –command "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';"
USER root

view raw
Dockerfile
hosted with ❤ by GitHub

Ubuntu 14.04 のイメージをベースに、Erlang と Elixir、自動テストの際に必要となる PostgreSQL をインストールしている。9行目から13行目でSSHのキーをインポートしているが、これは開発中のプロジェクトが GitHub のプライベートリポジトリにあるモジュールを参照しているためで、そのような依存関係がなければ本来必要ない部分である。

このDockerコンテナをどのように利用するかは circle.yml の方を参照して欲しい。ビルドの度に Dockerfile からイメージをビルドしていたら時間がかかってしまうので、ビルドしたイメージを ~/docker ディレクトリにキャッシュして、二度目以降はそちらを利用するようにしている(11行目、14, 15行目)。

テストを実行する時のコマンドは以下のような感じである(20行目):

docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'service postgresql start && cd /build && ./ci/test.sh'

CircleCI側のプロジェクトディレクトリをDockerコンテナ内の /build にマウントして、service postgresql start でデータベースを起動しておき、./ci/test.sh でテストを実行する。/etc/localtime:/etc/localtime:ro でコンテナのタイムゾーンをホスト側(Asia/Tokyo)と合わせる。これでビルド時のタイムスタンプが日本時間になる。

リリースパッケージをビルドする時のコマンドは以下(28行目):

docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'cd /build && ./ci/build-release.sh'

 

(4) test.sh – 自動テストの実行

Dockerコンテナ内で実行される自動テストのシェルは以下のような簡単なものだ。

#!/bin/bash
export MIX_ENV="test"
mix do deps.get, deps.compile, compile, test

view raw
test.sh
hosted with ❤ by GitHub

 

(5) get-prev-rel.sh – 直前のバージョンを取得する

exrm のタスク、mix release を実行すると、リリースパッケージがビルドされて rel ディレクトリ以下に保存される。このとき、rel ディレクトリに直前のバージョンがあると、そこからアップグレードを行うためのファイル(relup)も一緒に生成される。常に同じマシンでリリースパッケージのビルドを行っているのであれば問題なくアップグレードファイルが出来上がるが、CIのように、毎回リポジトリからコードを持ってきてビルドする方式だと、当然以前のリリースパッケージが含まれないのでアップグレードファイルが出来ない。これに対処するため、リリースパッケージのビルド前にパッケージリポジトリ(S3)から直前のバージョンを取得して、rel ディレクトリ以下に展開している。

#!/bin/bash
export APP="example_app"
aws s3 cp s3://example-app-packages/latest.tar.gz /tmp/${APP}.tar.gz
mkdir -p rel/${APP} && rm -rf rel/${APP}/*
tar zxvf /tmp/${APP}.tar.gz -C rel/${APP}

view raw
get-prev-rel.sh
hosted with ❤ by GitHub

 

(6) build-release.sh – リリースパッケージをビルドする

rel ディレクトリに前バージョンのパッケージを用意したら、リリースパッケージのビルドを行う。

#!/bin/bash
set -ex
mix do deps.get, deps.compile, compile
node -v
npm install
export MIX_ENV="prod"
mkdir -p priv/static
node node_modules/brunch/bin/brunch build
mix do phoenix.digest, compile, release –verbosity=verbose

view raw
build-release.sh
hosted with ❤ by GitHub

Production用のパッケージをビルドするため、brunchによるアセットファイルのビルドも合わせて行っておく必要がある。

リリースパッケージは以下の場所に出来上がる。

rel/example_app/releases/(VERSION)/example_app.tar.gz

このファイルと、同時に出来上がった VERSION ファイルをパッケージリポジトリにアップロードする(circle.yml – 30行目〜34行目):

PACKAGE="./rel/example_app/releases/`cat VERSION`/example_app.tar.gz" 
DEST="s3://example-app-packages"
aws s3 cp "${PACKAGE}" "${DEST}/`cat VERSION`.tar.gz"
aws s3 cp "${PACKAGE}" "${DEST}/latest.tar.gz"
aws s3 cp ./VERSION "${DEST}/VERSION"

これでめでたくリリースの完了である。

CircleCI用 IAM User の準備

パッケージリリースの処理にもあるように、ビルドの際、CircleCIからAWS上の各種リソースにアクセスする必要がある。そのために必要な最低限の権限を付与した IAM User を作成し、CircleCIのプロジェクトに設定しておく。

以下は今回のプロジェクト用に作成した、リリース・デプロイ担当の IAM User に付与したポリシー(Inline Policies)の例である:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "1",
"Effect": "Allow",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::example-app-packages/*"
]
},
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:revokeSecurityGroupIngress"
],
"Resource": "*"
}
]
}

view raw
deploy-policies.json
hosted with ❤ by GitHub

パッケージリポジトリであるS3へのアクセスと、デプロイ時に必要になるEC2に対する権限を設定している。

 

(7) deploy-to-staging.sh – リリースパッケージをアプリケーションサーバーにホットデプロイする

パッケージのリリースが完了したら、いよいよホットデプロイを行う。

デプロイには Capistrano という自動化ツールを使い、対象となるアプリケーションサーバーにSSHでログインして、アップグレード処理が書かれたシェルを実行する。

CircleCIからEC2インスタンスへのSSHアクセスを一時的に許可する

アプリケーションサーバーにSSHするためには、CircleCI側からアクセス出来るようにセキュリティグループ(ファイアウォール)の設定をしておかなければならない。CircleCIに対するアクセス許可を永続的に行うのには抵抗があるし、そもそもCircleCI側のIPは常に変わる。

そこで、ビルドの度にCircleCIコンテナのIPアドレスを調べて、そのIPアドレスに対してデプロイの時だけ一時的にSSHアクセスを許可するようにする。

#!/bin/bash
set -ex
export AWS_DEFAULT_REGION="ap-northeast-1"
SGID="sg-xxxxxxxx"
MYIP="`dig +short myip.opendns.com @resolver1.opendns.com`"
cd $(dirname $(readlink -f $0))
trap "aws ec2 revoke-security-group-ingress –group-id ${SGID} –protocol tcp –port 22 –cidr ${MYIP}/32" 0 1 2 3 15
aws ec2 authorize-security-group-ingress –group-id ${SGID} –protocol tcp –port 22 –cidr ${MYIP}/32
cd ../deploy && ./deploy-to-staging.sh

view raw
deploy-to-staging.sh
hosted with ❤ by GitHub

自身のIPアドレスを調べる方法として、curl -s ifconfig.me というやり方がよく見られるが、これがかなり遅い上に失敗することも度々あるので DNS を利用したやり方にしてある(8行目)。

aws ec2 authorize-security-group-ingress でSSH接続を許可して(13行目)、デプロイ後、aws ec2 revoke-security-group-ingress で許可を取り消すのだが(12行目)、許可取り消しを確実に実施するために trap コマンドを利用する方法はこちらを参考にさせて頂いた(感謝)。

Capistranoでデプロイを実施する

SSH接続の許可を行った後にまた別の deploy-to-staging.sh を実行しているが(14行目)、このシェルは以下のように単に Capistrano のタスクを実行しているだけである。

#!/bin/sh
bundle exec cap staging deploy:main $1

肝心の Capistranoタスクの内容(deploy/config/deploy.rb)は以下の通り、

require 'aws-sdk'
lock '3.4.0'
set :application, 'example_app'
set :pty, true
set :user, "ubuntu"
set :use_sudo, true
set :ssh_key, "~/.ssh/example_app.pem"
set :role_name, 'example_app'
set :env_name, 'staging'
REGION = 'ap-northeast-1'
Aws.config.update({
region: REGION
})
namespace :deploy do
task :main do
invoke "deploy:set_target_instances"
invoke "deploy:upgrade_to_latest"
end
task :set_target_instances do
count = 0
ec2 = Aws::EC2::Client.new
ec2.describe_instances(
filters:[
{ name: "tag:Environment", values: [fetch(:env_name)] },
{ name: "tag:Role", values: [fetch(:role_name)] },
{ name: 'instance-state-name', values: ['running'] }
]
).reservations.each {|r|
r.instances.each {|i|
puts "#{fetch(:role_name)}(#{count += 1}): #{i.public_dns_name}"
server i.public_dns_name, roles: %w(app), user: fetch(:user), ssh_options: {
keys: [File.expand_path(fetch(:ssh_key))]
}
}
}
end
task :upgrade_to_latest do
on roles(:app) do |host|
upload! "upgrade.sh", "/tmp/upgrade.sh"
execute "chmod +x /tmp/upgrade.sh && /tmp/upgrade.sh"
end
end
end

view raw
deploy.rb
hosted with ❤ by GitHub

処理の流れは、EC2インスタンスに付けたタグ Environment, Role の値と、稼働中であることを条件にしてインスタンスを検索し、それらのインスタンスにアップグレード用のシェル upgrade.sh をアップロードして実行するというものである。

開発用マシンからもデプロイが実施出来るように10行目でSSHキーの場所を指定しているが、CircleCIの場合は SSH Permission にキーを設定しておく必要がある。

 

(8) upgrade.sh – デプロイ処理の実際

Capistranoによって実行される upgrade.sh が実際のデプロイ(アップグレード)処理を行う。

#!/bin/bash
export AWS_DEFAULT_REGION="ap-northeast-1"
APP="example_app"
APP_HOME="/opt/${APP}"
DEPLOY_TO="${APP_HOME}/releases"
S3_FOLDER="s3://example-app-packages"
# Get the specified or latest version number
aws s3 cp ${S3_FOLDER}/VERSION /tmp/VERSION
VERSION=${1:-"`cat /tmp/VERSION`"}
echo "Trying to deploy ${APP} ${VERSION}"
# Deploy
if [ ! -d "${DEPLOY_TO}/${VERSION}" ]; then
mkdir -p ${DEPLOY_TO}/${VERSION}
aws s3 cp ${S3_FOLDER}/${VERSION}.tar.gz ${DEPLOY_TO}/${VERSION}/${APP}.tar.gz
cp ${APP_HOME}/app.conf ${DEPLOY_TO}/${VERSION}/${APP}.conf # Apply config
else
echo "${APP} ${VERSION} already exists."
fi
# Upgrade
if [ ! "${VERSION}" = "`curl -s http://localhost:8080/api/public/version`" ]; then
# Upgrade
${APP_HOME}/bin/${APP} upgrade ${VERSION}
# Set the current and prev versions
if [ -e "${DEPLOY_TO}/current" ]; then
mv ${DEPLOY_TO}/current ${DEPLOY_TO}/prev
fi
echo $VERSION > ${DEPLOY_TO}/current
else
echo "${APP} ${VERSION} is already running."
fi

view raw
upgrade.sh
hosted with ❤ by GitHub

デプロイ対象のバージョンは、デフォルトでパッケージリポジトリの VERSION ファイル(つまり最新版)を参照するが、シェルへの引数として指定することも出来る(12行目)。ただ、今のところは指定出来るというだけで任意のバージョンをデプロイ出来るわけではない。

20行目で、前述のConformについてのセクションで説明したアプリケーションの設定ファイル ${APP_HOME}/app.conf を、デプロイ対象のパッケージを置いた(19行目)ディレクトリにコピーしている。これでデプロイ時に本番用の設定を適用することが出来る。

稼働中のアプリケーションのバージョンを予め用意したAPI経由で調べて(26行目)、デプロイ対象のバージョンと同じでなければ、デプロイ処理を実行する(28行目)。現段階で対応しているのはアップグレード処理だけだが、同じ要領でダウングレードも実現出来るはずだ。

以上が、git push した後に、CircleCI上で行われる処理の全貌である。今のところ、たまチームの環境では、これら全ての処理が完了するまでにかかる時間はおよそ5、6分程度である。これからプロジェクトのサイズが大きくなるに従ってこの時間は長くなると思うが、それでも以前の環境とは比較にならないぐらい快適なデプロイ環境を実現することが出来たと思う。今回の改善の多くは、Elixir/Phoenixのホットデプロイ機能に負うところが大きいが、このホットデプロイがどこまで安定して運用出来るかはまだ未知数である。今回実現した完全自動化は今のところステージング環境で運用しているが、本番で利用する際には、安全のため、アップグレードのテストを行うためのリハーサル機を用意しようと考えている。

 

アプリケーションサーバーの構成管理

これまでのセクションでCircleCI上の自動化については一通り見てきたが、デプロイ対象となるアプリケーションサーバー内の構成がどうなっているかについては触れてこなかった。デプロイを自動化するためには、当然のことながら、アプリケーションサーバー側の設定も必要になってくる。というわけで、このセクションではその辺の設定について紹介して今回の記事の締めくくりとしたい。

今回のプロジェクトでは、アプリケーション用のプロジェクト(app)とは別に、アプリケーションサーバーのマシンイメージを作るためのプロジェクト(app-image)を別途 GitHub で管理している。このプロジェクトには、サーバープロビジョニング用の Ansible コードや、プロビジョニングの結果をテストするための serverspec コードが含まれており、git push されると、CircleCI上でそれらを実行してマシンイメージ(AMI)を作るようになっている。

既に書いたように、exrm によるリリースパッケージはランタイムを含む All-in-One パッケージなので、それらの環境を予め用意しておく必要はなく、プロビジョニングが比較的楽に行える。

アプリケーションサーバーのAnsibleタスクは以下のようになっている。

app-image/ansible/roles/app/tasks/main.yml

name: Download package
command: aws s3 cp "{{ package_url }}" /tmp/app.tar.gz
environment:
AWS_ACCESS_KEY_ID: "{{ lookup('env','AWS_ACCESS_KEY_ID') }}"
AWS_SECRET_ACCESS_KEY: "{{ lookup('env','AWS_SECRET_ACCESS_KEY') }}"
name: Make app directory
file: state=directory path={{ item }}
with_items:
"{{ app_home }}"
"{{ app_home }}/log"
name: Install app
unarchive: src=/tmp/app.tar.gz dest={{ app_home }}
name: Put app config
template: src=app.conf dest={{ app_home }}/
name: Ensure app directory belongs to ubuntu
file: "state=directory path={{ app_home }} owner=ubuntu group=ubuntu recurse=true"
name: Symlink log directory
file: src={{ app_home }}/log path=/var/log/example_app state=link
name: Put upstart config
template: src=example_app.conf dest=/etc/init/
name: Start and enable services
service: name={{ item }} state=started enabled=yes
with_items:
example_app
nginx
name: Wait for app to start
wait_for: port={{ app_port }} delay=10
name: Ensure app is running
uri:
url: http://localhost:{{ app_port }}/api/public/version
status_code: 200
timeout: 600

view raw
main.yml
hosted with ❤ by GitHub

パッケージリポジトリからリリースパッケージを取ってきてインストール、設定ファイルやログ関連の準備をして、サービスを起動するだけである。

マシンイメージのデフォルト設定

17行目のタスクで、アプリケーションの設定ファイル app.conf を配置している。「Conformを利用して、アプリケーション起動時に設定を適用する」で説明した通り、この設定ファイルは、マシンイメージからサーバーインスタンスを起動する時に Cloud-init 経由で実際の値に書き換えられるため、マシンイメージに含めるファイルの内容は、以下のようなデフォルトの設定にしてある。

app-image/ansible/roles/app/templates/app.conf

logger.level = info

Endpoint.url.host = "example.com"
Endpoint.url.port = 443

Endpoint.secret_key_base = "default_secret_key_base"

Repo.username = "postgres_user"
Repo.password = "postgres_pass"
Repo.database = "gyron_connect_dev"
Repo.hostname = "localhost"
Repo.pool_size = 20

実際の設定は Cloud-init で行われるとは言え、マシンイメージにあるデフォルトの設定でもアプリケーションを起動出来るようにしておきたい。そうしておくことで、マシンイメージの動作確認やテストがやりやすくなる。

アプリケーションを Upstart に登録する

マシンイメージから新しいサーバーを立ち上げるとき、あるいは何らかの理由でサーバーが再起動された場合に、アプリケーションも一緒に起動するようにしておきたい。そのためにはアプリケーションをサービスとして登録しておく必要があるが、Ubuntu 14.04 には Upstart という仕組みがあるのでそれを利用する。

上のAnsibleタスクでは、27行目でUpstart用の設定ファイル(/etc/init/example_app.conf)を配置している。

app-image/ansible/roles/app/templates/example_app.conf

description "Example App"
setuid ubuntu
setgid ubuntu
start on runlevel [2345]
stop on runlevel [016]
expect stop
respawn
env MIX_ENV=prod
export MIX_ENV
env PORT=8080
export PORT
env HOME={{ app_home }}
export HOME
pre-start exec /bin/sh {{ app_home }}/bin/example_app start
post-stop exec /bin/sh {{ app_home }}/bin/example_app stop

view raw
example_app.conf
hosted with ❤ by GitHub

開発者を叱責の無間地獄から解放するためのメトリクス

DevOps色の強いインフラチーム(infrastructure team)に所属していた著者が、DevOpsの原則が通用しないプロダクトチーム(product team)に移って受けたカルチャーショックと、そこから、従来の「運用チーム(operations team)」という考え方は止めて、プロダクト開発者のためのサービスを開発・運用する「プラットフォームチーム(platform team)」へ移行するべきだという発想に至るまでの話。

この記事では、運用というものが、障害を起こさないという方向に強く動機付けられていて、それがプロダクトを常に更新していかなければならない開発者の利害と衝突してしまうという問題を指摘している。障害が起きたら、たとえ深夜であろうが、まず対応しなければならないのは運用チームであるため、運用としては出来るだけ障害が起こらないよう、システムの変更に対しては保守的にならざるを得ない。

つまり、問題の本質は「維持」と「変更」の対立であって、これは運用だけに限ったことではなく、障害が起きた時に顧客対応しなければならない営業やカスタマーサポートでも同様の問題が起こる可能性がある。

DevOps界隈でよく話題になる、開発者から嫌われる運用チームの人物像というものがある。何故こんな人物像が生まれるかと言えば、「維持」に動機づけられた側の人間が、システムを変更し障害をもたらした人物に対して責任の追及を行う(finger-pointing)ということが頻繁に発生するからである。これは上に書いたように運用に限らず、「維持」と「変更」のトレードオフを理解しない人間によっても度々行われるため、組織で働く開発者というのは常に、完璧に防ぐことが原理的に不可能な問題で、叱責され続ける運命にある。

この問題を解決するためのヒントとなりそうなのが、この記事で紹介されている MTTRMTBF という二つのメトリクスである。

MTTRというのは Mean Time to Recovery の略で、障害が起きてから回復するまでにかかった時間の平均である。一方、MTBFは Mean Time between Failures の略で、障害から回復後、次に障害が起こるまでの時間の平均、つまり平穏無事にシステムが動き続ける時間の平均である。

「維持」に動機づけられた人間は、これら二つの指標の内、MTBF、つまりシステムが平穏無事に動きづける時間の最大化を目指す。これを MTTR の方に重きを置くように考え方をシフトさせる事が出来れば、開発者の精神的な負担は大分軽くなる。

「維持」と「変更」がトレードオフの関係であること、システムを変更し続けなければ組織のミッションは果たせないというところから、「変更」に対する理解を得ることはそれほど難しくない。しかし、どこまで障害に対して許容するかとなれば、利害が異なる二つのグループの間でそう簡単に折り合いがつく訳でもない。そのような時に「障害をいかに起こさないか」という考え方から「障害が起こる事自体はそれほど大きな問題ではない、問題はいかに早く障害を解決するか」という方向に考え方を変える事が出来れば、変更に対する抵抗も低減するし、変更は MTTR を最小化するための学びの場となる。

DevOpsの起源とOpsを巡る対立

たまチームとして技術ブログを書くようになって、ある日ふと気付いたのだが、そのメインのトピックの一つであるDevOpsという言葉の起源については深く追求しないまま今日に至ってしまっていた。開発と運用が一緒になるとか、あるいはインフラの自動化ぐらいのぼんやりとしたイメージはあったのだが、その運動を起こした人たちとその意図、歴史などについては無頓着なままであった。そこで今回はこの辺の事情について調査を行い、今の知識と照らし合わせながら現状の考えをまとめておきたい。

DevOpsの起源

まず、DevOpsの起源と歴史については以下のサイトに簡単にまとまっている。

Agile 2008 conference

これらの記事によれば、DevOpsという言葉が生まれる一年ほど前、2008年にカナダのトロントで開催された Agile 2008 conference にて、ベルギーのITコンサルタントであるPatrick Debois氏が「Agile Infrastructure & OperationsPDFのプレゼン資料)」というプレゼンテーションを行い、これが後のDevOpsムーブメントの萌芽となったらしい。

このプレゼンテーションで提示されている文脈としては、まず「開発と運用の分離」がある。「What is DevOps?」という資料にも歴史的経緯が解説されているが、元々は分かれていなかった開発(Development)と運用(Operations)という役割が、コンピュータの進歩と共に、次第にそれらを担う別々の専門家が生まれて、最終的には職能として、組織の中では別々の部門に配属される事が多くなったという流れである。

そして、発表が行われたカンファレンスのテーマである「アジャイル開発」も重要な文脈である。このプレゼンテーションのために実施されたアンケートでは、(科学的なサーベイではないと断り書きがあるものの)開発者はアジャイルという方法論に慣れ親しんでいるが、それ以外の部署、運用やインフラ、営業などになると、いわゆるアジリティーはかなり低くなるという結果が出ている。

そして、発表者のPatrick Debois氏は、開発と運用の両方で豊富な経験を持ち、両者の問題をよく理解している、いわば開発と運用のブリッジとなるような人である。その彼がプレゼンテーションで紹介しているのが、データセンターの移行というインフラの仕事をどうやってアジャイル(スクラム)でやるかという事例で、アプリケーションや運用チームがProduct Ownerになるという大変に興味深いものだ。

Velocity 2009 – O’Reilly Conferences

Patrick Debois氏の発表から約一年後、カリフォルニア州サンノゼで開催されたVelocity 2009というイベントで、Flickrのエンジニア(John Allspaw, Paul Hammond)によって発表された「10+ Deploys per Day: Dev and Ops Cooperation at Flickr」というプレゼンテーションがDevOpsという言葉を生み出すきっかけとなった。

このO’Reilly主催のVelocityというカンファレンスのことは知らなかったのだが、インフラ・運用(Web Performance & Operations)がテーマになっていて、参加者もどちらかと言えば開発者より運用系の人が多いようである。このカンファレンスの存在自体が「開発と運用の分離」という事情を象徴するようであるが、そこからDevOpsという言葉が生まれたというのはなかなか興味深い事実である。

さて、件のプレゼンテーションの内容はと言えば、開発者と運用担当者が共通の目的に向かって手を携える事で、一日に10回以上デプロイするような高速な開発を実現できるというものである。まさに Wikipedia などに書かれているようなDevOpsの定義につながる。そして、そこで指摘されていたのは、職能によって分けられてしまった開発者と運用担当者の利害が衝突するようになり、いわゆるセクショナリズムが生じて、いつまでも対立を続けるようになった結果、組織全体が目指すべき本来の目的を見失っているということであった。

この問題は、開発・運用だけにかぎらず、例えば、営業・開発の関係でも、職能で部署が分けられている場合は不可避的に生じる問題である。何か問題が発生した場合に、セクショナリズムが存在すると、その問題を「我々の問題」として認識出来なくなる。そして責任転嫁(finger-pointing)や責任を回避するような保守的な言動が横行するようになる。これは普通の会社の中で働いているたまチーム自身もまさに経験していることでもある。

そして、このプレゼンテーションをベルギーからストリーミングで視聴していたPatrick Debois氏が、「Devopsdays」というイベントを開く事を思い立ち、それが本格的なDevOpsムーブメントの始まりとなった。

devops

NoOps炎上事件

さて、以上の流れでDevOpsというムーブメントが生まれるまでの経緯は大体把握する事が出来たが、その後の2011年から2012年にかけて「NoOps炎上事件」とでも呼ぶべき興味深い事件が起こっているのでここで紹介しておきたい。

事の発端となったのは、2011年にマサチューセッツ州ケンブリッジに本拠地を置くフォレスター・リサーチという調査会社の出した「Augment DevOps With NoOps」というレポートである。

このレポートの主張は、クラウド環境の進歩により自動化が促進されて、将来的に DevOps は NoOps となる、つまり運用というものは必要なくなるというものである。開発者の立場で考えると、昨今のPaaSの隆盛などを見るに、普通に納得出来そうな将来予測ではある。しかしながら、この NoOps という言葉が、運用だけでなく、運用を担ってきた人間さえも否定するように響き、DevOpsという言葉を生み出すきっかけを作ったコミュニティに少なからぬ波紋を引き起こす事となった。

象徴的なのが、DevOpsでは頻繁にその名前が言及される Netflix という会社に当時勤めていたAdrian Cockcroft氏の NoOps に関する記事「Ops, DevOps and PaaS (NoOps) at Netflix」と、それに対するJohn Allspaw氏の反論である。

John Allspaw氏の反論を全部読み通すのはなかなかに骨が折れるが、その長さからして運用のコミュニティを代表する彼の怒りが伝わってくるようだった。しかし、そもそもここで問題にされている事は何なのか?

それは「運用」とは一体何なのか? ということである。Allspaw氏によれば、Cockcroft氏が言うところの「運用」とは、90年代に存在した古い存在であり、相互不理解が原因で開発者にとっての障害かつフラストレーションの原因となっていたが、その反省によってまさに DevOps が生み出されたのだと、そして現代の我々運用コミュニティの人間はとっくにそちらにシフトしているのだと、今更お前は何を言っているんだと、Opsを無くして悦に浸っているようだけど、「運用」という専門領域あるいは責任は変わらずそこにあるし、全然 NoOps になってないじゃないかと、そのような言葉の誤解を指摘している訳である。

この議論は果たして有益だろうか? Allspaw氏の反論は、Opsの誤用を諭しているようで、その実は、彼の所属するコミュニティが信奉しているOpsの定義を主張しているだけのようにも見える。つまり、彼はOpsコミュニティの利害を代表して抗議する事によって、DevOpsが避けようとしていた落とし穴に再び陥っているかもしれないのである。

専門用語の定義問題というのは、その専門性によって縄張り意識を生み出し、セクショナリズムにつながる危険性を孕んでいる。

物理から仮想へ

この言葉を巡る炎上問題から学べる事は何だろうか? それはおそらくDevとOpsという区別自体がナンセンスな時代になったということではないだろうか。その区別が引き起こしていた障害を解消するために DevOps が発明された訳だが、依然として言葉上では区別していたために「NoOps炎上事件」のような対立が起きた。

DevOpsの背景にあった重大な変化は、あらゆるものがソフトウェア化しているということである。分かりやすい例で言えば、仮想化技術によって実現されたクラウドやVPSのような、物理リソースから論理リソースへの移行である。その他、元々はOpsの領域だった、OSのインストールやコンフィグレーション、アプリケーションのデプロイなどの操作もコード化されるようになった。それぞれ、Infrastructure as Code, Operations as Code と呼ばれるものである。

そのようなソフトウェア化した世界にとって、Infrastructure や Operations の区別というのはそれほど重要ではなくなる。あるいは別のレイヤーの話になる。例えば、Microservices概念の出現によって、一つのサーバー(Service)と、プログラム上の一つのモジュールとの概念的な違いが無くなっていくように。

物理から仮想へと言っても、物理的なハードウェアが無くなる事はあり得ない。もしOpsという言葉が有効に機能する領域があるとすれば、それは物理的装置に関連する領域ではないだろうか。言葉というのは視点の問題である。Infrastructure も Operations も、コード化してソフトウェアとして扱えるようなれば、ソフトウェア開発の技術がそこに応用出来る。そこで重要なのはDevとOpsの区別ではなくて、物理なのか仮想なのかという問題である。

運用中のサービスに与える影響を最小限に抑えつつ、EC2-Classic上のRDSをVPC上に移行する

たまチームで開発しているWebサービスの大半は AWS EC2-Classic 上で動いているのだが、去年から標準でサポートされるようになった VPC (Virtual Private Cloud) に移行しないといろいろと不便なことが多くなってきた。というわけで、移行にあたって一番厄介だと思われる RDS の移行手順を以下にまとめた。

ちなみにこの手順は、EC2-Classic から VPC への移行だけでなく、VPC から VPC の移行にも適用出来るはず。

rds-migration1

大まかな流れとしては、

  1. リードレプリカ経由でClassic側のdumpを取り
  2. それをVPC側で復元
  3. 復元したデータベースをClassic側のslaveとして動かしてデータを同期
  4. 読み込み専用のアプリはここでVPC側に移行して動作確認
  5. VPC側をmasterに昇格した後(ここで一瞬だけ Read Only になる)
  6. 書き込みのあるアプリケーションもVPC側に切り替えて
  7. Classic側を潰せば

めでたく移行は完了する。

1. リードレプリカ経由でClassic側のdumpを取る

rds-migration2

まずはClassic側で移行用のdumpを取得する。リードレプリカを作ってすぐにリプリケーションを停止し、そのときのdumpとバイナリログの位置を取得しておく。リードレプリカ経由なので、この間、サービスの運用には一切影響を与えない。

バイナリログの保持期間を変更する

デフォルトのバイナリログの保持期間が短いので、VPC-slaveを作る間にログが消失してしまわないよう、Classic-masterで以下のように変更しておく。

CALL mysql.rds_set_configuration('binlog retention hours', 24);

現在の設定を確認する:

CALL mysql.rds_show_configuration;

レプリケーション用のユーザーを作る

Classic-masterでレプリケーション用のユーザーを作っておく。

GRANT REPLICATION CLIENT, REPLICATION SLAVE ON *.* TO 'repl_user' IDENTIFIED BY 'some-password';

Classic-masterのリードレプリカを作る

AWS管理コンソールで Classic-master となるRDSインスタンスを選び、”Instance Actions” から “Create Read Replica” を選択する。

レプリケーションをストップする

Classic-slave(今作ったレプリカ)のレプリケーションをストップする。

CALL mysql.rds_stop_replication;

バイナリログの位置を記録する

Classic-slaveで、レプリケーションの状態をメモる(どの位置からレプリケーションを再開すれば良いか)。

show slave statusG
...
    Master_Log_File: mysql-bin-changelog.050354
Read_Master_Log_Pos: 422
...
   Slave_IO_Running: No
  Slave_SQL_Running: No
...

Slave_IO_RunningSlave_SQL_RunningNoになっているので、ちゃんとレプリケーションが止まっていることが分かる。

Classic-slaveのdumpを取る

  • Classic-slaveのエンドポイントを classic-slave.rds.amazonaws.com、管理ユーザーを dbuser とする。
mysqldump -u dbuser -h classic-slave.rds.amazonaws.com --single-transaction --compress --order-by-primary --max_allowed_packet=1G -p --databases database1 databases2  > classic-slave.dump

2. VPCにClassic-masterのレプリカを作る

rds-migration3

VPC内に立てたRDSインスタンスに、Classic-slaveのdumpをインポートしてClassic-masterへのレプリケーションを再開させる(VPC-slave)。

VPC-slaveとなるRDSインスタンスを立ち上げる

AWS管理コンソールでサクッと立ち上げる。

VPC-slaveにClassic-slaveのdumpをインポート

  • VPC-slaveのエンドポイントを vpc.rds.amazonaws.com、管理ユーザーを dbuser とする。
mysql -u dbuser -h vpc.rds.amazonaws.com -p --default-character-set=utf8 < classic-slave.dump

Classic-masterのセキュリティグループ設定を変更する

レプリケーションを可能にするために、VPC-slave から Classic-master にアクセス出来るようにセキュリティグループ(DB Security Groups)を設定する。

以下のような感じで VPC-slave のIPを調べて、そのIPからのアクセスを許可する:

$ ping vpc.rds.amazonaws.com

以下を Classic-master のセキュリティグループに追加。

CIDR/IP to Authorize:  xxx.xxx.xxx.xxx/32

レプリケーションをスタートさせる

VPC-slave にログインして、レプリケーションをスタートさせる(さっき作った専用ユーザーがここで活躍)。
パラメータは先ほどメモしたレプリケーションの状態を参考に。

CALL mysql.rds_set_external_master('classic-master.rds.amazonaws.com',3306,'repl_user','some-password','mysql-bin-changelog.050354',422,0);
CALL mysql.rds_start_replication;

レプリケーションの状態を確認:

show slave statusG

以下のような項目があれば、正しくレプリケーションが動作している。

...
 Slave_IO_Running: Yes
Slave_SQL_Running: Yes
...

レプリケーションがうまく行ったら、Classic-slaveは用無しなので削除する。
この段階で読み込み専用のアプリはVPC内に移行出来る。

3. VPC-slaveをmasterに昇格する

rds-migration4

Classic-masterをReadOnlyにする

FLUSH TABLES WITH READ LOCK;

Closes all open tables and locks all tables for all databases with a global read lock. This is a very convenient way to get backups if you have a file system such as Veritas or ZFS that can take snapshots in time. Use UNLOCK TABLES to release the lock.

として、全てのテーブルをロックしてから切り替えたいところなのだが、RDSではこの命令を実行するための権限がないことが判明。

簡単な代替策もないようなので、とりあえず編集を行う可能性があるサービスを全て「メンテナンス中」として操作出来ないようにした。

VPC-slaveのレプリケーションを停止する

CALL mysql.rds_stop_replication;
CALL mysql.rds_reset_external_master;

以後、こちらを master として扱う。

アプリケーションをVPC内に移動する

VPC内にアプリケーションサーバーを立てて、そちらを指すようにDNSの設定を書き換える。

後は古いClassic環境を潰して移行は完了。

Immutable Infrastructureを導入する

現在たまチームでは、以下のような自動化を実現している。

gyron-infra-old

自動化の柱は大きく分けて二つあって、まずは Deployment Pipeline。アプリケーションコードを変更して git push すると、Jenkins上でビルドされて、Amazon S3上へのパッケージのリリースから、アプリケーションサーバー上へのデプロイまで自動で行われる。

もう一つはサーバー群の構成管理 (Provisioning)で、Chef による Codification(コード化) が実現されている。

この環境でかれこれ二年ぐらい運用してきたが、自動デプロイは便利だと思いつつ、いくつかの問題点が明らかになってきた。

  • デプロイの際に更新されるのはアプリケーションだけなので、サーバー上のミドルウェアの更新をいつやるか、どうやって安全にやるかを考えるのはなかなか悩ましかった。
  • Chefを経由するより、まずはサーバーを直接直してしまおうという誘惑が常にある(突然のセキュリティアップデートが必要になった場合など)。
  • EC2のインスタンスがリタイアするときにあたふたしてしまう。
  • Chefのコードを書く人がインフラ担当者に限られてしまい、アプリケーション開発者側から手出し出来なくなる。
  • 簡単とは言えないChefにも問題があると思われ。
  • ProvisioningコードがCI上に乗っかってない。

というわけで、たまチームへの新たな要求や DevOps の新たなトレンドなどを鑑みて、インフラの大幅刷新を行うことにした。

以下が、目下移行中、新インフラの概要図である。

gyron-infra-new

大きく変わるのはアプリケーション開発者が担当するビルドの部分。以下、重要なポイントごとに検討してみる。

DeploymentとProvisioningの統合

今までは別々に管理されていた Deployment と Provisioning のプロセスを統合して、アプリケーション側で Server Image の生成まで面倒を見るようにする。

現状のビルドプロセス:

deploy-and-provision-old

新ビルドプロセス:

deploy-and-provision

この統合によって、Provisioningの大部分がアプリケーション側に移動することになる。今まではインフラ担当者の責任だった Provisioning を各アプリケーションの開発者が担当することによって、誰もがアプリケーションコードを扱う感覚でProvisioningコードを扱えるようになることを狙いたい。

また、今回は Chef から Ansible への移行も行う。プロジェクト全体で Provisioning を共有する場合、あるいはインフラが大規模になるようなケースであれば Chef のメリットもあると思われるが、アプリケーションに Provisioning を含めてしまうという今回のケースでは、より習得が容易でコンパクトに書ける Ansible が向いているのではないかという判断である。

アプリだけでなくサーバー全体を含めたContinuous Integration

今までCI上で実施されていたのはアプリのビルドまでだった。新しい仕組みでは、サーバーのビルド(AMIの作成)までをCI上で回す。これによって、以下のようなメリットが得られる。

  • Provisioningも自動テストの対象にできる(serverspec
  • アプリケーションの変更を行う感覚でミドルウェアの変更を行える
  • サーバーのPortabilityが改善し、テスト用の環境などが構築しやすくなる

Immutable Infrastructure

新インフラのアプリケーションサーバーは、原則として、一度起動した後にsshなどでログインしてその状態を変更しないようにする。アプリケーションやミドルウェアを更新する場合は、コードを変更してServer Imageを作り直し、古いサーバーを破棄して新しいサーバーに入れ替える。

このような方針で管理されたインフラを「Immutable Infrastructure」と呼ぶ。インフラがコードに表現された以外の状態を取り得ないということは、コードさえあれば環境を容易に再現できるということであり、テストが簡単になる、理解しやすくなる、不測の事態が起こりづらくなる、Blue-Green Deployment がやりやすくなるなど、数多くのメリットがある。

DevOps Split – BuildとOrchestration

新しいインフラでは、Server Image (AMI) という Artifact を生成するプロセスと、そのArtifactを利用してOrchestration(サーバーの構成管理)を行うプロセスを分離する。前者は個々のアプリケーション開発者が担当し、後者はチーム全体で共同管理することになる。

以下は HashiCorp で紹介されているワークフロー:

Atlas-DevOps-Split

このワークフローを採用した場合、Artifactの作成で流れが一旦切れるため、git pushしたらアプリケーションの更新まで行われるという、現状実現できている自動化を再現するのが難しくなる。新インフラで得られるメリットに比べたら些細なことだと言えるかもしれないが、今後の課題ということでここに記録しておく。

CIのCodification – JenkinsからCircleCIへ

現状のインフラで自動化の要を担っているJenkinsであるが、ジョブの数が多くなってくると管理が大変になってくる上に、設定が Codification されていないという問題があるため、CircleCIに移行することにした。

Blue-Green Deployment

現状のインフラで Blue-Green Deployment を実現しているのは、状態(セッション)を持たないアプリケーションに限られ、かつデプロイ作業の多くは手動で行っている(ELBの操作など)。新インフラでは、以下のような手法を採用して、全てのアプリでより安全な Blue-Green Deployment の導入を目指す。

  • JEEアプリについては、 tomcat-redis-session-manager を利用してセッションをRedis (Elasticache) に保存するようにし、アプリケーションサーバーをステートレスにする。
  • Terraform を利用して新しい Server Image を立ち上げた後、Smoke Testで動作確認し、ELB配下で新旧の切り替えを行う。
  • Terraformによって、サーバーの設定・構成がコード化されると共に、管理コンソールで操作する必要がなくなるため、ヒューマンエラーの予防になる。