git branchに変更が加わった際、

  • JUnit test (with MySQL)
  • docker build
  • Kubernetes環境にデプロイ (CIOps)

が行われるJavaプロジェクトを構築します。

本番運用ではArgoCDなどgitOps構築をお勧めします

登場するもの

OSS

  • Maven
  • MySQL
  • Docker

サービス

  • GitHub
  • CircleCI
  • AWS (ECR, EKS) ⇦ 微修正でその他マネージドk8sにも応用可能かと思います。

サンプルプロジェクトの実装

最終的にディレクトリ構成はこんな感じになります。順を追って作っていきます。 GitHubからcloneして頂いても結構です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
testproject/
    ├ src/
    │   ├ main/
    │   │   └ java/
    │   │        └testpackage/
    │   │            └Main.java
    │   └ test/
    │       └ java/
    │           └testpackage/  
    │              └MainTest.java   
    ├ .cifcleci/
    │   └ config.yml
    ├ schema.sql
    ├ pom.xml
    ├ deploy.yaml
    └ Dockerfile

1. スキーマ定義

テーブルスキーマを記述します。resourceフォルダに置いても良いのですが、今回はトップディレクトリに配置することにします。
データベース名は指定しません。

1
CREATE TABLE user (id int, name varchar(10));

2. mavenプロジェクト作成

先ほどのtableに適当なレコードを挿入する最小限のJavaプロジェクトを実装します。

JDBC、JUnit、maven-assembly-pluginを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>aaaanwz</groupId>
	<artifactId>test</artifactId>
	<version>0.0.1</version>
	<name>testproject</name>
	<properties>
		<maven.compiler.source>11</maven.compiler.source>
		<maven.compiler.target>11</maven.compiler.target>
	</properties>
	<dependencies>
		<!-- JDBC driver -->
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<version>8.0.17</version>
		</dependency>
		<!-- JUnit -->
		<dependency>
			<groupId>org.junit.jupiter</groupId>
			<artifactId>junit-jupiter-engine</artifactId>
			<version>5.3.1</version>
			<scope>test</scope>
		</dependency>
	</dependencies>
	<build>
		<plugins>
			<!-- JUnit test -->
			<plugin>
				<artifactId>maven-surefire-plugin</artifactId>
				<version>3.0.0-M3</version>
			</plugin>
			<!-- Build runnable jar -->
			<plugin>
				<artifactId>maven-assembly-plugin</artifactId>
				<executions>
					<execution>
						<phase>package</phase>
						<goals>
							<goal>single</goal>
						</goals>
					</execution>
				</executions>
				<configuration>
					<archive>
						<manifest>
							<mainClass>testpackage.Main</mainClass>
						</manifest>
					</archive>
					<descriptorRefs>
						<descriptorRef>jar-with-dependencies</descriptorRef>
					</descriptorRefs>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

1レコード挿入するだけのMainクラスを実装します。
データベースへの接続情報は環境変数から取得します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package testpackage;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class Main {

  public static void main(String[] args) throws ClassNotFoundException, SQLException {
    final String host = System.getenv("DB_HOST");
    final String dbname = System.getenv("DB_NAME");
    execute(host, dbname);
  }

  static void execute(String host, String dbname) throws ClassNotFoundException, SQLException {
    Class.forName("com.mysql.cj.jdbc.Driver");
    Connection conn = DriverManager.getConnection(
        "jdbc:mySql://" + host + "/" + dbname + "?useSSL=false", "root", "");
    PreparedStatement stmt = conn.prepareStatement("INSERT INTO user(id,name) VALUES(?, ?)");
    stmt.setInt(1, 1);
    stmt.setString(2, "Yamada");
    stmt.executeUpdate();
  }

}

レコードが挿入された事を確認するだけのテストを書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package testpackage;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import testpackage.Main;

class MainTest {
  Statement stmt;

  @BeforeEach
  void before() throws ClassNotFoundException, SQLException {
    Class.forName("com.mysql.cj.jdbc.Driver");
    Connection conn =
        DriverManager.getConnection("jdbc:mySql://localhost/test?useSSL=false", "root", "");
    stmt = conn.createStatement();
  }

  @AfterEach
  void after() throws SQLException {
    stmt.executeUpdate("TRUNCATE TABLE user");
  }

  @Test
  void test() throws Exception {
    Main.execute("localhost", "test");
    try (ResultSet rs = stmt.executeQuery("SELECT * FROM user WHERE id = 1;")) {
      if (rs.next()) {
        assertEquals("Yamada", rs.getString("name"));
      } else {
        fail();
      }
    }
  }
}

3. Dockerfile作成

Dockerfileを書きます。テストは docker buildとは別のステップで行う予定のため、-DskipTestsを付与してビルド時のテストをスキップします。

イメージサイズ削減のためmaven:3.6でビルド、openjdk11:apline-slimで実行するマルチステージビルドを行います。 maven:3.6では約800MB、openjdk11:alpine-slimでは約300MBとなります。現時点でECRの無料枠は500MBのため、個人開発では大きな差です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM maven:3.6 AS build

