Z użyciem Terraform możemy jedną komendą stawiać nawet duże infrastruktury chmurowe, a także przechowywać je i wersjonować jako kod. Zobaczmy więc, jak możemy wykorzystać to narzędzie, aby w szybki sposób postawić aplikację Spring na GKE z użyciem Terraform. Potem spróbujemy dodatkowo połączyć aplikację z bazą danych PostgreSQL. Baza, a także połączenie pomiędzy nią a aplikacją stworzymy za pomocą Terraform i Kubernetes Secrets. Zaczynajmy!
Kod zawarty w tym artykule znajdziecie tutaj.
W artykule postawimy za pomocą Terraform całą serię komponentów i działającą aplikację. Przedstawia je poglądowo poniższa ilustracja.

Setup
Na początek zakładamy projekt Spring i konto w Google Cloud. Do przetestowania kodu w projekcie polecam założyć sobie oddzielny projekt. Pomimo, że będziemy na koniec niszczyć całą infrastrukturę komendą terraform destroy
, usunięcie projektu da nam pewność, że nie zostanie przypadkiem jakiś zasób, który będzie dalej generował koszty. Poza tym nowy projekt zapewni nam świeży start i powtarzalność wyników.
Potrzebujemy zatem napisać aplikację Spring, stworzyć obiekty w GKE i konfigurację Terraform. Zacznijmy więc od końca, od Terraforma.
Provisioning infrastruktury
W głównym folderze naszego springowego projektu utwórzmy sobie folder terraform
. W nim main.tf
dla naszego głównego kodu i variables.tf
do zmiennych. Zacznijmy od zdefiniowania providera google
w main.tf
terraform { required_providers { google = { source = "hashicorp/google" version = "3.88.0" } } } provider "google" { project = var.project_id region = var.region zone = var.zone }
Skąd bierzemy project_id
, region
i zone
? Skoro to zmienne, definiujemy je w pliku variables.tf
. Wartości domyślne możemy zdefiniować na region warszawski, id projektu dopiszemy w czasie wywołania.
variable "project_id" { type = string } variable "region" { type = string default = "europe-central2" } variable "zone" { type = string default = "europe-central2-a" }
Teraz wracamy do pliku main.tf
, aby za pomocą providera google
utworzyć klaster Kubernetes w prywatnej sieci VPC. Ta sieć przyda się później do bezpiecznego połączenia aplikacji z bazą danych. Tworzymy więc prywatną sieć i jej podsieci dla serwisów i pod-ów Kubernetesowych:
resource "google_compute_network" "vpc" { name = "test-network" auto_create_subnetworks = false } resource "google_compute_subnetwork" "vpc_subnet" { name = "test-subnetwork" ip_cidr_range = "10.2.0.0/16" region = var.region network = google_compute_network.vpc.id secondary_ip_range { range_name = "services-range" ip_cidr_range = "192.168.1.0/24" } secondary_ip_range { range_name = "pod-ranges" ip_cidr_range = "192.168.64.0/22" } }
Utwórzmy jeszcze konto serwisowe, z którego będzie korzystał nasz Kubernetes. Musimy pamiętać, aby nadać mu rolę roles/containerregistry.ServiceAgent
, która pozwoli na tworzenie nowych pod-ów z nasza aplikacją:
resource "google_service_account" "default" { account_id = "k8s-service-account-id" display_name = "K8s Service Account" } resource "google_project_iam_member" "registry_reader_binding" { role = "roles/containerregistry.ServiceAgent" member = "serviceAccount:${google_service_account.default.email}" }
Teraz już możemy stworzyć klaster Kuberenetes z maszynami wirtualnymi tworzonymi w prywatnych podsieciach, które zdefiniowaliśmy, podpięty pod zarządzane przez nas konto serwisowe. Ponadto zamiast domyślnej puli node-ów, zdefiniujemy pulę zarządzaną przez nas, co pozwoli na większą nad nią kontrolę z poziomu Terraform.
resource "google_container_cluster" "vpc_native_cluster" { name = "my-gke-cluster" remove_default_node_pool = true initial_node_count = 1 network = google_compute_network.vpc.id subnetwork = google_compute_subnetwork.vpc_subnet.id ip_allocation_policy { cluster_secondary_range_name = google_compute_subnetwork.vpc_subnet.secondary_ip_range.0.range_name services_secondary_range_name = google_compute_subnetwork.vpc_subnet.secondary_ip_range.1.range_name } } resource "google_container_node_pool" "vpc_native_cluster_preemptible_nodes" { name = "my-node-pool" cluster = google_container_cluster.vpc_native_cluster.name node_count = 1 node_config { preemptible = true machine_type = "e2-medium" service_account = google_service_account.default.email oauth_scopes = [ "https://www.googleapis.com/auth/cloud-platform" ] } }
Teraz w naszym nowo utworzonym projekcie musimy otworzyć konsolę Cloud Shell (przyciskiem w prawym górnym rogu – po wejściu na console.cloud.google.com). Gdy ta się otworzy, możemy przerzucić projekt na wirtualną maszynę, która została utworzona na potrzeby konsoli. Dla mnie najprościej było wrzucić kod do repozytorium na GitHubie i pobrać komendą git clone. Mając już projekt, musimy uruchomić odpowiednie Google Cloud API, aby nasz projekt zadziałał. Zrobimy to z poziomu konsoli serią komend:
gcloud services enable cloudapis.googleapis.com gcloud services enable container.googleapis.com gcloud services enable servicenetworking.googleapis.com
Teraz wchodzimy w nasz projekt, w folder terraform
i wywołujemy:
terraform init -upgrade
W tym momencie Terraform pobrał potrzebnych providerów i jest gotowy do akcji. Aby postawić zaplanowaną przez nas infrastrukturę, wywołujemy komendę, uzupełniając o id nowo utworzonego projektu:
terraform apply -var project_id="<TWOJE-ID-PROJEKTU>"
Terraform powinien w tym momencie rozpisać zmiany zaplanowane na platformie i zapytać, czy rzeczywiście chcemy ich dokonać. Potwierdzamy, wpisując „yes” i czekamy kilka minut, aż infrastruktura zostanie postawiona.
Aplikacja Spring
W naszej aplikacji do testów potrzebujemy następujących dependencji w pom.xml
:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.22</version> <scope>provided</scope> </dependency>
Teraz zdefiniujmy kontroler, zwracający testowego stringa:
@RestController public class TestController { @GetMapping public String test() { return "TEST RESPONSE FROM K8S APP"; } }
Mając to, będziemy mogli łatwo zweryfikować, czy nasza aplikacja została poprawnie zdeployowana i ma połączenie z publicznym internetem. Teraz tworzymy plik deployment.yml
w głównym folderze projektu. Będzie on przechowywał definicje zasobów, które utworzymy na Kubernetesie, aby wdrożyć naszą aplikację. Za samo wdrożenie odpowiada zasób Deployment
:
apiVersion: apps/v1 kind: Deployment metadata: name: test-app labels: app: test-app environment: test spec: replicas: 1 selector: matchLabels: environment: test app: test-app template: metadata: labels: environment: test app: test-app spec: containers: - name: test-app image: gcr.io/{{GCLOUD_PROJECT_ID}}/gke-terraform-demo:{{IMAGE_VERSION}} imagePullPolicy: Always ports: - containerPort: 8080 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 120 readinessProbe: httpGet: path: /actuator/health port: 8080
Poza tym, tworzymy jeszcze serwis, który posłuży nam jak punkt wejścia dla pod-ów należących do deploymentu naszej aplikacji. Możemy zrobić to w tym samym pliku, oddzielając separatorem ---
:
# … --- apiVersion: v1 kind: Service metadata: name: test-app labels: run: test-app spec: type: LoadBalancer ports: - port: 8080 protocol: TCP selector: app: test-app environment: test
Zwróćmy uwagę na pola {{GCLOUD_PROJECT_ID}}
i {{IMAGE_VERSION}}
. Uzupełnimy je dynamicznie podczas budowania obrazu dockerowego i wrzucimy gotowy plik do folderu target
. Do tego musimy użyć skryptu, który umieścimy w scripts/prepare-deployment.sh
:
rm target/deployment.yml sed -e "s+{{GCLOUD_PROJECT_ID}}+$1+g" -e "s+{{IMAGE_VERSION}}+$2+g" deployment.yml >> target/deployment.yml
Aby wywołać skrypt na dowolnym etapie buildu, użyjemy pluginu org.codehaus.mojo.exec-maven-plugin
, który skonfigurujemy w pom.xml
w sekcji <plugins>
:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.0.0</version> <executions> <execution> <id>Inject version and project id to deployment.yml</id> <goals> <goal>exec</goal> </goals> <phase>package</phase> </execution> </executions> <configuration> <executable>${basedir}/scripts/prepare-deployment.sh</executable> <arguments> <argument>${gcloud.project.id}</argument> <argument>${project.version}</argument> </arguments> </configuration> </plugin>
Nie mamy już tylko zawartości zmiennej ${gcloud.project.id}
, ale podamy ją w wywołaniu jako argument.
W deployment.yml zdefiniowaliśmy też livenessProbe
i readinessProbe
jako requesty GET na określoną ścieżkę. Jest to podstawowy mechanizm healthcheck Kubernetesa – pozytywna odpowiedź z tych endpointów świadczy o zdrowiu aplikacji i jej gotowości na przyjęcie ruchu. Jeśli aplikacja po uruchomieniu przez dłuższy czas nie da odpowiedzi pozytywnej, pod na którym działa zostanie zrestartowany. Wpisaliśmy tutaj domyślną ścieżkę, na której działa projekt Spring Aktuator – dostawca mechanizmu healthcheck. Teraz wystarczy już tylko zaimportować go w pom.xml
i sprawdzanie gotowości aplikacji będzie zintegrowane z Kubernetesem.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
Deploy aplikacji
Teraz przerzucamy zmiany na naszą konsolę Google Cloud Shell (np. przez gita) i przystępujemy do wdrożenia. Aby to zrobić, łączymy się z nowo utworzonym klastrem k8s:
gcloud container clusters get-credentials my-gke-cluster --zone=europe-central2-a
Teraz za pomocą komendy kubectl
będziemy mogli zlecać na naszym klastrze różne zadania i odpytywać o jego stan. Wchodzimy więc do głównego folderu projektu i budujemy dockerowy obraz naszej aplikacji:
mvn spring-boot:build-image -Dgcloud.project.id=<TWOJE-ID-PROJEKTU>
W międzyczasie w folderze target
wylądował już gotowy plik deployment.yml
, uzupełniony o nasze id projektu oraz wersję obrazu aplikacji. Teraz przechowujemy obraz lokalnie, tutaj jednak Kubernetes nie ma do niego dostępu. Dlatego musimy wypushować go do Google Container Registry, które wcześniej włączyliśmy. Dopiero stamtąd Kubernetes będzie mógł pobrać obraz.
docker push gcr.io/<TWOJE-ID-PROJEKTU>/gke-terraform-demo:0.0.1-SNAPSHOT
Teraz możemy już utworzyć nowy Deployment
za pomocą kubectl
:
kubectl apply -f target/deployment.yml
Sprawdżmy, czy nasze wdrożenie przebiegło pomyślnie, komendą:
kubectl get deployments
Jeśli wszystko się udało, powinniśmy otrzymać coś podobnego:
NAME READY UP-TO-DATE AVAILABLE AGE test-app 1/1 1 1 30s
Zobaczmy teraz, jakie publiczne IP dostał nasz serwis:
kubectl describe service test-app
Powinniśmy dostać dość długi output, zawierający m.in. informację, która nas interesuje:
LoadBalancer Ingress: <PUBLICZNY-ADRES-IP>
Ten adres IP to właśnie adres, pod którym możemy znaleźć naszą aplikację w publicznym Internecie. Spróbujmy więc to przetestować zapytaniem, wpisując skopiowany z poprzedniego polecenia adres IP:
GET / HTTP/1.1 Host: <PUBLICZNY-ADRES-IP>:8080
Jeśli wszystko się udało, powinniśmy otrzymać w odpowiedzi:
TEST RESPONSE FROM K8S APP
Mamy to, postawiliśmy aplikację Spring na GKE z użyciem Terraform. Pójdźmy jednak o krok dalej…
Aplikacja z bazą danych SQL
Spróbujmy teraz zrobić nieco bardziej realny use-case, dodając możliwość zapisu do SQL-owej bazy danych. Bazą będzie PostgreSQL dostarczany przez Google Cloud SQL, a wszystko postawimy używając Terraform.
Baza i k8s secret w Terraform
Zacznijmy od zdefiniowania w pliku terraform/main.tf
definicji prywatnego IP w naszej wirtualnej sieci, pod które podłączymy bazę danych:
resource "google_compute_global_address" "private_ip" { name = "private-ip-address" purpose = "VPC_PEERING" address_type = "INTERNAL" prefix_length = 16 network = google_compute_network.vpc.id } resource "google_service_networking_connection" "service_vpc_connection" { network = google_compute_network.vpc.id service = "servicenetworking.googleapis.com" reserved_peering_ranges = [google_compute_global_address.private_ip.name] }
Teraz definiujemy już samą instancję bazy PostgreSQL:
resource "random_id" "postgres_suffix" { byte_length = 4 } resource "google_sql_database_instance" "postgres" { name = "postgres-${random_id.postgres_suffix.hex}" project = var.project_id region = var.region database_version = "POSTGRES_13" depends_on = [google_service_networking_connection.service_vpc_connection] settings { tier = "db-f1-micro" ip_configuration { ipv4_enabled = false private_network = google_compute_network.vpc.id } } deletion_protection = false #TODO: true in real application }
Dla naszej aplikacji musimy jeszcze zdefiniować usera i bazę danych, z której będzie korzystać:
resource "google_sql_database" "database" { name = "${var.app_name}-db" instance = google_sql_database_instance.postgres.name } resource "random_password" "postgres_password" { length = 32 special = true } resource "google_sql_user" "postgres_user" { name = "${var.app_name}-user" instance = google_sql_database_instance.postgres.name password = random_password.postgres_password.result }
Zmienną odpowiadającą za nazwę aplikacji zdefiniujemy sobie w terraform/variables.tf
:
variable "app_name" { type = string default = "test-app" }
Mając już instancję PostgreSQL, a na niej zdefiniowaną bazę danych i użytkownika, musimy już tylko przekazać aplikacji dane do połączenia. Najlepiej nadaje się do tego obiekt Kubernetes Secret. Aby utworzyć go w Terraform, definiujemy providera kubernetes w sekcji required_providers
:
terraform { required_providers { # … kubernetes = { source = "hashicorp/kubernetes" version = "2.5.1" } } }
Konfigurujemy providera do połączenia z naszym klastrem:
data "google_client_config" "default" {} provider "kubernetes" { host = "https://${google_container_cluster.vpc_native_cluster.endpoint}" cluster_ca_certificate = base64decode(google_container_cluster.vpc_native_cluster.master_auth[0].cluster_ca_certificate) token = data.google_client_config.default.access_token }
Dzięki temu providerowi definiujemy sekret, który zostanie utworzony w naszym klastrze Kubernetes. Wstrzykujemy do niego użytkownika i hasło do bazy danych, jej nazwę oraz adres IP instancji PostgreSQL:
resource "kubernetes_secret" "postgres_credentials" { metadata { name = "postgres-credentials" } data = { host = google_sql_database_instance.postgres.first_ip_address db_name = google_sql_database.database.name username = google_sql_user.postgres_user.name password = google_sql_user.postgres_user.password } type = "kubernetes.io/basic-auth" }
Deployment w k8s
W Kubernetes możemy łatwo podpiąć wartości z naszego sekretu do obiektu Deployment
. Zrobimy to, modyfikując jego definicję w deployment.yml
, aby wyglądała ona tak:
apiVersion: apps/v1 kind: Deployment metadata: name: test-app labels: app: test-app environment: test spec: replicas: 1 selector: matchLabels: environment: test app: test-app template: metadata: labels: environment: test app: test-app spec: containers: - name: test-app image: gcr.io/{{GCLOUD_PROJECT_ID}}/gke-terraform-demo:{{IMAGE_VERSION}} imagePullPolicy: Always ports: - containerPort: 8080 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 120 readinessProbe: httpGet: path: /actuator/health port: 8080 env: - name: POSTGRES_HOST valueFrom: secretKeyRef: name: postgres-credentials key: host - name: POSTGRES_DB valueFrom: secretKeyRef: name: postgres-credentials key: db_name - name: POSTGRES_USERNAME valueFrom: secretKeyRef: name: postgres-credentials key: username - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-credentials key: password - name: SPRING_PROFILES_ACTIVE value: "deployment"
W rzeczywistości podpięliśmy wartości z sekretu do zmiennych środowiskowych które zostaną zdefiniowane wewnątrz kontenera, w którym będzie uruchamiana nasza aplikacja. Kolejna zmienna, SPRING_PROFILES_ACTIVE
zostanie automatycznie odebrana przez Springa jako profile uruchomieniowe. Użyjemy jej, aby włączyć automatyczne tworzenie tabel (ddl-auto
) dopiero po uruchomieniu aplikacji w kontenerze. Ze zmiennych środowiskowych możemy skorzystać w application.yml
, definiując parametry połączenia do bazy danych:
spring: jpa: database-platform: org.hibernate.dialect.PostgreSQL10Dialect datasource: platform: postgres url: jdbc:postgresql://${POSTGRES_HOST:127.0.0.1}:5432/${POSTGRES_DB:test} username: ${POSTGRES_USERNAME:postgres} password: ${POSTGRES_PASSWORD:postgres} driverClassName: org.postgresql.Driver --- spring: config.activate.on-profile: deployment jpa.hibernate.ddl-auto: update
Testowa tabela w bazie i kontroler
W naszej apce będziemy potrzebować dodatkowych dependencji aby połączyć się z bazą PostgrSQL:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.2.24</version> </dependency>
Teraz możemy zdefiniować encję bazodanową Book
, dla której utworzymy w bazie danych tabelę oraz będziemy mogli wykonać testowo odczyt i zapis:
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; static Book of(String title) { final Book book = new Book(); book.title = title; return book; } }
Zdefiniujmy repozytorium dla encji Book
:
public interface BookRepository extends JpaRepository<Book, Long> { }
I podepnijmy je w kontrolerze pozwalającym na odczyt/zapis:
@RestController @RequestMapping("books") @RequiredArgsConstructor public class BookController { private final BookRepository repository; @GetMapping public List<Book> getBooks() { return repository.findAll(); } @PostMapping public ResponseEntity<Book> addNewBook(@RequestBody AddNewBookRequest request) { return ResponseEntity.status(CREATED).body( repository.save(Book.of(request.getTitle())) ); } } @Data public class AddNewBookRequest { private String title; }
Wdrożenie aplikacji
Teraz, aby wdrożyć aplikację, przerzucamy cały kod do Cloud Shella. Następnie wchodzimy do folderu terraform
i wywołujemy:
terraform init -upgrade
Komenda powinna pobrać nam nowego providera kubernetes, którego przed chwilą zdefiniowaliśmy. Teraz możemy już zaaplikować zmiany do naszej infrastruktury:
terraform apply -var project_id="<TWOJE-ID-PROJEKTU>"
Teraz wracamy do głównego folderu i budujemy nową wersję naszej aplikacji:
mvn spring-boot:build-image -Dgcloud.project.id=<TWOJE-ID-PROJEKTU>
I pushujemy ją do registry:
docker push gcr.io/<TWOJE-ID-PROJEKTU>/gke-terraform-demo:0.0.1-SNAPSHOT
Jeszcze raz pobieramy dane uwierzytelnienia z naszego klastra Kubernetes:
gcloud container clusters get-credentials my-gke-cluster --zone=europe-central2-a
Teraz możemy usunąć stary Deployment
i wrzucić jego zaktualizowaną definicję (usuwamy aby zmusić Kubernetesa do pobrania nowej wersji obrazu, mimo że ma taką samą nazwę).
kubectl delete deployment test-app kubectl apply -f target/deployment.yml
Sprawdźmy, czy wszystko wstało:
kubectl get deployments
Powinniśmy otrzymać coś podobnego:
NAME READY UP-TO-DATE AVAILABLE AGE test-app 1/1 1 1 30s
Teraz już możemy przetestować naszą apkę i połączenie z bazą danych. Utwórzmy sobie parę książek:
POST /books HTTP/1.1 Host: <PUBLICZNY-ADRES-IP>:8080 Content-Type: application/json { "title": "Game of Thrones" }
Powinniśmy dostać w odpowiedzi:
HTTP/1.1 201 Content-Type: application/json { "id": 2, "title": "Game of Thrones" }
A teraz spróbujmy pobrać sobie wszystkie książki:
GET /books HTTP/1.1 Host: <PUBLICZNY-ADRES-IP>:8080
Jeśli wszystko się udało, powinniśmy otrzymać:
HTTP/1.1 200 Content-Type: application/json [ { "id": 1, "title": "Game of Thrones" }, { "id": 2, "title": "Harry Potter and the Chamger of Secrets" } ]
Cool, mamy teraz w pełni działającą aplikację Spring na GKE z bazą SQL, wszystko postawione z użyciem Terraform!
Zagadnienia bezpieczeństwa
Przede wszystkim miejcie świadomość, że to tylko projekt poglądowy – nie wszystkie opisane tu kroki spełniają zalecenia bezpieczeństwa i powinny być stosowane w systemach produkcyjnych. To temat na zupełnie inny artykuł (a może i kilka), ale wymienię chociaż dwa:
- Stan Terraforma jest tutaj trzymany lokalnie. Ponieważ jednak zawiera on hasła do bazy danych, powinniśmy traktować go jako daną wrażliwą. Czyli co najmniej przechowywać na dedykowanym serwerze, w postaci zaszyfrowanej.
- Baza danych jest dostępna tylko z prywatnego adresu IP i to dobrze, ale najbezpieczniejszą i rekomendowaną formą połączenia aplikacji z nią jest Cloud SQL Auth Proxy. Możemy go uruchomić jako sidecar, w tym samym podzie, obok naszej aplikacji i kierować ruch do bazy danych przez niego.
Czas na sprzątanie
Całą infrastrukturę możesz teraz złożyć za pomocą komendy, wywołanej w folderze terraform
:
terraform apply -var project_id="<TWOJE-ID-PROJEKTU>"
Polecam po tym również usunąć dla pewności projekt, na którym pracowałeś.
Podsumowanie
Jeśli przeszedłeś cały artykuł, powinieneś już mieć działającą aplikację Spring na GKE, podniesioną z użyciem Terraform i z podpiętą do niej bazą danych SQL. Co najważniejsze jednak, wszystko działa w chmurze a Ty możesz podnieść lub zniszczyć to wszystko za pomocą kilku komend. Mam nadzieję, że pokazuje to moc możliwości, jakie oferuje Terraform.