From 0f51815aa56da51ae08c19e2470f6a4617bf4e1a Mon Sep 17 00:00:00 2001 From: Yeonri Date: Sat, 29 Aug 2026 04:32:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20DB=20EC2=20=EC=A0=84=ED=99=98=20=EB=B0=8F?= =?UTF-8?q?=20Bruno=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 4 + .github/workflows/load-test-run.yml | 40 ++ .github/workflows/load-test-stop.yml | 4 +- .gitignore | 4 +- environment/load_test/main.tf | 145 +++--- environment/load_test/output.tf | 42 +- .../templates/load_test_mysql_setup.sh.tftpl | 210 +++++++++ environment/load_test/variables.tf | 107 ++++- scripts/load_test/README.md | 72 ++- scripts/load_test/generate_bruno_k6.py | 445 ++++++++++++++++++ scripts/load_test/run_k6.sh | 43 +- scripts/load_test/start.sh | 57 ++- .../load_test/tests/test_generate_bruno_k6.py | 129 +++++ 13 files changed, 1177 insertions(+), 125 deletions(-) create mode 100644 .gitattributes create mode 100644 environment/load_test/templates/load_test_mysql_setup.sh.tftpl create mode 100644 scripts/load_test/generate_bruno_k6.py create mode 100644 scripts/load_test/tests/test_generate_bruno_k6.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..07ade0c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +*.tftpl text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/workflows/load-test-run.yml b/.github/workflows/load-test-run.yml index 6ccb7f4..34a7745 100644 --- a/.github/workflows/load-test-run.yml +++ b/.github/workflows/load-test-run.yml @@ -18,6 +18,19 @@ on: required: true default: "15m" type: string + test_mode: + description: "k6 test mode" + required: true + default: "bruno-all-apis" + type: choice + options: + - bruno-all-apis + - whole-user-flow + api_docs_ref: + description: "api-docs git ref used when test_mode is bruno-all-apis" + required: false + default: "main" + type: string target_base_url: description: "Target base URL. Empty uses Terraform default." required: false @@ -60,6 +73,15 @@ jobs: token: ${{ secrets.GH_PAT }} persist-credentials: false + - uses: actions/checkout@v4 + if: inputs.test_mode == 'bruno-all-apis' + with: + repository: solid-connection/api-docs + ref: ${{ inputs.api_docs_ref }} + path: api-docs + token: ${{ secrets.GH_PAT }} + persist-credentials: false + - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_LOAD_TEST_ROLE_ARN }} @@ -80,6 +102,7 @@ jobs: VUS: ${{ inputs.vus }} ITERATIONS: ${{ inputs.iterations }} MAX_DURATION: ${{ inputs.max_duration }} + TEST_MODE: ${{ inputs.test_mode }} TARGET_BASE_URL: ${{ inputs.target_base_url }} PROMETHEUS_REMOTE_WRITE_URL: ${{ inputs.prometheus_remote_write_url }} run: | @@ -89,6 +112,23 @@ jobs: --max-duration "$MAX_DURATION" ) + case "$TEST_MODE" in + bruno-all-apis) + args+=( + --script bruno-all-apis.js + --generate-bruno-script + --bruno-collection-dir "api-docs/Solid Connection" + ) + ;; + whole-user-flow) + args+=(--script whole-user-flow.js) + ;; + *) + echo "::error::Invalid test_mode: $TEST_MODE" + exit 1 + ;; + esac + if [ -n "$TARGET_BASE_URL" ]; then args+=(--target-base-url "$TARGET_BASE_URL") fi diff --git a/.github/workflows/load-test-stop.yml b/.github/workflows/load-test-stop.yml index d5ceffa..10f2e82 100644 --- a/.github/workflows/load-test-stop.yml +++ b/.github/workflows/load-test-stop.yml @@ -8,7 +8,7 @@ on: required: true default: true type: boolean - destroy_rds: + destroy_infra: description: "Destroy load test Terraform stack" required: true default: true @@ -58,7 +58,7 @@ jobs: args+=(--restore-stage-dev) fi - if [ "${{ inputs.destroy_rds }}" != "true" ]; then + if [ "${{ inputs.destroy_infra }}" != "true" ]; then args+=(--skip-terraform-destroy) fi diff --git a/.gitignore b/.gitignore index d95d421..c1f7668 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.tfvars .terraform.lock.hcl modules/shared_resources/dist/*.zip +config/load-test/k6/bruno-all-apis.js +__pycache__/ # --- Secrets (보안상 절대 커밋 금지) --- *.pem @@ -17,4 +19,4 @@ modules/shared_resources/dist/*.zip .DS_Store # --- AGENTS --- -AGENTS.local.md \ No newline at end of file +AGENTS.local.md diff --git a/environment/load_test/main.tf b/environment/load_test/main.tf index 7669a7d..3b8b370 100644 --- a/environment/load_test/main.tf +++ b/environment/load_test/main.tf @@ -22,15 +22,34 @@ data "aws_instance" "stage_api" { } } +data "aws_instance" "prod_db" { + filter { + name = "tag:Name" + values = [var.prod_db_instance_name] + } + + filter { + name = "instance-state-name" + values = ["running"] + } +} + data "aws_subnet" "stage_api" { id = data.aws_instance.stage_api.subnet_id } -data "aws_subnets" "target" { - filter { - name = "vpc-id" - values = [data.aws_subnet.stage_api.vpc_id] - } +locals { + load_test_db_subnet_id = var.load_test_db_subnet_id != null ? var.load_test_db_subnet_id : data.aws_instance.stage_api.subnet_id + load_test_db_ami_id = var.load_test_db_ami_id != null ? var.load_test_db_ami_id : data.aws_instance.prod_db.ami + + source_security_group_ids = setunion( + data.aws_instance.prod_api.vpc_security_group_ids, + data.aws_instance.stage_api.vpc_security_group_ids + ) +} + +data "aws_subnet" "load_test_db" { + id = local.load_test_db_subnet_id } data "aws_ami" "ubuntu" { @@ -48,27 +67,10 @@ data "aws_ami" "ubuntu" { } } -data "aws_db_instance" "prod" { - db_instance_identifier = var.prod_rds_identifier -} - -data "aws_db_snapshot" "latest_prod" { - db_instance_identifier = var.prod_rds_identifier - most_recent = true - snapshot_type = "automated" -} - -locals { - source_security_group_ids = setunion( - data.aws_instance.prod_api.vpc_security_group_ids, - data.aws_instance.stage_api.vpc_security_group_ids - ) -} - resource "aws_security_group" "load_test_db" { name = "sc-load-test-db-sg" - description = "Security group for load test RDS" - vpc_id = data.aws_subnet.stage_api.vpc_id + description = "Security group for load test MySQL EC2" + vpc_id = data.aws_subnet.load_test_db.vpc_id egress { from_port = 0 @@ -78,7 +80,9 @@ resource "aws_security_group" "load_test_db" { } tags = { - Name = "solid-connection-load-test-db-sg" + Name = "solid-connection-load-test-db-sg" + Project = "solid-connection" + Env = "load_test" } } @@ -94,6 +98,65 @@ resource "aws_security_group_rule" "load_test_db_mysql" { source_security_group_id = each.value } +resource "aws_ebs_volume" "load_test_db_data" { + availability_zone = data.aws_subnet.load_test_db.availability_zone + size = var.allocated_storage + type = "gp3" + encrypted = true + + tags = { + Name = "${var.load_test_db_instance_name}-data" + Project = "solid-connection" + Env = "load_test" + } +} + +resource "aws_instance" "load_test_db" { + ami = local.load_test_db_ami_id + instance_type = var.load_test_db_instance_type + subnet_id = local.load_test_db_subnet_id + vpc_security_group_ids = [aws_security_group.load_test_db.id] + associate_public_ip_address = var.load_test_db_associate_public_ip + iam_instance_profile = var.load_test_db_instance_profile_name + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + } + + root_block_device { + volume_size = 8 + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + + user_data = templatefile("${path.module}/templates/load_test_mysql_setup.sh.tftpl", { + aws_region = "ap-northeast-2" + data_volume_id = aws_ebs_volume.load_test_db_data.id + db_name = var.db_name + load_test_parameter_prefix = var.load_test_parameter_prefix + mysql_backup_bucket_name = var.mysql_backup_bucket_name + mysql_config_content = file("${path.module}/../../modules/app_stack/templates/mysql_tuning.cnf") + }) + + user_data_replace_on_change = true + + tags = { + Name = var.load_test_db_instance_name + Project = "solid-connection" + Env = "load_test" + } +} + +resource "aws_volume_attachment" "load_test_db_data" { + device_name = "/dev/sdf" + volume_id = aws_ebs_volume.load_test_db_data.id + instance_id = aws_instance.load_test_db.id + stop_instance_before_detaching = true +} + resource "aws_security_group" "load_generator" { count = var.create_load_generator ? 1 : 0 @@ -152,39 +215,9 @@ resource "aws_instance" "load_generator" { } } -resource "aws_db_subnet_group" "load_test" { - name = "sc-load-test-db-subnet-group" - subnet_ids = data.aws_subnets.target.ids - - tags = { - Name = "solid-connection-load-test-db-subnet-group" - } -} - -resource "aws_db_instance" "load_test" { - identifier = var.rds_identifier - instance_class = var.db_instance_class - parameter_group_name = var.db_parameter_group_name - snapshot_identifier = data.aws_db_snapshot.latest_prod.id - db_subnet_group_name = aws_db_subnet_group.load_test.name - vpc_security_group_ids = [aws_security_group.load_test_db.id] - publicly_accessible = false - skip_final_snapshot = true - copy_tags_to_snapshot = true - deletion_protection = false - backup_retention_period = 0 - apply_immediately = true - storage_encrypted = true - kms_key_id = var.kms_key_arn - - tags = { - Name = var.rds_identifier - } -} - resource "aws_ssm_parameter" "load_test_datasource_url" { name = "${var.load_test_parameter_prefix}/spring.datasource.url" type = "String" - value = "jdbc:mysql://${aws_db_instance.load_test.address}:${aws_db_instance.load_test.port}/${var.db_name}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8" + value = "jdbc:mysql://${aws_instance.load_test_db.private_ip}:3306/${var.db_name}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8" overwrite = true } diff --git a/environment/load_test/output.tf b/environment/load_test/output.tf index cae6815..0c43fbe 100644 --- a/environment/load_test/output.tf +++ b/environment/load_test/output.tf @@ -1,16 +1,26 @@ -output "load_test_rds_endpoint" { - description = "Load test RDS endpoint" - value = aws_db_instance.load_test.address +output "load_test_db_endpoint" { + description = "Load-test MySQL EC2 private endpoint" + value = aws_instance.load_test_db.private_ip } -output "load_test_rds_port" { - description = "Load test RDS port" - value = aws_db_instance.load_test.port +output "load_test_db_port" { + description = "Load-test MySQL EC2 port" + value = 3306 } -output "load_test_rds_identifier" { - description = "Load test RDS identifier" - value = aws_db_instance.load_test.identifier +output "load_test_db_instance_id" { + description = "Load-test MySQL EC2 instance ID" + value = aws_instance.load_test_db.id +} + +output "load_test_db_private_ip" { + description = "Load-test MySQL EC2 private IP" + value = aws_instance.load_test_db.private_ip +} + +output "load_test_db_data_volume_id" { + description = "Load-test MySQL EC2 data EBS volume ID" + value = aws_ebs_volume.load_test_db_data.id } output "load_test_db_name" { @@ -18,18 +28,18 @@ output "load_test_db_name" { value = var.db_name } -output "prod_rds_endpoint" { - description = "Prod RDS endpoint used as dump source" - value = data.aws_db_instance.prod.address +output "prod_db_instance_id" { + description = "Prod MySQL EC2 instance ID used as the default AMI source" + value = data.aws_instance.prod_db.id } -output "prod_rds_port" { - description = "Prod RDS port" - value = data.aws_db_instance.prod.port +output "prod_db_private_ip" { + description = "Prod MySQL EC2 private IP" + value = data.aws_instance.prod_db.private_ip } output "prod_api_instance_id" { - description = "Prod API EC2 instance ID whose security group can access load-test RDS" + description = "Prod API EC2 instance ID whose security group can access load-test MySQL EC2" value = data.aws_instance.prod_api.id } diff --git a/environment/load_test/templates/load_test_mysql_setup.sh.tftpl b/environment/load_test/templates/load_test_mysql_setup.sh.tftpl new file mode 100644 index 0000000..4457531 --- /dev/null +++ b/environment/load_test/templates/load_test_mysql_setup.sh.tftpl @@ -0,0 +1,210 @@ +#!/bin/bash +set -Eeuo pipefail + +AWS_REGION="${aws_region}" +DATA_VOLUME_ID="${data_volume_id}" +DB_NAME="${db_name}" +LOAD_TEST_PARAMETER_PREFIX="${load_test_parameter_prefix}" +MYSQL_BACKUP_BUCKET="${mysql_backup_bucket_name}" + +export AWS_REGION +export AWS_DEFAULT_REGION="$AWS_REGION" + +READY_FILE="/opt/solid-connection/load-test-db-ready" +DATA_VOLUME_SERIAL="$(printf '%s' "$DATA_VOLUME_ID" | tr -d '-')" +DATA_VOLUME_DEVICE="/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_$DATA_VOLUME_SERIAL" +DATA_VOLUME_MOUNT="/mnt/mysql-data" +MYSQL_DATA_DIR="$DATA_VOLUME_MOUNT/mysql" +RESTORE_ROOT="$DATA_VOLUME_MOUNT/mysql-restore" + +require_command() { + command -v "$1" >/dev/null || { + echo "Required command is not installed: $1" >&2 + exit 1 + } +} + +require_command aws +require_command blkid +require_command docker +require_command gzip +require_command mkfs.ext4 +require_command mountpoint +require_command python3 +require_command sha256sum + +if ! [[ "$DB_NAME" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "Invalid DB_NAME: $DB_NAME" >&2 + exit 1 +fi + +systemctl enable docker +systemctl start docker + +if systemctl list-unit-files | grep -q '^snap.amazon-ssm-agent.amazon-ssm-agent.service'; then + systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service || true + systemctl restart snap.amazon-ssm-agent.amazon-ssm-agent.service || true +elif systemctl list-unit-files | grep -q '^amazon-ssm-agent.service'; then + systemctl enable amazon-ssm-agent.service || true + systemctl restart amazon-ssm-agent.service || true +fi + +for _ in $(seq 1 120); do + if [ -e "$DATA_VOLUME_DEVICE" ]; then + break + fi + sleep 2 +done + +if [ ! -e "$DATA_VOLUME_DEVICE" ]; then + echo "Data volume $DATA_VOLUME_ID was not attached within 240 seconds." >&2 + ls -la /dev/disk/by-id >&2 || true + exit 1 +fi + +if ! blkid "$DATA_VOLUME_DEVICE" >/dev/null 2>&1; then + mkfs.ext4 -F "$DATA_VOLUME_DEVICE" +fi + +mkdir -p "$DATA_VOLUME_MOUNT" +DATA_VOLUME_UUID="$(blkid -s UUID -o value "$DATA_VOLUME_DEVICE")" + +awk -v mount="$DATA_VOLUME_MOUNT" '$2 != mount { print }' /etc/fstab > /etc/fstab.tmp +printf 'UUID=%s %s ext4 defaults,nofail 0 2\n' "$DATA_VOLUME_UUID" "$DATA_VOLUME_MOUNT" >> /etc/fstab.tmp +mv /etc/fstab.tmp /etc/fstab + +mountpoint -q "$DATA_VOLUME_MOUNT" || mount "$DATA_VOLUME_MOUNT" +mountpoint -q "$DATA_VOLUME_MOUNT" || { + echo "Failed to mount data volume $DATA_VOLUME_ID at $DATA_VOLUME_MOUNT." >&2 + exit 1 +} + +mkdir -p /etc/systemd/system/docker.service.d +cat > /etc/systemd/system/docker.service.d/10-require-mysql-data.conf <&2 + exit 1 +fi + +mkdir -p "$MYSQL_DATA_DIR" "$RESTORE_ROOT" /etc/mysql/conf.d /opt/solid-connection +chown -R 999:999 "$MYSQL_DATA_DIR" +chmod 750 "$MYSQL_DATA_DIR" + +cat > /etc/mysql/conf.d/tuning.cnf <<'CNFEOF' +${mysql_config_content} +CNFEOF +chmod 644 /etc/mysql/conf.d/tuning.cnf + +docker image inspect mysql:8.4.8 >/dev/null 2>&1 || docker pull mysql:8.4.8 +docker rm -f mysql-server 2>/dev/null || true +docker run -d \ + --name mysql-server \ + --restart always \ + -p 3306:3306 \ + -v "$MYSQL_DATA_DIR:/var/lib/mysql" \ + -v /etc/mysql/conf.d:/etc/mysql/conf.d \ + -e MYSQL_ROOT_PASSWORD="$DB_APP_PASSWORD" \ + mysql:8.4.8 + +MYSQL_READY=false +for _ in $(seq 1 90); do + if docker exec mysql-server mysql -uroot -p"$DB_APP_PASSWORD" -e "SELECT 1" >/dev/null 2>&1; then + MYSQL_READY=true + break + fi + sleep 2 +done + +if [ "$MYSQL_READY" != "true" ]; then + echo "MySQL container did not become ready within 180 seconds." >&2 + docker logs --tail 100 mysql-server >&2 || true + exit 1 +fi + +sql_escape() { + python3 -c "import sys; print(sys.argv[1].replace('\\\\', '\\\\\\\\').replace(\"'\", \"\\\\'\"))" "$1" +} + +DB_APP_USER_SQL="$(sql_escape "$DB_APP_USER")" +DB_APP_PASSWORD_SQL="$(sql_escape "$DB_APP_PASSWORD")" + +docker exec -i mysql-server mysql -uroot -p"$DB_APP_PASSWORD" <&2 + exit 1 + fi + + restore_dir="$(mktemp -d "$RESTORE_ROOT/restore.XXXXXX")" + manifest_file="$restore_dir/manifest.json" + aws s3 cp "s3://$MYSQL_BACKUP_BUCKET/$latest_manifest_key" "$manifest_file" \ + --region "$AWS_REGION" \ + --only-show-errors \ + --no-progress + + dump_file="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["dumpFile"])' "$manifest_file")" + dump_sha256="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["sha256"])' "$manifest_file")" + dump_prefix="$${latest_manifest_key%/manifest.json}" + dump_path="$restore_dir/$dump_file" + + aws s3 cp "s3://$MYSQL_BACKUP_BUCKET/$dump_prefix/$dump_file" "$dump_path" \ + --region "$AWS_REGION" \ + --only-show-errors \ + --no-progress + + actual_sha256="$(sha256sum "$dump_path" | awk '{print $1}')" + if [ "$actual_sha256" != "$dump_sha256" ]; then + echo "Dump checksum mismatch: expected=$dump_sha256 actual=$actual_sha256" >&2 + exit 1 + fi + + docker exec -i mysql-server mysql -uroot -p"$DB_APP_PASSWORD" < "$READY_FILE" diff --git a/environment/load_test/variables.tf b/environment/load_test/variables.tf index 9d9ed09..c396515 100644 --- a/environment/load_test/variables.tf +++ b/environment/load_test/variables.tf @@ -1,124 +1,189 @@ variable "rds_identifier" { - description = "RDS identifier for load test" + description = "Deprecated. RDS 기반 부하 테스트 DB 식별자 호환 입력입니다." type = string + default = null + nullable = true } variable "db_instance_class" { - description = "RDS instance class for load test" + description = "Deprecated. RDS 기반 부하 테스트 DB 인스턴스 클래스 호환 입력입니다." type = string + default = null + nullable = true } variable "allocated_storage" { - description = "RDS storage in GiB" + description = "Load-test MySQL EC2 데이터 EBS 볼륨 크기(GiB)입니다." type = number default = 20 + + validation { + condition = var.allocated_storage >= 1 + error_message = "allocated_storage는 1GiB 이상이어야 합니다." + } } variable "db_engine_version" { - description = "Deprecated. The load-test RDS is restored from the latest prod snapshot." + description = "Deprecated. RDS 기반 부하 테스트 DB 엔진 버전 호환 입력입니다." type = string default = null nullable = true } variable "db_parameter_group_name" { - description = "MySQL parameter group name" + description = "Deprecated. RDS 기반 부하 테스트 DB 파라미터 그룹 호환 입력입니다." type = string + default = null + nullable = true } variable "db_name" { - description = "Application database name" + description = "부하 테스트 대상 애플리케이션 DB 이름입니다." type = string default = "solid_connection" } variable "load_test_db_username_parameter_name" { - description = "Deprecated compatibility input. Load-test datasource credentials are copied from prod datasource parameters." + description = "Deprecated. datasource username은 load-test Parameter Store 경로에서 직접 읽습니다." type = string default = null nullable = true } variable "load_test_db_password_parameter_name" { - description = "Deprecated compatibility input. Load-test datasource credentials are copied from prod datasource parameters." + description = "Deprecated. datasource password는 load-test Parameter Store 경로에서 직접 읽습니다." type = string default = null nullable = true } variable "kms_key_arn" { - description = "KMS key ARN for RDS storage encryption" + description = "Deprecated. RDS 기반 부하 테스트 DB 스토리지 암호화 KMS ARN 호환 입력입니다." type = string + default = null + nullable = true } variable "ssm_kms_key_id" { - description = "Deprecated compatibility input. Terraform no longer writes a load-test DB password SecureString." + description = "Deprecated. Terraform은 더 이상 load-test DB password SecureString을 작성하지 않습니다." type = string default = null nullable = true } variable "prod_rds_identifier" { - description = "Source prod RDS identifier" + description = "Deprecated. RDS snapshot 조회용 prod RDS identifier 호환 입력입니다." + type = string + default = null + nullable = true +} + +variable "prod_db_instance_name" { + description = "prod MySQL EC2의 Name tag입니다. load-test DB AMI 기본값을 조회할 때 사용합니다." + type = string + default = "solid-connection-db-mysql-prod" +} + +variable "load_test_db_instance_name" { + description = "load-test MySQL EC2에 부여할 Name tag입니다." + type = string + default = "solid-connection-db-mysql-loadtest" +} + +variable "load_test_db_instance_type" { + description = "load-test MySQL EC2 인스턴스 타입입니다." + type = string + default = "t3.medium" +} + +variable "load_test_db_ami_id" { + description = "load-test MySQL EC2 AMI ID입니다. null이면 prod MySQL EC2의 AMI를 사용합니다." + type = string + default = null + nullable = true +} + +variable "load_test_db_subnet_id" { + description = "load-test MySQL EC2를 배치할 subnet ID입니다. null이면 stage API EC2와 같은 subnet을 사용합니다." + type = string + default = null + nullable = true +} + +variable "load_test_db_associate_public_ip" { + description = "load-test MySQL EC2 public IP 할당 여부입니다." + type = bool + default = false +} + +variable "load_test_db_instance_profile_name" { + description = "load-test MySQL EC2에 연결할 IAM instance profile 이름입니다. SSM Parameter Store 조회와 S3 백업 조회 권한이 필요합니다." + type = string + default = "SolidConnectionParameterStoreReadProfile" +} + +variable "mysql_backup_bucket_name" { + description = "prod MySQL dump manifest와 dump 파일을 읽을 S3 bucket 이름입니다." type = string + default = "solid-connection-prod-mysql-backup" } variable "prod_api_instance_name" { - description = "Name tag of the prod API EC2 instance whose security group can access load-test RDS" + description = "load-test MySQL EC2에 접근할 prod API EC2의 Name tag입니다." type = string default = "solid-connection-server-prod" } variable "stage_api_instance_name" { - description = "Name tag of the stage API EC2 instance that will connect to load test RDS" + description = "load-test MySQL EC2에 접근하고 loadtest profile로 전환할 stage API EC2의 Name tag입니다." type = string default = "solid-connection-server-stage" } variable "load_test_parameter_prefix" { - description = "SSM Parameter Store prefix for load test datasource values" + description = "load-test datasource 값을 기록하거나 읽을 SSM Parameter Store prefix입니다." type = string default = "/solid-connection/loadtest" } variable "load_generator_instance_type" { - description = "EC2 instance type for the k6 load generator" + description = "k6 load-generator EC2 인스턴스 타입입니다." type = string default = "c7i.xlarge" } variable "create_load_generator" { - description = "Whether to create the k6 load generator EC2 instance" + description = "k6 load-generator EC2 생성 여부입니다." type = bool default = false } variable "load_generator_instance_profile_name" { - description = "Existing IAM instance profile name for the k6 load generator. It must allow SSM RunCommand." + description = "k6 load-generator EC2에 연결할 IAM instance profile 이름입니다. SSM RunCommand 권한이 필요합니다." type = string default = "solid-connection-load-test-generator" } variable "load_generator_root_volume_size" { - description = "Root volume size in GiB for the k6 load generator" + description = "k6 load-generator EC2 root volume 크기(GiB)입니다." type = number default = 20 } variable "load_generator_k6_dir" { - description = "Directory where k6 files are placed on the load generator" + description = "load-generator EC2에 k6 파일을 배치할 디렉터리입니다." type = string default = "/home/ubuntu/solid-connection-load-test/k6" } variable "load_test_target_base_url" { - description = "Default target base URL for k6" + description = "k6가 호출할 기본 target base URL입니다." type = string default = "https://api.stage.solid-connection.com" } variable "k6_prometheus_remote_write_url" { - description = "Default Prometheus remote-write URL for k6. Empty disables remote-write unless the workflow input overrides it." + description = "k6 Prometheus remote-write URL입니다. 빈 값이면 workflow 입력값이 없을 때 remote-write를 비활성화합니다." type = string default = "" } diff --git a/scripts/load_test/README.md b/scripts/load_test/README.md index 20f369f..366da0d 100644 --- a/scripts/load_test/README.md +++ b/scripts/load_test/README.md @@ -20,14 +20,17 @@ `environment/load_test`는 `config/secrets/load_test.tfvars`를 사용합니다. -스냅샷 복원 방식에서는 load-test DB root 계정을 별도로 만들지 않습니다. Terraform은 datasource URL만 갱신하고 username/password는 읽거나 복사하지 않습니다. load-test datasource username/password는 앱의 Parameter Store 연동이 loadtest 경로에서 직접 읽습니다. +load-test DB는 RDS snapshot을 복원하지 않고 별도 MySQL EC2로 생성합니다. EC2 user data가 prod MySQL 백업 S3 bucket에서 최신 dump manifest를 찾고, dump 파일을 검증한 뒤 MySQL container에 복원합니다. + +Terraform은 datasource URL만 load-test DB EC2 private IP로 갱신합니다. username/password는 읽거나 복사하지 않습니다. load-test datasource username/password는 앱의 Parameter Store 연동이 loadtest 경로에서 직접 읽습니다. 주요 확인값: -- `prod_rds_identifier`: snapshot을 조회할 prod RDS identifier -- `kms_key_arn`: 복원된 load-test RDS storage encryption에 사용할 KMS key ARN -- `/solid-connection/loadtest/spring.datasource.username`: 앱이 loadtest profile에서 읽는 DB username. snapshot 복원 직후에는 prod DB username과 같은 값이어야 합니다. -- `/solid-connection/loadtest/spring.datasource.password`: 앱이 loadtest profile에서 읽는 SecureString. snapshot 복원 직후에는 prod DB password와 같은 값이어야 합니다. +- `prod_db_instance_name`: prod MySQL EC2의 Name tag입니다. `load_test_db_ami_id`가 비어 있으면 이 EC2의 AMI를 사용합니다. +- `load_test_db_instance_profile_name`: load-test MySQL EC2에 연결할 IAM instance profile 이름입니다. 기본값은 `SolidConnectionParameterStoreReadProfile`입니다. +- `mysql_backup_bucket_name`: prod MySQL dump manifest와 dump 파일이 저장되는 S3 bucket 이름입니다. 기본값은 `solid-connection-prod-mysql-backup`입니다. +- `/solid-connection/loadtest/spring.datasource.username`: 앱이 loadtest profile에서 읽는 DB username입니다. +- `/solid-connection/loadtest/spring.datasource.password`: 앱이 loadtest profile에서 읽는 SecureString입니다. 그 외 부하 테스트 설정값은 Terraform 기본값, GitHub Actions variable, workflow 입력값으로 처리합니다. @@ -43,13 +46,15 @@ GitHub에서 **Actions > Load Test Start**를 수동 실행합니다. Start workflow 동작: 1. GitHub Actions가 `environment/load_test`에서 Terraform apply를 실행합니다. -2. Terraform이 최신 prod RDS 자동 snapshot을 조회합니다. -3. Terraform이 해당 snapshot에서 load-test RDS를 복원합니다. +2. Terraform이 prod MySQL EC2와 prod/stage API EC2를 Name tag로 조회합니다. +3. Terraform이 load-test MySQL EC2, 데이터 EBS 볼륨, 보안 그룹을 생성합니다. 4. Terraform이 load-test datasource URL을 Parameter Store에 기록합니다. - - datasource URL은 복원된 load-test RDS endpoint를 사용합니다. + - datasource URL은 load-test MySQL EC2 private IP를 사용합니다. - datasource username/password는 Parameter Store에 복사하지 않습니다. -5. `scripts/load_test/start.sh`가 Terraform output에서 필요한 값을 읽습니다. -6. `switch_stage_to_loadtest=true`이면 stage 앱을 `dev,loadtest` profile로 재기동합니다. username/password는 앱의 Parameter Store 연동이 loadtest 경로에서 읽습니다. +5. load-test MySQL EC2 user data가 prod MySQL 백업 S3 bucket의 최신 dump를 내려받아 복원합니다. +6. `scripts/load_test/start.sh`가 Terraform output에서 필요한 값을 읽습니다. +7. `scripts/load_test/start.sh`가 SSM으로 load-test MySQL EC2의 `cloud-init` 완료와 `/opt/solid-connection/load-test-db-ready` marker 생성을 확인합니다. 기본 대기 시간은 3600초입니다. +8. `switch_stage_to_loadtest=true`이면 stage 앱을 `dev,loadtest` profile로 재기동합니다. username/password는 앱의 Parameter Store 연동이 loadtest 경로에서 읽습니다. Start workflow는 load-generator EC2를 만들지 않습니다. 부하 생성용 EC2는 비용 누수를 막기 위해 Run workflow에서만 생성합니다. @@ -62,6 +67,12 @@ GitHub에서 **Actions > Load Test Run**을 수동 실행합니다. - `vus`: k6 virtual user 수입니다. 예: `10` - `iterations`: VU당 반복 횟수입니다. 예: `10` - `max_duration`: 최대 실행 시간입니다. 예: `30s`, `5m`, `15m`, `1h` +- `test_mode`: 실행할 k6 테스트 모드입니다. + - `bruno-all-apis`: `solid-connection/api-docs`의 Bruno 문서를 읽어 전체 API 요청용 k6 스크립트를 생성해 실행합니다. + - `whole-user-flow`: 기존 수동 작성 시나리오인 `whole-user-flow.js`를 실행합니다. +- `api_docs_ref` + - `test_mode=bruno-all-apis`일 때 checkout할 `solid-connection/api-docs`의 git ref입니다. + - 기본값은 `main`입니다. - `target_base_url` - 선택값입니다. 비워두면 Terraform output `load_test_target_base_url`을 사용합니다. - `prometheus_remote_write_url` @@ -80,11 +91,12 @@ Run workflow 동작: 1. `scripts/load_test/run_k6.sh`가 Terraform target apply로 load-generator EC2와 보안 그룹을 생성합니다. 2. Terraform output에서 load-generator EC2 ID와 k6 기본값을 읽습니다. 3. load-generator EC2의 SSM agent가 online 상태가 될 때까지 기다립니다. -4. SSM RunCommand로 k6 파일을 load-generator EC2에 동기화합니다. -5. 이전 실행에서 남아 있을 수 있는 k6 프로세스를 정리합니다. -6. k6 binary가 없거나 `rebuild_k6=true`이면 `set_up_xk6.sh`로 Prometheus remote-write 지원이 포함된 k6를 빌드합니다. -7. load-generator EC2에서 `whole-user-flow.js`를 실행합니다. -8. `destroy_runner=true`이면 실행 성공/실패와 관계없이 load-generator EC2와 보안 그룹을 제거합니다. +4. `test_mode=bruno-all-apis`이면 `solid-connection/api-docs`를 checkout하고 Bruno collection에서 `bruno-all-apis.js`를 생성합니다. +5. SSM RunCommand로 k6 파일을 load-generator EC2에 동기화합니다. +6. 이전 실행에서 남아 있을 수 있는 k6 프로세스를 정리합니다. +7. k6 binary가 없거나 `rebuild_k6=true`이면 `set_up_xk6.sh`로 Prometheus remote-write 지원이 포함된 k6를 빌드합니다. +8. load-generator EC2에서 선택한 k6 스크립트를 실행합니다. +9. `destroy_runner=true`이면 실행 성공/실패와 관계없이 load-generator EC2와 보안 그룹을 제거합니다. `destroy_runner=false`로 runner를 남긴 뒤 다시 Run workflow를 실행해도 됩니다. 이 경우 기존 EC2를 재사용하며, k6 파일은 매번 다시 동기화됩니다. @@ -94,6 +106,30 @@ Run workflow 동작: - `updatePost.json` - `whole-user-flow.js` - `set_up_xk6.sh` +- `bruno-all-apis.js` (`test_mode=bruno-all-apis` 실행 중 생성) + +### Bruno 전체 API 모드 + +`test_mode=bruno-all-apis`는 Bruno 문서의 `.bru` 파일을 파싱해서 전체 API 요청용 k6 스크립트를 생성합니다. + +생성 스크립트의 동작: + +- `/auth/email/sign-in`으로 먼저 로그인한 뒤 `auth: inherit` 요청에 bearer token을 붙입니다. +- 기본 로그인 계정은 `user{{VU}}@example.com`, 기본 비밀번호는 `password`입니다. +- `vars:pre-request`, `BASE_URL`, `BRUNO_VAR_*` 환경 변수로 Bruno 변수를 치환합니다. +- Bruno 예시 JSON body와 multipart form body를 요청 본문으로 사용합니다. +- `{{URL}}` 기반의 Solid Connection API만 기본 실행하고, Kakao API 같은 외부 URL은 제외합니다. +- 반복 실행 계정을 제거하는 `/auth/quit`는 기본 실행 대상에서 제외합니다. +- 예시 ID, 권한, 데이터 선행 조건 때문에 4xx 응답은 허용하고, 기본적으로 5xx 응답만 실패 check로 기록합니다. + +런타임에서 조정 가능한 k6 환경 변수: + +- `BRUNO_ACCESS_TOKEN`: 로그인 대신 이미 발급된 access token을 사용합니다. +- `BRUNO_LOGIN_EMAIL_TEMPLATE`: 로그인 이메일 템플릿입니다. 예: `user{{VU}}@example.com` +- `BRUNO_LOGIN_PASSWORD`: 로그인 비밀번호입니다. +- `BRUNO_REQUEST_SLEEP_SECONDS`: 생성된 요청 사이의 대기 시간입니다. +- `BRUNO_FAIL_ON_5XX`: `false`로 설정하면 5xx 응답도 실패 check로 기록하지 않습니다. +- `BRUNO_VAR_`: Bruno 변수 override입니다. 변수명의 영문/숫자가 아닌 문자는 `_`로 바꿔 지정합니다. ## 결과 확인 @@ -114,14 +150,14 @@ GitHub에서 **Actions > Load Test Stop**을 수동 실행합니다. - `restore_stage_dev`: `true` 또는 `false` - `true`이면 stage 앱을 기존 dev compose 구성으로 되돌립니다. -- `destroy_rds`: `true` 또는 `false` +- `destroy_infra`: `true` 또는 `false` - `true`이면 load-test Terraform stack을 destroy합니다. Stop workflow 동작: 1. `scripts/load_test/stop.sh`가 `environment/load_test`에서 Terraform init을 실행합니다. 2. `restore_stage_dev=true`이면 stage를 dev datasource 구성으로 복구합니다. -3. `destroy_rds=true`이면 Terraform destroy로 load-test RDS와 남아 있는 load-generator EC2를 제거합니다. +3. `destroy_infra=true`이면 Terraform destroy로 load-test MySQL EC2, 데이터 EBS 볼륨, 보안 그룹과 남아 있는 load-generator EC2를 제거합니다. ## 참고 @@ -129,4 +165,4 @@ Stop workflow 동작: - private submodule checkout에는 `GH_PAT`를 사용합니다. - prod/stage EC2는 `Name` tag로 조회합니다. - prod/load-test DB 계정 정보는 Parameter Store에서 읽습니다. -- load-test RDS 보안 그룹은 prod/stage API EC2 보안 그룹에서 들어오는 MySQL 접근만 허용합니다. +- load-test MySQL EC2 보안 그룹은 prod/stage API EC2 보안 그룹에서 들어오는 MySQL 접근만 허용합니다. diff --git a/scripts/load_test/generate_bruno_k6.py b/scripts/load_test/generate_bruno_k6.py new file mode 100644 index 0000000..bc501eb --- /dev/null +++ b/scripts/load_test/generate_bruno_k6.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +from pathlib import Path + + +HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head") +LOGIN_PATH = "/auth/email/sign-in" +TOKEN_ENDING_PATHS = ("/auth/sign-out", "/auth/quit") +DESTRUCTIVE_PATHS = ("/auth/quit",) + + +def brace_delta(line): + """Return the net brace balance contributed by a single Bruno line.""" + return line.count("{") - line.count("}") + + +def read_text(path): + """Read a Bruno file while tolerating invalid byte sequences.""" + return path.read_text(encoding="utf-8", errors="replace") + + +def collect_blocks(text): + """Collect top-level Bruno blocks keyed by block name.""" + lines = text.splitlines() + blocks = {} + index = 0 + + while index < len(lines): + line = lines[index] + match = re.match(r"^\s*([A-Za-z0-9_:-]+)\s*\{\s*$", line) + if not match: + index += 1 + continue + + block_name = match.group(1) + body = [] + balance = brace_delta(line) + index += 1 + + while index < len(lines): + current = lines[index] + next_balance = balance + brace_delta(current) + if next_balance <= 0 and current.strip() == "}": + index += 1 + break + body.append(current) + balance = next_balance + index += 1 + + blocks.setdefault(block_name, []).append("\n".join(body).strip("\n")) + + return blocks + + +def parse_fields(block): + """Parse simple Bruno key-value fields from a block body.""" + fields = {} + for line in block.splitlines(): + match = re.match(r"^\s*(~?[^:]+):\s*(.*)$", line) + if match: + key = match.group(1).strip() + value = match.group(2).strip() + fields[key] = value + return fields + + +def parse_meta(blocks): + """Extract display metadata and sort order from parsed Bruno blocks.""" + meta = parse_fields(blocks.get("meta", [""])[0]) + seq = meta.get("seq", "999999") + try: + seq_number = int(seq) + except ValueError: + seq_number = 999999 + return { + "name": meta.get("name", ""), + "seq": seq_number, + } + + +def parse_vars(blocks): + """Extract pre-request variables defined in Bruno request blocks.""" + variables = {} + for block in blocks.get("vars:pre-request", []): + for key, value in parse_fields(block).items(): + variables[key.lstrip("~")] = value + return variables + + +def clean_body_json(block): + """Return the JSON body text exactly as k6 should send it.""" + return block.strip() + + +def parse_multipart(block): + """Parse Bruno multipart form fields into k6-friendly metadata.""" + fields = [] + for line in block.splitlines(): + match = re.match(r"^\s*(~?[^:]+):\s*(.*?)\s*$", line) + if not match: + continue + + raw_name = match.group(1).strip() + raw_value = match.group(2).strip() + optional = raw_name.startswith("~") + name = raw_name.lstrip("~") + content_type = None + + content_type_match = re.search(r"\s+@contentType\(([^)]+)\)\s*$", raw_value) + if content_type_match: + content_type = content_type_match.group(1) + raw_value = raw_value[: content_type_match.start()].strip() + + file_match = re.match(r"^@file\(([^)]+)\)$", raw_value) + if file_match: + file_path = file_match.group(1) + fields.append( + { + "name": name, + "kind": "file", + "fileName": Path(file_path).name or "bruno-file", + "contentType": content_type or "application/octet-stream", + "optional": optional, + } + ) + continue + + fields.append( + { + "name": name, + "kind": "value", + "value": raw_value, + "contentType": content_type, + "optional": optional, + } + ) + + return fields + + +def parse_request(path, collection_dir): + """Parse one Bruno request file into the intermediate request model.""" + text = read_text(path) + blocks = collect_blocks(text) + method = next((name for name in HTTP_METHODS if name in blocks), None) + if method is None: + return None + + request_fields = parse_fields(blocks[method][0]) + raw_url = request_fields.get("url", "") + if not raw_url: + return None + + body_type = request_fields.get("body", "none") + body = {"type": body_type} + if body_type == "json" and "body:json" in blocks: + body["raw"] = clean_body_json(blocks["body:json"][0]) + elif body_type == "multipartForm" and "body:multipart-form" in blocks: + body["fields"] = parse_multipart(blocks["body:multipart-form"][0]) + + meta = parse_meta(blocks) + relative_path = path.relative_to(collection_dir).as_posix() + display_name = meta["name"] or Path(relative_path).stem + url_without_base = re.sub(r"^\{\{URL\}\}", "", raw_url) + url_without_query = url_without_base.split("?", 1)[0] + + return { + "name": f"{method.upper()} {url_without_query}", + "displayName": display_name, + "relativePath": relative_path, + "method": method.upper(), + "url": raw_url, + "auth": request_fields.get("auth", "inherit"), + "body": body, + "vars": parse_vars(blocks), + "seq": meta["seq"], + } + + +def order_key(request): + """Sort auth-ending requests after regular requests and login first.""" + url = request["url"] + if LOGIN_PATH in url: + return (0, request["relativePath"], request["seq"]) + if any(path in url for path in TOKEN_ENDING_PATHS): + return (2, request["relativePath"], request["seq"]) + return (1, request["relativePath"], request["seq"]) + + +def load_requests(collection_dir, include_external, include_destructive): + """Load and filter HTTP requests from a Bruno collection directory.""" + requests = [] + for path in sorted(collection_dir.rglob("*.bru")): + if path.name in ("collection.bru", "folder.bru"): + continue + if "environments" in path.relative_to(collection_dir).parts: + continue + + request = parse_request(path, collection_dir) + if request is None: + continue + if not include_external and not request["url"].startswith("{{URL}}"): + continue + if not include_destructive and any(path in request["url"] for path in DESTRUCTIVE_PATHS): + continue + requests.append(request) + + requests.sort(key=order_key) + return requests + + +def render_k6(requests): + """Render parsed Bruno requests as a self-contained k6 JavaScript file.""" + regular_requests = [request for request in requests if LOGIN_PATH not in request["url"]] + payload = json.dumps(regular_requests, ensure_ascii=True, indent=2) + + return f"""import http from 'k6/http'; +import {{ sleep, check }} from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'https://api.stage.solid-connection.com'; +const testId = 'bruno-all-apis'; +const requestSleepSeconds = Number(__ENV.BRUNO_REQUEST_SLEEP_SECONDS || '0.1'); +const failOn5xx = (__ENV.BRUNO_FAIL_ON_5XX || 'true') !== 'false'; +const loginEmailTemplate = __ENV.BRUNO_LOGIN_EMAIL_TEMPLATE || 'user{{{{VU}}}}@example.com'; +const loginPassword = __ENV.BRUNO_LOGIN_PASSWORD || 'password'; +const preloadedAccessToken = __ENV.BRUNO_ACCESS_TOKEN || ''; + +const now = new Date(); +const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16); +const time = (() => {{ + const [, mm, dd, hh, min] = kst.split(/[-T:]/); + return `${{mm}}/${{dd}} ${{hh}}:${{min}}`; +}})(); + +export const options = {{ + scenarios: {{ + bruno_all_apis: {{ + executor: 'per-vu-iterations', + vus: Number(__ENV.K6_VUS || 10), + iterations: Number(__ENV.K6_ITERATIONS || 10), + maxDuration: __ENV.K6_MAX_DURATION || '15m', + }}, + }}, + tags: {{ + testid: testId, + time, + }}, +}}; + +const requests = {payload}; + +function envNameFor(variableName) {{ + return `BRUNO_VAR_${{variableName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}}`; +}} + +function fallbackValue(variableName) {{ + const explicit = __ENV[envNameFor(variableName)]; + if (explicit !== undefined) {{ + return explicit; + }} + + const defaults = {{ + URL: BASE_URL, + ACCESS_TOKEN: preloadedAccessToken, + value: '1', + id: '1', + 'home-university-id': '1', + 'term-id': '1', + 'board-code': 'FREE', + }}; + return defaults[variableName] !== undefined ? defaults[variableName] : '1'; +}} + +function renderTemplate(value, variables) {{ + if (value === null || value === undefined) {{ + return value; + }} + return String(value).replace(/\\{{\\{{\\s*([^}}]+?)\\s*\\}}\\}}/g, (_, variableName) => {{ + const key = variableName.trim(); + return variables[key] !== undefined ? variables[key] : fallbackValue(key); + }}); +}} + +function buildVariables(request, accessToken) {{ + return {{ + ...request.vars, + URL: BASE_URL, + ACCESS_TOKEN: accessToken || preloadedAccessToken, + }}; +}} + +function resolveUrl(request, variables) {{ + const rendered = renderTemplate(request.url, variables); + if (rendered.startsWith('http://') || rendered.startsWith('https://')) {{ + return rendered; + }} + return `${{BASE_URL}}${{rendered.startsWith('/') ? '' : '/'}}${{rendered}}`; +}} + +function resolveLoginEmail() {{ + return loginEmailTemplate + .replaceAll('{{{{VU}}}}', String(__VU)) + .replaceAll('${{__VU}}', String(__VU)); +}} + +function readAccessToken(response) {{ + try {{ + return response.json('accessToken') || ''; + }} catch (error) {{ + return ''; + }} +}} + +function login() {{ + if (preloadedAccessToken) {{ + return preloadedAccessToken; + }} + + const response = http.post(`${{BASE_URL}}{LOGIN_PATH}`, JSON.stringify({{ + email: resolveLoginEmail(), + password: loginPassword, + }}), {{ + headers: {{ 'Content-Type': 'application/json; charset=utf-8' }}, + tags: {{ ...options.tags, name: '{LOGIN_PATH}', brunoPath: 'generated-login' }}, + }}); + + check(response, {{ + 'generated login status is 200': (res) => res.status === 200, + 'generated login has access token': (res) => Boolean(readAccessToken(res)), + }}); + + return readAccessToken(response); +}} + +function buildBody(request, variables) {{ + if (request.body.type === 'none') {{ + return null; + }} + if (request.body.type === 'json') {{ + return renderTemplate(request.body.raw || '{{}}', variables); + }} + if (request.body.type === 'multipartForm') {{ + const form = {{}}; + for (const field of request.body.fields || []) {{ + if (field.kind === 'file') {{ + form[field.name] = http.file('bruno-placeholder-file', field.fileName, field.contentType); + }} else {{ + form[field.name] = renderTemplate(field.value || '', variables); + }} + }} + return form; + }} + return null; +}} + +function buildParams(request, variables) {{ + const headers = {{}}; + if (request.auth !== 'none') {{ + const accessToken = variables.ACCESS_TOKEN || preloadedAccessToken; + if (accessToken) {{ + headers.Authorization = `Bearer ${{accessToken}}`; + }} + }} + if (request.body.type === 'json') {{ + headers['Content-Type'] = 'application/json; charset=utf-8'; + }} + return {{ + headers, + tags: {{ + ...options.tags, + name: request.name, + method: request.method, + brunoPath: request.relativePath, + brunoName: request.displayName, + }}, + }}; +}} + +export default function () {{ + const accessToken = login(); + + for (const request of requests) {{ + const variables = buildVariables(request, accessToken); + const url = resolveUrl(request, variables); + const params = buildParams(request, variables); + const body = buildBody(request, variables); + + let response; + try {{ + response = http.request(request.method, url, body, params); + }} catch (error) {{ + console.error(`Bruno request failed before response: ${{request.method}} ${{url}} ${{error.message}}`); + continue; + }} + + check(response, {{ + 'bruno request did not return 5xx': (res) => !failOn5xx || res.status < 500, + }}); + + if (response.status >= 500) {{ + console.error(`Bruno request returned ${{response.status}}: ${{request.method}} ${{url}}`); + }} + + if (requestSleepSeconds > 0) {{ + sleep(requestSleepSeconds); + }} + }} +}} +""" + + +def main(): + """Parse CLI arguments and write the generated k6 script.""" + parser = argparse.ArgumentParser(description="Generate a k6 script from a Bruno collection.") + parser.add_argument("--collection-dir", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--include-external", + action="store_true", + help="Include Bruno requests whose URL is not based on {{URL}}.", + ) + parser.add_argument( + "--include-destructive", + action="store_true", + help="Include destructive requests that can break repeated load-test iterations.", + ) + args = parser.parse_args() + + collection_dir = args.collection_dir.resolve() + if not collection_dir.exists(): + raise SystemExit(f"Bruno collection directory does not exist: {collection_dir}") + + requests = load_requests(collection_dir, args.include_external, args.include_destructive) + if not requests: + raise SystemExit(f"No Bruno HTTP requests found under: {collection_dir}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_k6(requests), encoding="utf-8") + print(f"Generated {args.output} from {len(requests)} Bruno HTTP requests.") + + +if __name__ == "__main__": + main() diff --git a/scripts/load_test/run_k6.sh b/scripts/load_test/run_k6.sh index ba0f099..81c0b0a 100644 --- a/scripts/load_test/run_k6.sh +++ b/scripts/load_test/run_k6.sh @@ -5,6 +5,9 @@ TERRAFORM_DIR="environment/load_test" VAR_FILE="../../config/secrets/load_test.tfvars" LOCAL_K6_DIR="config/load-test/k6" K6_SCRIPT="whole-user-flow.js" +GENERATE_BRUNO_SCRIPT="false" +BRUNO_COLLECTION_DIR="" +BRUNO_GENERATOR="scripts/load_test/generate_bruno_k6.py" TARGET_BASE_URL="" PROMETHEUS_REMOTE_WRITE_URL="" K6_VUS="10" @@ -23,6 +26,9 @@ Options: --var-file PATH Default: ../../config/secrets/load_test.tfvars --local-k6-dir PATH Default: config/load-test/k6 --script FILE Default: whole-user-flow.js + --generate-bruno-script Generate the selected script from a Bruno collection + --bruno-collection-dir PATH Required when --generate-bruno-script is used + --bruno-generator PATH Default: scripts/load_test/generate_bruno_k6.py --target-base-url URL Default: Terraform output load_test_target_base_url --prometheus-remote-write-url URL Default: Terraform output k6_prometheus_remote_write_url --vus VALUE Default: 10 @@ -41,6 +47,9 @@ while [[ $# -gt 0 ]]; do --var-file) VAR_FILE="$2"; shift 2 ;; --local-k6-dir) LOCAL_K6_DIR="$2"; shift 2 ;; --script) K6_SCRIPT="$2"; shift 2 ;; + --generate-bruno-script) GENERATE_BRUNO_SCRIPT="true"; shift ;; + --bruno-collection-dir) BRUNO_COLLECTION_DIR="$2"; shift 2 ;; + --bruno-generator) BRUNO_GENERATOR="$2"; shift 2 ;; --target-base-url) TARGET_BASE_URL="$2"; shift 2 ;; --prometheus-remote-write-url) PROMETHEUS_REMOTE_WRITE_URL="$2"; shift 2 ;; --vus) K6_VUS="$2"; shift 2 ;; @@ -65,6 +74,20 @@ require_command terraform require_command aws require_command jq require_command base64 +require_command find +if [[ "$GENERATE_BRUNO_SCRIPT" == "true" ]]; then + require_command python3 +fi + +if [[ "$GENERATE_BRUNO_SCRIPT" == "true" && -z "$BRUNO_COLLECTION_DIR" ]]; then + echo "--bruno-collection-dir is required when --generate-bruno-script is used" >&2 + exit 1 +fi + +if [[ "$GENERATE_BRUNO_SCRIPT" == "true" && ! -f "$BRUNO_GENERATOR" ]]; then + echo "Bruno k6 generator was not found: $BRUNO_GENERATOR" >&2 + exit 1 +fi tf_output() { terraform -chdir="$TERRAFORM_DIR" output -raw "$1" @@ -216,13 +239,21 @@ PROMETHEUS_REMOTE_WRITE_URL="${PROMETHEUS_REMOTE_WRITE_URL:-$tf_prometheus_remot wait_for_ssm "$load_generator_instance_id" -for relative_path in \ - "createPost.json" \ - "updatePost.json" \ - "whole-user-flow.js" \ - "set_up_xk6.sh"; do +if [[ "$GENERATE_BRUNO_SCRIPT" == "true" ]]; then + python3 "$BRUNO_GENERATOR" \ + --collection-dir "$BRUNO_COLLECTION_DIR" \ + --output "${LOCAL_K6_DIR}/${K6_SCRIPT}" +fi + +if [[ ! -f "${LOCAL_K6_DIR}/${K6_SCRIPT}" ]]; then + echo "Missing k6 script: ${LOCAL_K6_DIR}/${K6_SCRIPT}" >&2 + exit 1 +fi + +while IFS= read -r -d '' source_path; do + relative_path="${source_path#"$LOCAL_K6_DIR"/}" sync_file "$load_generator_instance_id" "$load_generator_k6_dir" "$relative_path" -done +done < <(find "$LOCAL_K6_DIR" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.sh' \) -print0) run_commands_json="$(jq -cn \ --arg k6_dir "$load_generator_k6_dir" \ diff --git a/scripts/load_test/start.sh b/scripts/load_test/start.sh index f7ca2c8..3ecacf8 100644 --- a/scripts/load_test/start.sh +++ b/scripts/load_test/start.sh @@ -7,7 +7,7 @@ DATABASE_NAME="" SWITCH_STAGE_TO_LOADTEST="false" STAGE_APP_DIR="/home/ubuntu/solid-connection-dev" STAGE_COMPOSE_FILE="docker-compose.dev.yml" -SSM_COMMAND_TIMEOUT_SECONDS="${SSM_COMMAND_TIMEOUT_SECONDS:-1800}" +SSM_COMMAND_TIMEOUT_SECONDS="${SSM_COMMAND_TIMEOUT_SECONDS:-3600}" SKIP_TERRAFORM_APPLY="false" usage() { @@ -21,7 +21,7 @@ Options: --switch-stage-to-loadtest Restart stage app through SSM with dev,loadtest profiles --stage-app-dir PATH Default: /home/ubuntu/solid-connection-dev --stage-compose-file VALUE Default: docker-compose.dev.yml - --ssm-command-timeout-seconds Default: 1800 + --ssm-command-timeout-seconds Default: 3600 --skip-terraform-apply -h, --help EOF @@ -115,6 +115,50 @@ send_ssm_command() { done } +wait_for_ssm() { + local instance_id="$1" + local started_at + started_at="$(date +%s)" + + while true; do + local ping_status + ping_status="$(aws ssm describe-instance-information \ + --filters "Key=InstanceIds,Values=${instance_id}" \ + --query "InstanceInformationList[0].PingStatus" \ + --output text 2>/dev/null || true)" + + if [[ "$ping_status" == "Online" ]]; then + break + fi + + if (( $(date +%s) - started_at > SSM_COMMAND_TIMEOUT_SECONDS )); then + echo "SSM agent did not become online after ${SSM_COMMAND_TIMEOUT_SECONDS}s: ${instance_id}" >&2 + exit 1 + fi + + sleep 10 + done +} + +wait_for_load_test_db_restore() { + local instance_id="$1" + local commands_json + + commands_json="$(jq -cn \ + '{ + commands: [ + "set -euo pipefail", + "READY_FILE=/opt/solid-connection/load-test-db-ready", + "cloud-init status --wait --long || { journalctl -u cloud-final --no-pager -n 200 || true; exit 1; }", + "test -f \"$READY_FILE\" || { echo \"Load-test DB ready marker was not created: $READY_FILE\" >&2; journalctl -u cloud-final --no-pager -n 200 || true; exit 1; }", + "cat \"$READY_FILE\"" + ] + }')" + + wait_for_ssm "$instance_id" + send_ssm_command "$instance_id" "Wait for load-test DB restore" "$commands_json" +} + if [[ "$SKIP_TERRAFORM_APPLY" != "true" ]]; then terraform -chdir="$TERRAFORM_DIR" init terraform -chdir="$TERRAFORM_DIR" apply -auto-approve -var-file="$VAR_FILE" @@ -122,12 +166,15 @@ fi stage_instance_id="$(tf_output stage_api_instance_id)" stage_public_ip="$(tf_output stage_api_public_ip)" -loadtest_endpoint="$(tf_output load_test_rds_endpoint)" -loadtest_port="$(tf_output load_test_rds_port)" +loadtest_db_instance_id="$(tf_output load_test_db_instance_id)" +loadtest_endpoint="$(tf_output load_test_db_endpoint)" +loadtest_port="$(tf_output load_test_db_port)" loadtest_db_name="$(tf_output load_test_db_name)" DATABASE_NAME="${DATABASE_NAME:-$loadtest_db_name}" +wait_for_load_test_db_restore "$loadtest_db_instance_id" + if [[ "$SWITCH_STAGE_TO_LOADTEST" == "true" ]]; then stage_commands_json="$(jq -cn \ --arg app_dir "$STAGE_APP_DIR" \ @@ -150,7 +197,7 @@ if [[ "$SWITCH_STAGE_TO_LOADTEST" == "true" ]]; then fi echo "Load test environment is ready." -echo "RDS endpoint: ${loadtest_endpoint}:${loadtest_port}" +echo "DB endpoint: ${loadtest_endpoint}:${loadtest_port}" echo "Load generator instance: created by Load Test Run" echo "Stage instance: ${stage_instance_id}" echo "Stage public IP: ${stage_public_ip}" diff --git a/scripts/load_test/tests/test_generate_bruno_k6.py b/scripts/load_test/tests/test_generate_bruno_k6.py new file mode 100644 index 0000000..9221ac6 --- /dev/null +++ b/scripts/load_test/tests/test_generate_bruno_k6.py @@ -0,0 +1,129 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts.load_test.generate_bruno_k6 import load_requests, render_k6 + + +class GenerateBrunoK6Test(unittest.TestCase): + """Tests for Bruno request parsing and k6 rendering.""" + + def write_bru(self, root, relative_path, content): + """Write a Bruno fixture file under a temporary collection root.""" + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.strip() + "\n", encoding="utf-8") + return path + + def test_load_requests_filters_external_and_destructive_requests(self): + """Only internal non-destructive Bruno requests should be loaded by default.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.write_bru( + root, + "auth/sign-in.bru", + """ +meta { + name: sign in + seq: 1 +} + +post { + url: {{URL}}/auth/email/sign-in + body: json + auth: none +} + +body:json { + {"email":"{{email}}","password":"password"} +} +""", + ) + self.write_bru( + root, + "users/me.bru", + """ +meta { + name: my info + seq: 2 +} + +get { + url: {{URL}}/my + body: none + auth: inherit +} + +vars:pre-request { + email: user1@example.com +} +""", + ) + self.write_bru( + root, + "external/kakao.bru", + """ +get { + url: https://kapi.kakao.com/v2/user/me + body: none + auth: inherit +} +""", + ) + self.write_bru( + root, + "auth/quit.bru", + """ +delete { + url: {{URL}}/auth/quit + body: none + auth: inherit +} +""", + ) + + requests = load_requests(root, include_external=False, include_destructive=False) + + self.assertEqual([request["url"] for request in requests], [ + "{{URL}}/auth/email/sign-in", + "{{URL}}/my", + ]) + self.assertEqual(requests[1]["vars"], {"email": "user1@example.com"}) + + def test_render_k6_excludes_login_request_and_keeps_request_metadata(self): + """The generated script should log in separately and retain request tags.""" + requests = [ + { + "name": "POST /auth/email/sign-in", + "displayName": "sign in", + "relativePath": "auth/sign-in.bru", + "method": "POST", + "url": "{{URL}}/auth/email/sign-in", + "auth": "none", + "body": {"type": "json", "raw": "{}"}, + "vars": {}, + "seq": 1, + }, + { + "name": "GET /my", + "displayName": "my info", + "relativePath": "users/me.bru", + "method": "GET", + "url": "{{URL}}/my", + "auth": "inherit", + "body": {"type": "none"}, + "vars": {}, + "seq": 2, + }, + ] + + script = render_k6(requests) + + self.assertIn("const requests = [", script) + self.assertIn('"relativePath": "users/me.bru"', script) + self.assertNotIn('"relativePath": "auth/sign-in.bru"', script) + self.assertIn("/auth/email/sign-in", script) + + +if __name__ == "__main__": + unittest.main() From 5595ce24aed61b4d648c7e99f2b429741813ea77 Mon Sep 17 00:00:00 2001 From: Yeonri Date: Sat, 29 Aug 2026 17:14:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20DB=20=EB=B3=B5=EC=9B=90=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- environment/load_test/main.tf | 164 ++++++++++++++++-- environment/load_test/output.tf | 2 +- .../templates/load_test_mysql_setup.sh.tftpl | 59 ++++++- environment/load_test/variables.tf | 22 ++- scripts/load_test/README.md | 7 + 5 files changed, 229 insertions(+), 25 deletions(-) diff --git a/environment/load_test/main.tf b/environment/load_test/main.tf index 3b8b370..24d3dcc 100644 --- a/environment/load_test/main.tf +++ b/environment/load_test/main.tf @@ -38,9 +38,21 @@ data "aws_subnet" "stage_api" { id = data.aws_instance.stage_api.subnet_id } +data "aws_caller_identity" "current" {} + locals { - load_test_db_subnet_id = var.load_test_db_subnet_id != null ? var.load_test_db_subnet_id : data.aws_instance.stage_api.subnet_id + load_test_db_subnet_id = var.load_test_db_subnet_id != null ? var.load_test_db_subnet_id : data.aws_instance.prod_db.subnet_id load_test_db_ami_id = var.load_test_db_ami_id != null ? var.load_test_db_ami_id : data.aws_instance.prod_db.ami + load_test_db_instance_profile_name = ( + var.load_test_db_instance_profile_name != null + ? var.load_test_db_instance_profile_name + : aws_iam_instance_profile.load_test_db.name + ) + load_test_db_ssm_endpoint_services = toset([ + "ec2messages", + "ssm", + "ssmmessages", + ]) source_security_group_ids = setunion( data.aws_instance.prod_api.vpc_security_group_ids, @@ -52,6 +64,96 @@ data "aws_subnet" "load_test_db" { id = local.load_test_db_subnet_id } +resource "aws_iam_role" "load_test_db" { + name = "solid-connection-load-test-db" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + Action = "sts:AssumeRole" + }, + ] + }) + + tags = { + Name = "solid-connection-load-test-db" + } +} + +resource "aws_iam_role_policy_attachment" "load_test_db_ssm" { + role = aws_iam_role.load_test_db.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_role_policy" "load_test_db_read" { + name = "LoadTestDbReadPolicy" + role = aws_iam_role.load_test_db.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadLoadTestDatasourceParameters" + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + Resource = "arn:aws:ssm:ap-northeast-2:${data.aws_caller_identity.current.account_id}:parameter${var.load_test_parameter_prefix}/*" + }, + { + Sid = "DecryptLoadTestDatasourceParameters" + Effect = "Allow" + Action = [ + "kms:Decrypt", + ] + Resource = "*" + Condition = { + StringEquals = { + "kms:ViaService" = "ssm.ap-northeast-2.amazonaws.com" + } + } + }, + { + Sid = "ListMysqlBackupDumpObjects" + Effect = "Allow" + Action = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + Resource = "arn:aws:s3:::${var.mysql_backup_bucket_name}" + Condition = { + StringLike = { + "s3:prefix" = "dump/*" + } + } + }, + { + Sid = "ReadMysqlBackupDumpObjects" + Effect = "Allow" + Action = [ + "s3:GetObject", + ] + Resource = "arn:aws:s3:::${var.mysql_backup_bucket_name}/dump/*" + }, + ] + }) +} + +resource "aws_iam_instance_profile" "load_test_db" { + name = "solid-connection-load-test-db" + role = aws_iam_role.load_test_db.name + + tags = { + Name = "solid-connection-load-test-db" + } +} + data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] @@ -80,9 +182,7 @@ resource "aws_security_group" "load_test_db" { } tags = { - Name = "solid-connection-load-test-db-sg" - Project = "solid-connection" - Env = "load_test" + Name = "solid-connection-load-test-db-sg" } } @@ -91,13 +191,52 @@ resource "aws_security_group_rule" "load_test_db_mysql" { type = "ingress" description = "MySQL from prod/stage API server" - from_port = 3306 - to_port = 3306 + from_port = var.load_test_db_port + to_port = var.load_test_db_port protocol = "tcp" security_group_id = aws_security_group.load_test_db.id source_security_group_id = each.value } +resource "aws_security_group" "load_test_db_ssm_endpoint" { + name = "sc-load-test-db-ssm-endpoint-sg" + description = "Security group for load test DB SSM interface endpoints" + vpc_id = data.aws_subnet.load_test_db.vpc_id + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.load_test_db.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "solid-connection-load-test-db-ssm-endpoint-sg" + } +} + +resource "aws_vpc_endpoint" "load_test_db_ssm" { + for_each = local.load_test_db_ssm_endpoint_services + + vpc_id = data.aws_subnet.load_test_db.vpc_id + service_name = "com.amazonaws.ap-northeast-2.${each.key}" + vpc_endpoint_type = "Interface" + subnet_ids = [local.load_test_db_subnet_id] + security_group_ids = [aws_security_group.load_test_db_ssm_endpoint.id] + private_dns_enabled = true + + tags = { + Name = "solid-connection-load-test-db-${each.key}-endpoint" + } +} + resource "aws_ebs_volume" "load_test_db_data" { availability_zone = data.aws_subnet.load_test_db.availability_zone size = var.allocated_storage @@ -105,9 +244,7 @@ resource "aws_ebs_volume" "load_test_db_data" { encrypted = true tags = { - Name = "${var.load_test_db_instance_name}-data" - Project = "solid-connection" - Env = "load_test" + Name = "${var.load_test_db_instance_name}-data" } } @@ -117,7 +254,7 @@ resource "aws_instance" "load_test_db" { subnet_id = local.load_test_db_subnet_id vpc_security_group_ids = [aws_security_group.load_test_db.id] associate_public_ip_address = var.load_test_db_associate_public_ip - iam_instance_profile = var.load_test_db_instance_profile_name + iam_instance_profile = local.load_test_db_instance_profile_name metadata_options { http_endpoint = "enabled" @@ -136,6 +273,7 @@ resource "aws_instance" "load_test_db" { aws_region = "ap-northeast-2" data_volume_id = aws_ebs_volume.load_test_db_data.id db_name = var.db_name + db_port = var.load_test_db_port load_test_parameter_prefix = var.load_test_parameter_prefix mysql_backup_bucket_name = var.mysql_backup_bucket_name mysql_config_content = file("${path.module}/../../modules/app_stack/templates/mysql_tuning.cnf") @@ -144,9 +282,7 @@ resource "aws_instance" "load_test_db" { user_data_replace_on_change = true tags = { - Name = var.load_test_db_instance_name - Project = "solid-connection" - Env = "load_test" + Name = var.load_test_db_instance_name } } @@ -218,6 +354,6 @@ resource "aws_instance" "load_generator" { resource "aws_ssm_parameter" "load_test_datasource_url" { name = "${var.load_test_parameter_prefix}/spring.datasource.url" type = "String" - value = "jdbc:mysql://${aws_instance.load_test_db.private_ip}:3306/${var.db_name}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8" + value = "jdbc:mysql://${aws_instance.load_test_db.private_ip}:${var.load_test_db_port}/${var.db_name}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8" overwrite = true } diff --git a/environment/load_test/output.tf b/environment/load_test/output.tf index 0c43fbe..2f352e5 100644 --- a/environment/load_test/output.tf +++ b/environment/load_test/output.tf @@ -5,7 +5,7 @@ output "load_test_db_endpoint" { output "load_test_db_port" { description = "Load-test MySQL EC2 port" - value = 3306 + value = var.load_test_db_port } output "load_test_db_instance_id" { diff --git a/environment/load_test/templates/load_test_mysql_setup.sh.tftpl b/environment/load_test/templates/load_test_mysql_setup.sh.tftpl index 4457531..f1f98cc 100644 --- a/environment/load_test/templates/load_test_mysql_setup.sh.tftpl +++ b/environment/load_test/templates/load_test_mysql_setup.sh.tftpl @@ -4,6 +4,7 @@ set -Eeuo pipefail AWS_REGION="${aws_region}" DATA_VOLUME_ID="${data_volume_id}" DB_NAME="${db_name}" +DB_PORT="${db_port}" LOAD_TEST_PARAMETER_PREFIX="${load_test_parameter_prefix}" MYSQL_BACKUP_BUCKET="${mysql_backup_bucket_name}" @@ -38,6 +39,11 @@ if ! [[ "$DB_NAME" =~ ^[A-Za-z0-9_]+$ ]]; then exit 1 fi +if ! [[ "$DB_PORT" =~ ^[0-9]+$ ]] || [ "$DB_PORT" -lt 1 ] || [ "$DB_PORT" -gt 65535 ]; then + echo "Invalid DB_PORT: $DB_PORT" >&2 + exit 1 +fi + systemctl enable docker systemctl start docker @@ -49,6 +55,8 @@ elif systemctl list-unit-files | grep -q '^amazon-ssm-agent.service'; then systemctl restart amazon-ssm-agent.service || true fi +systemctl disable --now mysql-backup-dump.timer mysql-backup-binlog.timer 2>/dev/null || true + for _ in $(seq 1 120); do if [ -e "$DATA_VOLUME_DEVICE" ]; then break @@ -119,7 +127,7 @@ docker rm -f mysql-server 2>/dev/null || true docker run -d \ --name mysql-server \ --restart always \ - -p 3306:3306 \ + -p "$DB_PORT":3306 \ -v "$MYSQL_DATA_DIR:/var/lib/mysql" \ -v /etc/mysql/conf.d:/etc/mysql/conf.d \ -e MYSQL_ROOT_PASSWORD="$DB_APP_PASSWORD" \ @@ -160,8 +168,11 @@ if [ ! -f "$READY_FILE" ]; then --bucket "$MYSQL_BACKUP_BUCKET" \ --prefix dump/ \ --region "$AWS_REGION" \ - --query 'reverse(sort_by(Contents[?ends_with(Key, `manifest.json`)], &LastModified))[0].Key' \ - --output text)" + --query 'Contents[?ends_with(Key, `manifest.json`)].[LastModified,Key]' \ + --output text \ + | sort -k1,1 \ + | tail -n 1 \ + | awk '{print $2}')" if [ -z "$latest_manifest_key" ] || [ "$latest_manifest_key" = "None" ]; then echo "No MySQL dump manifest was found in s3://$MYSQL_BACKUP_BUCKET/dump/." >&2 @@ -175,8 +186,46 @@ if [ ! -f "$READY_FILE" ]; then --only-show-errors \ --no-progress - dump_file="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["dumpFile"])' "$manifest_file")" - dump_sha256="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["sha256"])' "$manifest_file")" + mapfile -t manifest_values < <(python3 - "$manifest_file" "$DB_NAME" <<'PY' +import json +import re +import sys + +manifest_path = sys.argv[1] +expected_database = sys.argv[2] + +try: + with open(manifest_path, encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) +except (OSError, json.JSONDecodeError) as exc: + print(f"Failed to read MySQL dump manifest: {exc}", file=sys.stderr) + sys.exit(1) + +checks = [ + (manifest.get("schemaVersion") == 1, "schemaVersion must be 1"), + (manifest.get("type") == "mysql-full-dump", "type must be mysql-full-dump"), + (manifest.get("database") == expected_database, f"database must be {expected_database}"), +] + +dump_file = manifest.get("dumpFile") +if not isinstance(dump_file, str) or "/" in dump_file or dump_file in {"", ".", ".."}: + checks.append((False, "dumpFile must be a file name")) + +sha256 = manifest.get("sha256") +if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", sha256): + checks.append((False, "sha256 must be a 64-character hex string")) + +errors = [message for valid, message in checks if not valid] +if errors: + print("Invalid MySQL dump manifest: " + "; ".join(errors), file=sys.stderr) + sys.exit(1) + +print(dump_file) +print(sha256.lower()) +PY + ) + dump_file="$${manifest_values[0]}" + dump_sha256="$${manifest_values[1]}" dump_prefix="$${latest_manifest_key%/manifest.json}" dump_path="$restore_dir/$dump_file" diff --git a/environment/load_test/variables.tf b/environment/load_test/variables.tf index c396515..8e50205 100644 --- a/environment/load_test/variables.tf +++ b/environment/load_test/variables.tf @@ -43,6 +43,17 @@ variable "db_name" { default = "solid_connection" } +variable "load_test_db_port" { + description = "Load-test MySQL EC2 listener port." + type = number + default = 3306 + + validation { + condition = var.load_test_db_port >= 1 && var.load_test_db_port <= 65535 + error_message = "load_test_db_port must be between 1 and 65535." + } +} + variable "load_test_db_username_parameter_name" { description = "Deprecated. datasource username은 load-test Parameter Store 경로에서 직접 읽습니다." type = string @@ -93,18 +104,18 @@ variable "load_test_db_instance_name" { variable "load_test_db_instance_type" { description = "load-test MySQL EC2 인스턴스 타입입니다." type = string - default = "t3.medium" + default = "t4g.medium" } variable "load_test_db_ami_id" { description = "load-test MySQL EC2 AMI ID입니다. null이면 prod MySQL EC2의 AMI를 사용합니다." type = string - default = null + default = "ami-0501a03cd31b53e82" nullable = true } variable "load_test_db_subnet_id" { - description = "load-test MySQL EC2를 배치할 subnet ID입니다. null이면 stage API EC2와 같은 subnet을 사용합니다." + description = "load-test MySQL EC2를 배치할 subnet ID입니다. null이면 prod DB EC2와 같은 subnet을 사용합니다." type = string default = null nullable = true @@ -117,9 +128,10 @@ variable "load_test_db_associate_public_ip" { } variable "load_test_db_instance_profile_name" { - description = "load-test MySQL EC2에 연결할 IAM instance profile 이름입니다. SSM Parameter Store 조회와 S3 백업 조회 권한이 필요합니다." + description = "Deprecated override. null이면 Terraform이 생성한 load-test DB 전용 IAM instance profile을 사용합니다." type = string - default = "SolidConnectionParameterStoreReadProfile" + default = null + nullable = true } variable "mysql_backup_bucket_name" { diff --git a/scripts/load_test/README.md b/scripts/load_test/README.md index 366da0d..5cafe5e 100644 --- a/scripts/load_test/README.md +++ b/scripts/load_test/README.md @@ -166,3 +166,10 @@ Stop workflow 동작: - prod/stage EC2는 `Name` tag로 조회합니다. - prod/load-test DB 계정 정보는 Parameter Store에서 읽습니다. - load-test MySQL EC2 보안 그룹은 prod/stage API EC2 보안 그룹에서 들어오는 MySQL 접근만 허용합니다. +- Load-test DB defaults updated after PR review: + - `load_test_db_ami_id`: `ami-0501a03cd31b53e82` (`solid-connection-db-mysql-8.4.8-arm64-ubuntu24.04-awscli-recovery-tools`). + - `load_test_db_instance_type`: `t4g.medium`, matching the arm64 DB AMI family. + - `load_test_db_subnet_id`: when omitted, the prod DB subnet is used so the existing S3 Gateway Endpoint route is available. + - `load_test_db_associate_public_ip`: remains `false`; SSM access is provided through Terraform-managed SSM interface endpoints. + - `load_test_db_instance_profile_name`: when omitted, Terraform uses the dedicated load-test DB instance profile it creates. + - `load_test_db_port`: defaults to `3306` and is used by the security group, Docker port mapping, Terraform output, and datasource URL.