ADD . /var/tmp/testproject/
WORKDIR /var/tmp/testproject/
RUN mvn -DskipTests package

FROM adoptopenjdk/openjdk11:alpine-slim

COPY --from=build /var/tmp/testproject/target/test-0.0.1-jar-with-dependencies.jar /usr/local/
CMD java -jar /usr/local/test-0.0.1-jar-with-dependencies.jar.jar

4. k8s yamlファイル作成

3.でビルドされたコンテナをk8sにデプロイするためのyamlを書きます。今回のプログラムは単発でSQLを実行して終了するため、kind: Jobにしてみます。Docker registryのurlは適宜置換してください。
envでDBへの接続情報を定義します。 DB_HOSTの値が mysqlとなっているのは、KubernetesのService経由で接続するためです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: batch/v1
kind: Job
metadata:
  name: test
spec:
  template:
    spec:
      containers:
      - name: test
        image: your-docker-registry-url/testimage:latest
        imagePullPolicy: Always
        env:
        - name: DB_HOST
          value: "mysql"
        - name: DB_NAME
          value: "ekstest"
      restartPolicy: Never
  backoffLimit: 0

5. circleci configファイル作成

本稿のキモです。CircleCIで自動テスト/ビルドを行うための構成設定を書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
version: 2.1
orbs:
  aws-ecr: circleci/aws-ecr@6.1.0
  aws-eks: circleci/aws-eks@0.2.1
  kubernetes: circleci/kubernetes@0.3.0
jobs:
  test: # ①JUnit テストの実行
    docker:
      - image: circleci/openjdk:11
      - image: circleci/mysql:5.7
        environment:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_DATABASE: test
        command: [--character-set-server=utf8, --collation-server=utf8_general_ci, --default-storage-engine=innodb]
    steps:
      - checkout
      - run:
          name: Waiting for MySQL to be ready
          command: dockerize -wait tcp://localhost:3306 -timeout 1m
      - run:
          name: Install MySQL CLI; Create table;
          command: sudo apt-get install default-mysql-client && mysql -h 127.0.0.1 -uroot test < schema.sql
      - restore_cache:
          key: circleci-test-{{ checksum "pom.xml" }}
      - run: mvn dependency:go-offline
      - save_cache:
          paths:
            - ~/.m2
          key: circleci-test-{{ checksum "pom.xml" }}
      - run: mvn test
      - store_test_results:
          path: target/surefire-reports
  deploy: # ③EKSへのkubectl apply
    executor: aws-eks/python3
    steps:
      - checkout
      - kubernetes/install
      - aws-eks/update-kubeconfig-with-authenticator:
          cluster-name: test-cluster
          aws-region: "${AWS_REGION}"
      - run:
          command: |
            kubectl apply -f k8s-job.yaml            
          name: apply
workflows:
  test-and-deploy:
    jobs: 
      - test: # ①JUnit テストの実行
          filters:
            branches:
              only: develop
      - aws-ecr/build-and-push-image: # ②コンテナのビルドとECRへのpush
          filters:
            branches:
              only: master
          create-repo: true
          repo: "testimage"
          tag: "latest"
      - deploy: # ③EKSへのkubectl apply
          requires:
            - aws-ecr/build-and-push-image

①JUnitテストの実行

filterによって、developブランチに変更が加わった際に mvn testが実行されるようにしてみました。この際テスト用MySQLインスタンスが立ち上がり、 testデータベースが準備されます。

公式ドキュメントで詳しく解説されています。  

②コンテナのビルドとECRへのpush

masterブランチに変更が加わった際に、Dockerfileに従ってbuildし、ECRにpushします。
Orbクイックスタートガイドで詳しく解説されています。

③EKSへのkubectl apply

②が実施された後、4.で定義したJobを実行します。
circleci/aws-eksに解説がありますが、サンプルコードをよりシンプルに変更しました。
@0.2.1のドキュメントによるとパラメータaws-regionはRequiredとなっていませんが、実際は必須のようです。ECRのpushで環境変数AWS_REGIONを要求されているので、これをそのまま使いました。

インフラ設定

ほとんど公式ドキュメントへのリンク集です。

1. ECR, EKS環境準備

1-1. 基本設定

test-clusterを立ち上げます。

1-2. EKS上にMySQLをデプロイ

Kubernetes公式にDeploy MySQLというドキュメントが用意されています。
kubectl exec -it mysql-0 mysqlコマンドからekstestデータベースとuserテーブルを用意します。

2. CircleCI projectの設定

2-1. プロジェクトのセットアップ

2-2. 環境変数の設定

  • circleci/aws-ecrでRequiredとなっている環境変数をCircleCI Projectに設定します。
    Roleに要求される権限の詳細は割愛します。

まとめ

  • develop branchにpush
     mvn testが実行され、テストレポートがCircleCIのTest summaryに表示されます。
  • master branchにpush
    ECRにdocker imageがpushされ、EKSでJobが開始します。EKS上のMySQLに id:1 name:Yamadaレコードが挿入されます。