diff --git a/.github/actions/migrate-src-ssh/action.yml b/.github/actions/migrate-src-ssh/action.yml new file mode 100644 index 00000000..08acea64 --- /dev/null +++ b/.github/actions/migrate-src-ssh/action.yml @@ -0,0 +1,75 @@ +name: 구 계정 박스 SSH 준비 +description: >- + 계정 이관 중 구 계정 박스에 SSM Session Manager 경유 SSH 를 준비한다(infra 의 ssm-ssh). + job 이 OIDC 로 잡은 자격은 신 계정이라, 구 계정 키를 프로필로 써 두고 그 프로필로 붙는다. + 신 계정 박스와 Name 태그가 같아 별칭은 src- 다. + +inputs: + instance-name: + description: 구 계정 박스의 Name 태그 + required: true + expected-ip: + description: 이 박스의 공인 IP 가 이 값이어야 한다(config 가 도메인 DNS 로 해소한 구 앱 박스). 비우면 검사하지 않는다. + required: false + default: "" + user: + description: 박스 로그인 사용자 + required: true + ssh-key: + description: 박스 authorized_keys 에 등록된 개인 키 + required: true + aws-access-key-id: + description: 구 계정 키 (MIGRATE_SOURCE_AWS_ACCESS_KEY_ID) + required: true + aws-secret-access-key: + description: 구 계정 시크릿 (MIGRATE_SOURCE_AWS_SECRET_ACCESS_KEY) + required: true + +outputs: + host: + description: ssh·scp 에 쓸 호스트 별칭 + value: ${{ steps.ssh.outputs.host }} + +runs: + using: composite + steps: + - name: 구 계정 프로필 (piki-migrate-src) + shell: bash + env: + SRC_KEY: ${{ inputs.aws-access-key-id }} + SRC_SECRET: ${{ inputs.aws-secret-access-key }} + run: | + set -euo pipefail + [ -n "${SRC_KEY:-}" ] && [ -n "${SRC_SECRET:-}" ] || { + echo "::error::구 계정 자격(MIGRATE_SOURCE_AWS_*) 미설정"; exit 1; } + grep -qx '\[piki-migrate-src\]' ~/.aws/credentials 2>/dev/null && exit 0 + umask 077 + mkdir -p ~/.aws + # printf 는 셸 내장이라 키가 프로세스 목록에 뜨지 않는다(aws configure set 은 argv 로 받는다). + printf '[piki-migrate-src]\naws_access_key_id = %s\naws_secret_access_key = %s\n' "$SRC_KEY" "$SRC_SECRET" >> ~/.aws/credentials + printf '[profile piki-migrate-src]\nregion = %s\n' "${AWS_REGION:?}" >> ~/.aws/config + + - name: SSM 경유 SSH + id: ssh + uses: TeamPiKi/infra/.github/actions/ssm-ssh@main + with: + instance-name: ${{ inputs.instance-name }} + alias: src-${{ inputs.instance-name }} + aws-profile: piki-migrate-src + user: ${{ inputs.user }} + ssh-key: ${{ inputs.ssh-key }} + + # 구 계정 키가 엉뚱한 계정을 가리켜도 Name 태그는 맞을 수 있다. 서비스 중인 박스는 도메인이 정본이다. + - name: 도메인이 가리키는 박스인지 확인 + if: ${{ inputs.expected-ip != '' }} + shell: bash + env: + INSTANCE_ID: ${{ steps.ssh.outputs.instance-id }} + EXPECTED: ${{ inputs.expected-ip }} + run: | + set -euo pipefail + ACTUAL=$(aws --profile piki-migrate-src ec2 describe-instances --instance-ids "$INSTANCE_ID" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) + [ "$ACTUAL" = "$EXPECTED" ] || { + echo "::error::구 계정 박스($INSTANCE_ID → $ACTUAL)가 도메인이 가리키는 $EXPECTED 와 다르다 — 중단"; exit 1; } + echo "::notice::구 계정 박스 확인 — $INSTANCE_ID ($ACTUAL)" diff --git a/.github/actions/ssh-gate/action.yml b/.github/actions/ssh-gate/action.yml deleted file mode 100644 index 35e82ad9..00000000 --- a/.github/actions/ssh-gate/action.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: SSH gate -description: 러너의 공인 IP 만 대상 인스턴스의 22번에 잠시 열고, 일이 끝나면 되돌린다. - -# 왜 필요한가 — 신 계정에서는 러너가 어떤 박스에도 SSH 를 붙일 수 없다. -# -# terraform/security_groups.tf 의 앱 SG 에는 22 번 규칙이 없다. 평소 배포는 SSM 경유(#1196)이고, -# 이 액션은 계정 이관 워크플로가 SSM 으로 바뀔 때까지(#1210) 그 워크플로만 쓴다. -# -# 즉 갓 만든 계정의 SG 에는 22 번이 없다. 그대로 두면 provision 의 scp·ssh 가 첫 이관에서 -# 확정적으로 막히고, rehearsal·cutover 의 앱 박스 접근도 함께 막힌다. 콘솔에서 손으로 열라고 -# 하면 "딸깍" 이 아니게 되므로, 필요한 순간에 필요한 IP 만 열고 끝나면 닫는다. -# -# 0.0.0.0/0 으로 상시 개방하지 않는 이유: 그 규칙은 한 번 넣으면 ignore_changes 때문에 아무도 -# 지우지 않아 계정 수명 내내 남는다. 러너 IP/32 · 작업 구간 한정이 같은 일을 훨씬 좁게 한다. - -inputs: - action: - description: "open | close" - required: true - instance-name: - description: "대상 인스턴스의 Name 태그 (예: piki-prod-app · piki-prod-db)" - required: true - port: - description: "열 포트" - required: false - default: "22" - -runs: - using: composite - steps: - - shell: bash - env: - ACTION: ${{ inputs.action }} - INSTANCE_NAME: ${{ inputs.instance-name }} - PORT: ${{ inputs.port }} - run: | - set -uo pipefail - # close 는 실패 처리 중에도 불린다(if: always()). 여기서 다시 죽으면 원래 실패 원인이 가려지므로 - # 정리 경로는 어떤 경우에도 0 으로 끝낸다. open 은 실패하면 뒤 단계가 어차피 막히니 그대로 전파한다. - - STATE_FILE="/tmp/piki-ssh-gate-${INSTANCE_NAME}.sg" - - sg_ids() { - aws ec2 describe-instances \ - --filters "Name=tag:Name,Values=$INSTANCE_NAME" "Name=instance-state-name,Values=running" \ - --query 'Reservations[].Instances[].SecurityGroups[].GroupId' --output text - } - - if [ "$ACTION" = open ]; then - set -e - MY_IP=$(curl -sf --max-time 10 https://checkip.amazonaws.com | tr -d '[:space:]') - echo "$MY_IP" | grep -Eq '^[0-9]+(\.[0-9]+){3}$' || { echo "::error::러너 공인 IP 조회 실패"; exit 1; } - CIDR="$MY_IP/32" - IDS=$(sg_ids) - [ -n "$IDS" ] || { echo "::error::인스턴스를 찾지 못했다 — tag:Name=$INSTANCE_NAME (running 상태 확인)"; exit 1; } - : > "$STATE_FILE" - for sg in $IDS; do - # 종료코드만 보고 전부 "이미 열림"으로 넘기면 권한 부족·규칙 한도 초과·잘못된 SG 같은 실제 실패가 - # 가려진 채 게이트가 안 열리고, 뒤 스텝(scp·스모크 SSH)이 타임아웃으로 죽는다(컷오버 freeze 뒤라 특히 나쁘다). - # stderr 를 잡아 InvalidPermission.Duplicate(=이미 있음, 정상)만 건너뛰고 나머지는 즉시 실패시킨다. - # Duplicate 는 남의 규칙일 수 있어 상태 파일에 적지 않는다(close 에서 안 지운다). - if ERR=$(aws ec2 authorize-security-group-ingress --group-id "$sg" \ - --ip-permissions "IpProtocol=tcp,FromPort=$PORT,ToPort=$PORT,IpRanges=[{CidrIp=$CIDR,Description=piki-migrate-runner}]" \ - 2>&1 >/dev/null); then - echo "$sg $CIDR" >> "$STATE_FILE" - echo "::notice::SSH 게이트 개방 — $sg $PORT/tcp ← $CIDR ($INSTANCE_NAME)" - elif printf '%s' "$ERR" | grep -q 'InvalidPermission.Duplicate'; then - echo "::notice::SSH 게이트 이미 열려 있음 — $sg $CIDR (건드리지 않는다)" - else - echo "::error::SSH 게이트 개방 실패 — $sg: $ERR" - exit 1 - fi - done - exit 0 - fi - - if [ "$ACTION" = close ]; then - [ -s "$STATE_FILE" ] || { echo "::notice::SSH 게이트 — 되돌릴 규칙 없음 ($INSTANCE_NAME)"; exit 0; } - FAILED=0 - REMAIN=$(mktemp) - while read -r sg cidr; do - [ -n "${sg:-}" ] || continue - # 일시적 API 오류(스로틀링 등)로 규칙이 남는 것을 막기 위해 몇 번 다시 시도한다. - # 남은 규칙은 "이 러너 IP 로 22번이 계속 열려 있다" 는 뜻이라 조용히 넘길 수 없다 — - # GitHub 러너 IP 는 공유 풀이라 다음에 그 주소를 받는 쪽이 우리 박스에 닿는다. - ok=0 - for attempt in 1 2 3; do - if ERR=$(aws ec2 revoke-security-group-ingress --group-id "$sg" \ - --ip-permissions "IpProtocol=tcp,FromPort=$PORT,ToPort=$PORT,IpRanges=[{CidrIp=$cidr}]" \ - 2>&1 >/dev/null); then - ok=1; break - fi - # 규칙이 이미 없으면 회수된 것과 같다(누가 먼저 지웠거나 앞 시도가 실제로는 성공). - if printf '%s' "$ERR" | grep -q 'InvalidPermission.NotFound'; then ok=1; break; fi - [ "$attempt" -lt 3 ] && sleep 3 - done - if [ "$ok" = 1 ]; then - echo "::notice::SSH 게이트 회수 — $sg $PORT/tcp ← $cidr" - else - echo "::error::SSH 게이트 회수 실패 — $sg $cidr ($ERR). 이 규칙이 남아 있으면 러너 IP 로 22번이 열린 채다 — 콘솔에서 직접 지울 것" - printf '%s %s\n' "$sg" "$cidr" >> "$REMAIN" - FAILED=1 - fi - done < "$STATE_FILE" - if [ "$FAILED" = 0 ]; then - rm -f "$STATE_FILE" "$REMAIN" - exit 0 - fi - # 회수하지 못한 것만 상태 파일에 남긴다 — 이 job 을 재실행하면 그것만 다시 시도한다. - mv "$REMAIN" "$STATE_FILE" - exit 1 - fi - - echo "::error::action 은 open|close 여야 한다 (받은 값: $ACTION)" - exit 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 619b2257..a8c769ad 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ on: # 아직 구 계정을 가리킨다. 아래 가드는 정확히 그 불일치를 잡으라고 있는 것이라, 이관에서는 # 우회 경로를 따로 열어 준다 — 가드를 느슨하게 고치는 대신 "이관이라고 명시할 때만" 비킨다. migration_host: - description: "[이관 전용] 신 계정 앱 박스 공인 IP. 채우면 이관 모드다 — 도메인 가드를 건너뛰고 구 호스트 TLS cert 를 복사한다. 평소에는 비워 둘 것." + description: "[이관 전용] 신 계정 앱 박스 공인 IP. 채우면 이관 모드다 — 도메인 가드를 건너뛴다. 평소에는 비워 둘 것." required: false type: string migration_role_arn: @@ -41,7 +41,7 @@ on: required: false type: string migration_host: - description: "[이관 전용] 신 계정 앱 박스 공인 IP. 채우면 이관 모드(도메인 가드 우회·구 호스트 TLS cert 복사)." + description: "[이관 전용] 신 계정 앱 박스 공인 IP. 채우면 이관 모드(도메인 가드 우회)." required: false type: string migration_role_arn: @@ -319,43 +319,21 @@ jobs: # prod 는 cert 가 이미 있어 통째로 skip(=nginx 안 멈춤, 다운타임 0). # certonly --standalone 은 nginx 와 무관하게 :80 을 잠깐 점유해 발급하므로, SSL 을 참조하는 # nginx conf 적용(다음 스텝)보다 먼저여야 nginx -t 가 통과한다. - # 이관 세미 디플로이(migration_host)의 cert 최초 설정. HTTP-01 은 DNS 가 그 호스트를 가리켜야 - # 발급되는데, 컷오버 전엔 DNS 가 아직 구 호스트라 신 호스트에서 fresh 발급이 원리적으로 실패한다 - # (검증 요청이 구 호스트로 감). cert 는 도메인에 묶인 거라, 구 호스트의 /etc/letsencrypt 를 그대로 - # 신 호스트로 옮기면 유효한 HTTPS 로 바로 뜨고, 컷오버 후 갱신은 DNS 가 신 호스트라 정상 동작한다. - # 아래 'Ensure TLS cert' 는 cert 가 이미 있으면 HTTP-01 을 건너뛰므로, 이 복사가 그 fresh 발급을 대신한다. - - name: (이관) 구 호스트 TLS cert 복사 - if: ${{ inputs.migration_host != '' }} - env: - OLD_HOST: ${{ secrets.EC2_HOST }} - HOST: ${{ steps.box.outputs.host }} - SSH_USER: ${{ secrets.EC2_USER }} - SSH_KEY: ${{ secrets.EC2_SSH_KEY }} - run: | - set -euo pipefail - umask 077 - echo "$SSH_KEY" > /tmp/mig_key.pem && chmod 600 /tmp/mig_key.pem - # 구 호스트는 다른 계정 박스라 이 job 의 역할로는 SSM 을 쓸 수 없다 — 공인 IP 로 직접 SSH 한다 - # (구 계정 SG 의 22 번이 열려 있어야 한다). 신 박스는 위 SSM 별칭으로 간다. - SSHOPT=(-i /tmp/mig_key.pem -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15) - # 구 호스트에서 /etc/letsencrypt 를 tar 로 말아 러너로 받아 신 호스트로 푼다(러너 릴레이). - # /etc/letsencrypt 는 root 소유라 sudo. live 심볼릭·archive·renewal 설정·options-ssl·dhparams 가 통째로 옮겨져 - # nginx conf 가 참조하는 파일이 다 따라온다(다음 스텝의 개별 복사도 no-op 가 된다). - ssh "${SSHOPT[@]}" "$SSH_USER@$OLD_HOST" 'sudo tar -C /etc -czf - letsencrypt' > /tmp/le.tgz - [ -s /tmp/le.tgz ] || { echo "::error::구 호스트에서 /etc/letsencrypt 를 가져오지 못했다 (cert 존재·sudo 권한 확인)"; exit 1; } - ssh "$HOST" 'sudo tar -C /etc -xzf -' < /tmp/le.tgz - rm -f /tmp/mig_key.pem /tmp/le.tgz - echo "::notice::TLS cert 복사 완료 — $OLD_HOST → $HOST (HTTP-01 건너뜀, 컷오버 즉시 HTTPS 유효)" - + # 이관 세미 디플로이(migration_host)에서는 migrate.yml provision 이 구 박스 cert 를 미리 옮겨 둬 skip 된다. + # 그때 cert 가 없으면 발급하지 않고 멈춘다. DNS 가 아직 구 박스라 HTTP-01 이 원리적으로 실패한다. - name: Ensure TLS cert (certbot if missing) id: cert timeout-minutes: 15 env: HOST: ${{ steps.box.outputs.host }} DOMAIN: ${{ needs.resolve.outputs.domain }} + MIGRATION_HOST: ${{ inputs.migration_host }} SCRIPT: | if [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then echo "[certbot] $DOMAIN cert 이미 존재 — skip" + elif [ -n "$MIGRATION_HOST" ]; then + echo "::error::[이관 모드] 신 박스에 $DOMAIN cert 가 없다 — migrate.yml 의 provision 을 먼저 실행할 것" + exit 1 else echo "[certbot] $DOMAIN cert 발급 (standalone)" # --pre-hook/--post-hook 는 발급 시점뿐 아니라 certbot 갱신 타이머가 쓰는 renewal 설정에도 @@ -377,7 +355,7 @@ jobs: [ -n "$SRC" ] && sudo cp "$SRC" /etc/letsencrypt/ssl-dhparams.pem && echo "[certbot] ssl-dhparams.pem 복사" fi run: | - EXPORTS=$(for v in DOMAIN; do printf 'export %s=%q; ' "$v" "${!v}"; done) + EXPORTS=$(for v in DOMAIN MIGRATION_HOST; do printf 'export %s=%q; ' "$v" "${!v}"; done) ssh -n "$HOST" "$EXPORTS$SCRIPT" - name: Apply nginx config diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 82dd2e7a..47a3a975 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -14,17 +14,20 @@ name: Migrate # 신 계정: OIDC 로 역할(BOOTSTRAP_ROLE)을 맡는다. GitHub 에 신 계정 장기 키를 두지 않는다 — 사람이 CloudShell 에 # infra/scripts/bootstrap-oidc.sh 를 한 번 붙여넣으면 발판이 생기고, 나중에 지울 키가 남지 않는다. # 구 계정: secrets.MIGRATE_SOURCE_AWS_ACCESS_KEY_ID/SECRET. 순수 read-only 는 아니다 — 릴레이·이미지 버킷 읽기에 -# 더해, prod DB 제어(SSM 경로)에서 ssm:SendCommand·ListCommandInvocations·GetCommandInvocation 이 필요하다. +# 더해, prod DB 제어(SSM 경로)에서 ssm:SendCommand·ListCommandInvocations·GetCommandInvocation 이, +# 구 앱 박스 SSH(SSM 경유)에서 ec2:DescribeInstances·ssm:StartSession(AWS-StartSSHSession)· +# ssm:TerminateSession·ssmmessages:OpenDataChannel(session/*) 이 필요하다. 마지막이 없으면 세션이 +# 열리자마자 끊긴다(terraform/iam_people.tf 실측). 이관 전에 이 키로 start-session 을 한 번 확인할 것. # (박스 안 GetParameter·kms:Decrypt·릴레이 업로드는 DB 인스턴스 role 이 하고, 이 키가 하지 않는다.) 이미 있는 계정이라 키가 정상 경로다. -# provision: 신 계정 역할(tfstate output 조회·SSM)로. 앱 박스 SSH 는 secrets.EC2_SSH_KEY. # 기타 secret: DISCORD_WEBHOOK_URL(진행보고)·CROSS_REPO_TOKEN(extractor/renderer checkout). # # SSH 키는 하나다: bootstrap 이 EC2_SSH_KEY 의 공개키를 신 계정에 import 하므로, 이관 중에도 구·신 # 계정 박스에 같은 키로 붙는다. 새 키를 발급하면 프라이빗 키 보관에 PAT 가 필요하고, 구·신 키가 갈려 # EC2_SSH_KEY 를 언제 갈아끼울지가 또 하나의 상태 전환이 된다(그 사이 deploy.yml 이 깨진다). # -# 러너 SSH 접근: 신 계정 SG 는 22번이 닫힌 채 생성된다(평소 배포는 SSM 경유, #1196). 필요한 구간에만 -# 러너 IP/32 를 여는 일은 ./.github/actions/ssh-gate 가 맡는다. SSM 전환은 #1210. +# 박스 SSH 는 전부 SSM 경유다(#1210). 두 계정 모두 22번 인바운드가 없다. +# 신 계정 박스: infra 의 ssm-ssh 를 job 의 OIDC 자격으로. 별칭은 Name 태그(piki-dev-app 등) +# 구 계정 박스: ./.github/actions/migrate-src-ssh 가 구 계정 키를 프로필로 써 두고 붙는다. 별칭은 src- on: workflow_dispatch: @@ -125,7 +128,6 @@ jobs: db_container: ${{ steps.map.outputs.db_container }} db_exec: ${{ steps.map.outputs.db_exec }} app_ip_output: ${{ steps.map.outputs.app_ip_output }} - db_ip_output: ${{ steps.map.outputs.db_ip_output }} db_tag: ${{ steps.map.outputs.db_tag }} root_pw_param: ${{ steps.map.outputs.root_pw_param }} app_tag: ${{ steps.map.outputs.app_tag }} @@ -154,20 +156,19 @@ jobs: # 안 되기 때문이다(migrate-db.sh 헤더 참고). 차이는 전달 수단뿐이다. # dev = ssh — 앱 박스에 DB 가 동거하고 그 박스엔 키페어가 있다 # prod = ssm — 별도 DB 박스는 key_name 없이 뜬다(db_ec2.tf 실측). SSH 키가 아예 없다 - # 인스턴스 Name 태그는 terraform 이 문자열로 박아 둔 값이다(ec2.tf·db_ec2.tf 실측) — SG 게이트가 - # 이걸로 박스를 찾는다. SG 이름(${project}-${environment}-ec2-sg)은 prod 키페어가 - # team3-SE-1 인 것처럼 env 접두사 규칙이 일정하지 않아 기준으로 삼지 않는다. + # 인스턴스 Name 태그는 terraform 이 문자열로 박아 둔 값이다(ec2.tf·db_ec2.tf 실측) — SSM 경유 SSH 와 + # Run Command 가 이걸로 박스를 찾는다. # 도메인은 계정이 바뀌어도 그대로다 — 이관에서 바뀌는 것은 A레코드가 가리키는 IP 하나뿐이다. # 그래서 변수가 아니라 여기 박는다. deploy.yml(63-70)·infra/nginx/*.conf 가 이미 같은 매핑을 # 문자열로 갖고 있어, 셋이 같은 사실을 말한다. 도메인을 옮기는 날엔 세 곳을 함께 고친다. if [ "$ENV" = "prod" ]; then - DB_CONTAINER=piki-prod-mysql; DB_EXEC=ssm; APP_IP_OUTPUT=ec2_public_ip; DB_IP_OUTPUT=db_ec2_private_ip; DB_TAG=piki-prod-db; APP_TAG=piki-prod-app; DOMAIN=api.piki.day + DB_CONTAINER=piki-prod-mysql; DB_EXEC=ssm; APP_IP_OUTPUT=ec2_public_ip; DB_TAG=piki-prod-db; APP_TAG=piki-prod-app; DOMAIN=api.piki.day # prod 는 provision-db.sh 가 별도 DB 박스를 띄우며 root 비번을 db-root-password 로 따로 둔다. ROOT_PW_PARAM=db-root-password # prod 이미지 버킷은 접두사 없는 var.image_bucket_name 그대로다(terraform/s3.tf). IMAGE_PREFIX="" else - DB_CONTAINER=piki-mysql; DB_EXEC=ssh; APP_IP_OUTPUT=dev_ec2_public_ip; DB_IP_OUTPUT=dev_ec2_public_ip; DB_TAG=piki-dev-app; APP_TAG=piki-dev-app; DOMAIN=dev.api.piki.day + DB_CONTAINER=piki-mysql; DB_EXEC=ssh; APP_IP_OUTPUT=dev_ec2_public_ip; DB_TAG=piki-dev-app; APP_TAG=piki-dev-app; DOMAIN=dev.api.piki.day # dev 는 provision-runtime.sh 가 MYSQL_ROOT_PASSWORD="${DB_PASSWORD}" 로 띄워 db-password 가 # 곧 root 비번이고, db-root-password 라는 파라미터는 존재하지 않는다(실측). ROOT_PW_PARAM=db-password @@ -184,7 +185,6 @@ jobs: echo "db_container=$DB_CONTAINER" echo "db_exec=$DB_EXEC" echo "app_ip_output=$APP_IP_OUTPUT" - echo "db_ip_output=$DB_IP_OUTPUT" # dev 는 앱 박스와 같은 값(동거) echo "db_tag=$DB_TAG" # ssm 모드의 대상 인스턴스 Name 태그 echo "root_pw_param=$ROOT_PW_PARAM" # root 비번이 담긴 SSM 파라미터 이름(env 별로 다르다) # image_bucket 은 terraform 변수(var.image_bucket_name)로 들어가는 값이라 접두사가 붙으면 @@ -874,54 +874,47 @@ jobs: role-to-assume: arn:aws:iam::${{ inputs.target_account_id }}:role/${{ env.BOOTSTRAP_ROLE }} role-session-name: piki-migrate - - uses: hashicorp/setup-terraform@v3 + - name: 신 계정 앱 박스 SSH 준비 (SSM 경유) + id: box + uses: TeamPiKi/infra/.github/actions/ssm-ssh@main + with: + instance-name: ${{ needs.config.outputs.app_tag }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} # bootstrap 이 이 키의 공개키를 신 계정에 등록해 뒀다 - - name: 앱 박스 IP 조회 (terraform output) - id: tf - working-directory: terraform + # 갓 뜬 박스는 SSM Agent 등록 전일 수 있다. 등록 전에 붙으면 TargetNotConnected 로 죽는다. + - name: 신 계정 앱 박스 SSM 등록 대기 env: - TFSTATE_BUCKET: ${{ needs.config.outputs.tfstate_bucket }} - APP_IP_OUTPUT: ${{ needs.config.outputs.app_ip_output }} + INSTANCE_ID: ${{ steps.box.outputs.instance-id }} run: | set -euo pipefail - printf 'bucket = "%s"\n' "$TFSTATE_BUCKET" > backend.hcl - terraform init -backend-config=backend.hcl -input=false >/dev/null - IP=$(terraform output -raw "$APP_IP_OUTPUT") - echo "app_ip=$IP" >> "$GITHUB_OUTPUT" - echo "::notice::앱 박스 IP=$IP" - - # 신 계정 SG 는 22번이 닫힌 채 생성된다(action 주석 참고). 이 job 동안만 러너 IP 를 연다. - - name: SSH 게이트 개방 (신 계정 앱 박스) - uses: ./.github/actions/ssh-gate - with: - action: open - instance-name: ${{ needs.config.outputs.app_tag }} + for _ in $(seq 1 60); do + ST=$(aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$INSTANCE_ID" \ + --query 'InstanceInformationList[0].PingStatus' --output text 2>/dev/null || true) + [ "$ST" = Online ] && { echo "::notice::SSM 등록 확인 — $INSTANCE_ID"; exit 0; } + sleep 10 + done + echo "::error::10분 안에 SSM 에 등록되지 않았다 — $INSTANCE_ID (인스턴스 프로필의 AmazonSSMManagedInstanceCore 확인)" + exit 1 - name: 앱 박스 provision (SSH, 멱등) env: - APP_IP: ${{ steps.tf.outputs.app_ip }} - SSH_KEY: ${{ secrets.EC2_SSH_KEY }} # bootstrap 이 이 키의 공개키를 신 계정에 등록해 뒀다 + HOST: ${{ steps.box.outputs.host }} ENV: ${{ inputs.env }} run: | set -euo pipefail - [ -n "${SSH_KEY:-}" ] || { - echo "::error::EC2_SSH_KEY 미설정"; exit 1; } - echo "$SSH_KEY" > key.pem && chmod 600 key.pem - # 배열로 둔다 — 문자열 변수로 두고 unquoted 확장하면 word-split 에 기대게 되어 취약(SC2086). - SSHOPT=(-i key.pem -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null) - scp "${SSHOPT[@]}" infra/scripts/provision-runtime.sh "ubuntu@${APP_IP}:/tmp/" + scp infra/scripts/provision-runtime.sh "$HOST:/tmp/" # ENVIRONMENT 를 원격 셸에 넘긴다. 안 넘기면 (1) dev 분기를 못 타 MySQL 을 안 깔고 # ("[mysql] prod 환경 — skip" 이 찍힌다) (2) 끝의 alloy 호출이 ${ENVIRONMENT:?} 로 죽는다(실측). # --skip-alloy: 관측 블록(config.alloy·provision-alloy-ssm.sh)의 SSOT 는 TeamPiKi/infra 이고 # 이관 경로는 그 블록을 올리지 않는다. 배포(deploy.yml)가 매번 올려 갱신하므로 여기선 건너뛴다. # shellcheck disable=SC2029 # $ENV 의 클라이언트 측 확장은 의도 — 원격엔 그 값이 리터럴로 실려야 한다 - ssh "${SSHOPT[@]}" "ubuntu@${APP_IP}" "ENVIRONMENT='$ENV' bash /tmp/provision-runtime.sh --skip-alloy" + ssh "$HOST" "ENVIRONMENT='$ENV' bash /tmp/provision-runtime.sh --skip-alloy" # dev 는 여기서 끝이다. DB 가 앱 박스에 동거하고, 그 MySQL 은 방금 돈 provision-runtime.sh 가 # ENVIRONMENT=dev 분기에서 설치·기동한다(로그의 "[mysql] ready"). provision-db.sh 를 또 부르면 # 그 스크립트는 별도 prod DB 박스 전용이라 SSM_PREFIX 가 /piki-core/prod 로 박혀 있어 # dev 에서는 ParameterNotFound 로 죽는다(실측). 백업 cron(db-backup.sh)은 prod 관심사다. - rm -f key.pem - echo "::notice::앱 박스 provision 완료 — $APP_IP" + echo "::notice::앱 박스 provision 완료 — $HOST" # DB 박스는 배포할 앱이 없어 deploy.yml 의 Alloy 갱신 경로를 타지 않는다. 여기서 안 올리면 이관 뒤 # 관측 없이 돌다가 백업 지표 NoData 알림으로만 드러난다(#1169 실측). 블록의 SSOT 는 TeamPiKi/infra 라 @@ -996,12 +989,53 @@ jobs: echo "::error::prod DB provision 실패 — Status=$STATUS (박스 instance-role 의 tfstate provision/* s3:GetObject 권한·스크립트 로그 확인)"; exit 1; } echo "::notice::prod DB provision 완료 — $DB_TAG (MySQL·백업 cron·Alloy)" - - name: SSH 게이트 회수 (신 계정 앱 박스) - if: always() - uses: ./.github/actions/ssh-gate + # 이미 있으면 구 계정을 부르지 않는다. 컷오버 뒤 provision 재실행은 도메인이 신 박스라 아래 IP 가드에 걸린다. + - name: 신 앱 박스 TLS cert 유무 + id: cert + env: + DST: ${{ steps.box.outputs.host }} + DOMAIN: ${{ needs.config.outputs.domain }} + run: | + set -euo pipefail + rc=0 + # shellcheck disable=SC2029 # $DOMAIN 의 클라이언트 측 확장은 의도 + ssh "$DST" "sudo test -d '/etc/letsencrypt/live/$DOMAIN'" || rc=$? + case $rc in + 0) echo "present=true" >> "$GITHUB_OUTPUT" + echo "::notice::신 박스에 $DOMAIN cert 가 이미 있다 — 구 박스 cert 복사 생략" ;; + 1) echo "present=false" >> "$GITHUB_OUTPUT" ;; + *) echo "::error::신 박스 SSH 실패 — cert 유무를 판단할 수 없다"; exit 1 ;; + esac + + - name: 구 계정 앱 박스 SSH 준비 (SSM 경유) + id: src + if: ${{ steps.cert.outputs.present == 'false' }} + uses: ./.github/actions/migrate-src-ssh with: - action: close instance-name: ${{ needs.config.outputs.app_tag }} + expected-ip: ${{ needs.config.outputs.src_app_host }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} + aws-access-key-id: ${{ secrets.MIGRATE_SOURCE_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.MIGRATE_SOURCE_AWS_SECRET_ACCESS_KEY }} + + # HTTP-01 은 DNS 가 그 박스를 가리켜야 발급되는데, 컷오버 전 DNS 는 구 박스라 신 박스 발급이 원리적으로 실패한다. + # cert 는 도메인에 묶여 있어 구 박스 것을 옮기면 바로 유효하고, deploy.yml 의 'Ensure TLS cert' 는 cert 가 있으면 건너뛴다. + # deploy.yml 에 두지 않는 이유: prod 이관의 deploy.yml 은 main 커밋을 체크아웃해 dev 에만 있는 이 레포 액션을 못 볼 수 있다. + - name: 구 앱 박스 TLS cert 복사 + if: ${{ steps.cert.outputs.present == 'false' }} + env: + SRC: ${{ steps.src.outputs.host }} + DST: ${{ steps.box.outputs.host }} + run: | + set -euo pipefail + umask 077 + # live 심볼릭·archive·renewal 설정·options-ssl·dhparams 가 통째로 따라와 nginx conf 가 참조하는 파일이 다 옮겨진다. + ssh "$SRC" 'sudo tar -C /etc -czf - letsencrypt' > le.tgz + [ -s le.tgz ] || { echo "::error::구 박스에서 /etc/letsencrypt 를 가져오지 못했다 (cert 존재·sudo 권한 확인)"; exit 1; } + ssh "$DST" 'sudo tar -C /etc -xzf -' < le.tgz + rm -f le.tgz + echo "::notice::TLS cert 복사 완료 — $SRC → $DST" - name: Discord — provision 결과 if: always() @@ -1136,34 +1170,35 @@ jobs: role-to-assume: arn:aws:iam::${{ inputs.target_account_id }}:role/${{ env.BOOTSTRAP_ROLE }} role-session-name: piki-migrate - - uses: hashicorp/setup-terraform@v3 + # dev 만 신 박스에 SSH 한다. dev 는 DB 가 앱 박스에 동거하고, prod 는 DB 박스를 SSM Run Command 로 조종한다. + - name: 신 계정 앱 박스 SSH 준비 (SSM 경유, dev DB 동거) + id: dst + if: ${{ needs.config.outputs.db_exec == 'ssh' }} + uses: TeamPiKi/infra/.github/actions/ssm-ssh@main + with: + instance-name: ${{ needs.config.outputs.app_tag }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} - - name: 신 계정 박스 주소 (terraform output) - id: tf - working-directory: terraform + - name: 구 계정 앱 박스 SSH 준비 (SSM 경유) + id: src + uses: ./.github/actions/migrate-src-ssh + with: + instance-name: ${{ needs.config.outputs.app_tag }} + expected-ip: ${{ needs.config.outputs.src_app_host }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} + aws-access-key-id: ${{ secrets.MIGRATE_SOURCE_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.MIGRATE_SOURCE_AWS_SECRET_ACCESS_KEY }} + + # 컷오버의 freeze 가 같은 경로로 구 앱을 멈춘다. 그 경로가 막혀 있으면 다운타임 밖인 여기서 드러나야 한다. + - name: 구 앱 박스 SSH 사전 점검 (컷오버 freeze 경로) env: - TFSTATE_BUCKET: ${{ needs.config.outputs.tfstate_bucket }} - APP_IP_OUTPUT: ${{ needs.config.outputs.app_ip_output }} - DB_IP_OUTPUT: ${{ needs.config.outputs.db_ip_output }} + SRC: ${{ steps.src.outputs.host }} run: | set -euo pipefail - printf 'bucket = "%s"\n' "$TFSTATE_BUCKET" > backend.hcl - terraform init -backend-config=backend.hcl -input=false >/dev/null - # 앱 주소와 DB 주소를 따로 받는다. prod 에서 둘은 다른 박스이고, DB 작업에 앱 EIP 를 쓰면 - # 그 박스엔 MySQL 컨테이너가 없어 복원이 통째로 실패한다. - { - echo "dst_app=$(terraform output -raw "$APP_IP_OUTPUT")" - echo "dst_db=$(terraform output -raw "$DB_IP_OUTPUT")" - } >> "$GITHUB_OUTPUT" - - # dev 는 DB 가 앱 박스에 동거해 러너가 그 박스로 직접 SSH 한다 — 그 구간만 22번을 연다. - # prod 는 DB 박스를 SSM 으로 조종하므로 SSH 게이트가 필요 없다(열지도 않는다). - - name: SSH 게이트 개방 (신 계정 앱 박스) - if: ${{ needs.config.outputs.db_exec == 'ssh' }} - uses: ./.github/actions/ssh-gate - with: - action: open - instance-name: ${{ needs.config.outputs.app_tag }} + N=$(ssh "$SRC" 'docker ps --format "{{.Names}}" | grep -cE "^piki-core-(blue|green)$" || true') + echo "::notice::구 앱 박스 SSH 확인 — $SRC 의 앱 컨테이너 ${N}개" - name: Discord — 리허설 시작 (라이브 무영향) uses: ./.github/actions/discord-notify @@ -1181,8 +1216,8 @@ jobs: DB_TAG: ${{ needs.config.outputs.db_tag }} SSM_PREFIX: ${{ needs.config.outputs.ssm_prefix }} ROOT_PW_PARAM: ${{ needs.config.outputs.root_pw_param }} - SRC_APP_HOST: ${{ needs.config.outputs.src_app_host }} # config 가 DNS 로 해소해 굳힌 값 - DST_DB_HOST: ${{ steps.tf.outputs.dst_db }} + SRC_APP_HOST: ${{ steps.src.outputs.host }} + DST_DB_HOST: ${{ steps.dst.outputs.host }} # dev 만. prod(ssm 모드)는 보지 않는다 SRC_RELAY_BUCKET: ${{ needs.config.outputs.src_backup_bucket }} DST_RELAY_BUCKET: ${{ needs.config.outputs.db_backup_bucket }} OLD_IMAGE_BUCKET: ${{ needs.config.outputs.old_image_bucket }} @@ -1218,13 +1253,6 @@ jobs: fi rm -f key.pem - - name: SSH 게이트 회수 (신 계정 앱 박스) - if: ${{ always() && needs.config.outputs.db_exec == 'ssh' }} - uses: ./.github/actions/ssh-gate - with: - action: close - instance-name: ${{ needs.config.outputs.app_tag }} - - name: Discord — 리허설 결과 if: always() uses: ./.github/actions/discord-notify @@ -1496,22 +1524,20 @@ jobs: env: TFSTATE_BUCKET: ${{ needs.config.outputs.tfstate_bucket }} APP_IP_OUTPUT: ${{ needs.config.outputs.app_ip_output }} - DB_IP_OUTPUT: ${{ needs.config.outputs.db_ip_output }} run: | set -euo pipefail printf 'bucket = "%s"\n' "$TFSTATE_BUCKET" > backend.hcl terraform init -backend-config=backend.hcl -input=false >/dev/null - { - echo "dst_app=$(terraform output -raw "$APP_IP_OUTPUT")" - echo "dst_db=$(terraform output -raw "$DB_IP_OUTPUT")" - } >> "$GITHUB_OUTPUT" + echo "dst_app=$(terraform output -raw "$APP_IP_OUTPUT")" >> "$GITHUB_OUTPUT" - # 스모크 체크가 신 계정 앱 박스로 SSH 하므로 dev·prod 모두 앱 박스 게이트가 필요하다. - - name: SSH 게이트 개방 (신 계정 앱 박스) - uses: ./.github/actions/ssh-gate + # 스모크가 dev·prod 모두 신 앱 박스에 SSH 한다. dev 는 DB 덤프·복원도 이 박스다. + - name: 신 계정 앱 박스 SSH 준비 (SSM 경유) + id: dst + uses: TeamPiKi/infra/.github/actions/ssm-ssh@main with: - action: open instance-name: ${{ needs.config.outputs.app_tag }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} # 도메인이 아직 구 계정을 가리키는지 확인한다. 이미 신 계정을 가리키면 이 실행은 컷오버 후 # 재실행이라는 뜻이고, 그대로 진행하면 신 DB 를 덤프해 자기 위에 덮어쓴다. @@ -1527,6 +1553,18 @@ jobs: exit 1; } echo "::notice::사전 점검 통과 — 구 계정=$SRC_APP_HOST, 신 계정=$NEW_IP" + # freeze·롤백이 이 별칭으로 구 앱을 멈추고 되살린다. 위 DNS 점검 뒤에 둬야 재실행 오인이 그쪽 메시지로 걸린다. + - name: 구 계정 앱 박스 SSH 준비 (SSM 경유) + id: src + uses: ./.github/actions/migrate-src-ssh + with: + instance-name: ${{ needs.config.outputs.app_tag }} + expected-ip: ${{ needs.config.outputs.src_app_host }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} + aws-access-key-id: ${{ secrets.MIGRATE_SOURCE_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.MIGRATE_SOURCE_AWS_SECRET_ACCESS_KEY }} + # 이미지 벌크 sync 를 freeze *앞*에 둔다. 러너의 imgs 는 매 실행 빈 디렉터리라 s3 sync 가 버킷 # 전량을 내려받는데(이름만 델타다), 이게 freeze 뒤에 있으면 다운타임이 곧 버킷 전체 전송 시간이 # 된다. 여기서 대부분을 미리 받아 두고, freeze 뒤 스텝은 같은 디렉터리를 재사용해 그 사이 늘어난 @@ -1553,8 +1591,8 @@ jobs: DB_TAG: ${{ needs.config.outputs.db_tag }} SSM_PREFIX: ${{ needs.config.outputs.ssm_prefix }} ROOT_PW_PARAM: ${{ needs.config.outputs.root_pw_param }} - SRC_APP_HOST: ${{ needs.config.outputs.src_app_host }} - DST_DB_HOST: ${{ steps.tf.outputs.dst_db }} + SRC_APP_HOST: ${{ steps.src.outputs.host }} + DST_DB_HOST: ${{ steps.dst.outputs.host }} # dev 만 본다. prod(ssm 모드)는 DB 박스를 Name 태그로 조종 SRC_RELAY_BUCKET: ${{ needs.config.outputs.src_backup_bucket }} DST_RELAY_BUCKET: ${{ needs.config.outputs.db_backup_bucket }} OLD_IMAGE_BUCKET: ${{ needs.config.outputs.old_image_bucket }} @@ -1591,8 +1629,8 @@ jobs: NEW_IMAGE_BUCKET: ${{ needs.config.outputs.sync_image_bucket }} SRC_AWS_KEY: ${{ secrets.MIGRATE_SOURCE_AWS_ACCESS_KEY_ID }} SRC_AWS_SECRET: ${{ secrets.MIGRATE_SOURCE_AWS_SECRET_ACCESS_KEY }} - DST_APP_HOST: ${{ steps.tf.outputs.dst_app }} - SRC_APP_HOST: ${{ needs.config.outputs.src_app_host }} + DST_APP_HOST: ${{ steps.dst.outputs.host }} + SRC_APP_HOST: ${{ steps.src.outputs.host }} SRC_APP_KEY: key.pem run: | set -euo pipefail @@ -1674,17 +1712,10 @@ jobs: - name: freeze 롤백 (최종 안전망 — 실패·취소 공통) if: always() env: - SRC_APP_HOST: ${{ needs.config.outputs.src_app_host }} + SRC_APP_HOST: ${{ steps.src.outputs.host }} SRC_APP_KEY: key.pem run: bash infra/scripts/migrate-unfreeze.sh - - name: SSH 게이트 회수 (신 계정 앱 박스) - if: always() - uses: ./.github/actions/ssh-gate - with: - action: close - instance-name: ${{ needs.config.outputs.app_tag }} - - name: Discord — cutover 결과 if: always() uses: ./.github/actions/discord-notify @@ -1880,25 +1911,23 @@ jobs: echo "::notice::서비스 검증 — 통과 $pass · 실패 $fail (https://$DOMAIN)" [ "$fail" -eq 0 ] - # 프로브가 깨졌을 때만 박스를 연다. 정상 경로에서 SSH 를 열지 않아 노출 시간을 줄인다. - - name: SSH 게이트 개방 (진단용) + - name: 신 계정 앱 박스 SSH 준비 (진단용, SSM 경유) + id: box if: failure() - uses: ./.github/actions/ssh-gate + uses: TeamPiKi/infra/.github/actions/ssm-ssh@main with: - action: open instance-name: ${{ needs.config.outputs.app_tag }} + user: ubuntu + ssh-key: ${{ secrets.EC2_SSH_KEY }} - name: 박스 진단 수집 id: diag if: failure() env: - APP_HOST: ${{ needs.deploy_core.outputs.app_ip }} - SSH_KEY: ${{ secrets.EC2_SSH_KEY }} + APP_HOST: ${{ steps.box.outputs.host }} run: | set -uo pipefail - printf '%s\n' "$SSH_KEY" > key.pem && chmod 600 key.pem - OUT=$(ssh -i key.pem -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ - -o ConnectTimeout=15 "ubuntu@$APP_HOST" ' + OUT=$(ssh "$APP_HOST" ' echo "── 컨테이너 ──"; docker ps -a --format "{{.Names}}\t{{.Status}}\t{{.Ports}}" | head -10 echo "── nginx ──"; systemctl is-active nginx; sudo nginx -t 2>&1 | tail -2 echo "── 업스트림 설정 ──"; cat /etc/nginx/piki-upstream.conf 2>/dev/null || echo "(없음)" @@ -1909,7 +1938,6 @@ jobs: echo "── 앱 로그 끝 ──" docker logs --tail 25 $(docker ps --filter name=piki-core --format "{{.Names}}" | head -1) 2>&1 | tail -25 ' 2>&1) || true - rm -f key.pem printf '%s\n' "$OUT" { echo "diag<> "$GITHUB_OUTPUT" - - name: SSH 게이트 회수 (진단용) - if: always() - uses: ./.github/actions/ssh-gate - with: - action: close - instance-name: ${{ needs.config.outputs.app_tag }} - - name: Discord — 서비스 검증 결과 if: always() uses: ./.github/actions/discord-notify @@ -1987,7 +2008,7 @@ jobs: echo "역할 ARN 의 정확한 이름은 신 계정 terraform output 또는 IAM 콘솔에서 확인한다" echo "(\`\${project}-\${environment}\` 접두사를 쓴다)." } >> "$GITHUB_STEP_SUMMARY" - echo "::warning::남은 수동 정리 6건 — EC2_HOST · AWS_ECR_PUSH_ROLE_ARN · AWS_DB_PROVISION_ROLE_ARN · OLD_ACCOUNT_ID · ${{ env.BOOTSTRAP_ROLE }} 역할 삭제 · 앱 SG 22번 인바운드(배포용). 자세한 내용은 실행 Summary." + echo "::warning::남은 수동 정리 5건 — EC2_HOST · AWS_ECR_PUSH_ROLE_ARN · AWS_DB_PROVISION_ROLE_ARN · OLD_ACCOUNT_ID · ${{ env.BOOTSTRAP_ROLE }} 역할 삭제. 자세한 내용은 실행 Summary." - name: 요약 조립 id: sum diff --git a/infra/scripts/migrate-db.sh b/infra/scripts/migrate-db.sh index 4ff1c7ad..ddd1d346 100755 --- a/infra/scripts/migrate-db.sh +++ b/infra/scripts/migrate-db.sh @@ -66,7 +66,8 @@ SRC_AWS_SECRET="${SRC_AWS_SECRET:?구 계정 시크릿}" # 경계를 공유하려고 파일로 남긴다 — migrate-unfreeze.sh 가 이 파일을 읽어 되살린다. FREEZE_STATE_FILE="${FREEZE_STATE_FILE:-/tmp/piki-migrate-frozen}" -SSHOPT=(-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15) +# ConnectTimeout 은 주지 않는다. 호스트는 SSM 별칭이라 ssm-ssh 가 쓴 30초가 맞고, 명령줄 값이 그걸 덮는다. +SSHOPT=(-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null) AWSCLI_IMAGE="public.ecr.aws/aws-cli/aws-cli:2.35.21" # db-backup.sh 와 같은 핀. 박스에 aws CLI 가 없어도 된다. RELAY_KEY="migrate/${DB_NAME}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz" WORK=/tmp/piki-migrate diff --git a/infra/scripts/migrate-unfreeze.sh b/infra/scripts/migrate-unfreeze.sh index 5161bc05..604bc9a7 100644 --- a/infra/scripts/migrate-unfreeze.sh +++ b/infra/scripts/migrate-unfreeze.sh @@ -14,26 +14,31 @@ set -uo pipefail FREEZE_STATE_FILE="${FREEZE_STATE_FILE:-/tmp/piki-migrate-frozen}" -SRC_APP_HOST="${SRC_APP_HOST:?구 계정 앱 박스 호스트}" -SRC_APP_KEY="${SRC_APP_KEY:?구 계정 앱 박스 SSH 키파일}" log() { echo "[migrate-unfreeze] $(date -u +%H:%M:%S) $*"; } # 파일이 없거나 비었으면 되살릴 게 없다 — 이미 무장해제됐거나 애초에 freeze 가 없었다. # 이 경로는 실패 처리 중에 불리므로 여기서 다시 죽지 않게 항상 0 으로 끝난다. +# 호스트 검사보다 먼저 본다. 구 박스 SSH 준비가 실패해 호스트가 빈 실행은 freeze 도 없었다. [ -s "$FREEZE_STATE_FILE" ] || { log "복구 대상 없음 — 생략"; exit 0; } +SRC_APP_HOST="${SRC_APP_HOST:?구 계정 앱 박스 호스트}" +SRC_APP_KEY="${SRC_APP_KEY:?구 계정 앱 박스 SSH 키파일}" + log "구 앱 컨테이너 재기동: $(tr '\n' ' ' < "$FREEZE_STATE_FILE")" -if ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 \ - -i "$SRC_APP_KEY" "ubuntu@$SRC_APP_HOST" 'xargs -r docker start' < "$FREEZE_STATE_FILE"; then - log "롤백 완료 — 구 계정 서비스 복구" - echo "::notice::freeze 롤백 완료 — 구 계정 앱을 되살렸다. DNS 는 아직 구 계정을 가리킨다." - exit 0 -else - log "롤백 실패" - echo "::error::freeze 롤백 실패 — 구 계정 박스($SRC_APP_HOST)에서 직접 docker start 해야 한다: $(tr '\n' ' ' < "$FREEZE_STATE_FILE")" - # 0 으로 끝내지 않는다. 이 경로는 "구 서비스가 내려간 채 남았다" 는 뜻인데, 0 을 돌려주면 호출한 - # 스텝이 초록불이 되고 컷오버 실패 안내가 "롤백이 돌아 구 서비스는 복구됐다" 고 단정해 버린다. - # 상태 파일은 남겨 둔다 — 재실행·재시도가 같은 컨테이너 목록을 다시 집어야 하기 때문이다. - exit 1 -fi +# SSM 채널은 스로틀·agent 재연결로 한 번씩 늦게 열린다. docker start 는 멱등이라 다시 시도해도 안전하다. +for attempt in 1 2 3; do + if ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ + -i "$SRC_APP_KEY" "ubuntu@$SRC_APP_HOST" 'xargs -r docker start' < "$FREEZE_STATE_FILE"; then + log "롤백 완료 — 구 계정 서비스 복구" + echo "::notice::freeze 롤백 완료 — 구 계정 앱을 되살렸다. DNS 는 아직 구 계정을 가리킨다." + exit 0 + fi + [ "$attempt" -lt 3 ] && sleep 10 +done +log "롤백 실패" +echo "::error::freeze 롤백 실패 — 구 계정 박스($SRC_APP_HOST)에서 직접 docker start 해야 한다: $(tr '\n' ' ' < "$FREEZE_STATE_FILE")" +# 0 으로 끝내지 않는다. 이 경로는 "구 서비스가 내려간 채 남았다" 는 뜻인데, 0 을 돌려주면 호출한 +# 스텝이 초록불이 되고 컷오버 실패 안내가 "롤백이 돌아 구 서비스는 복구됐다" 고 단정해 버린다. +# 상태 파일은 남겨 둔다 — 재실행·재시도가 같은 컨테이너 목록을 다시 집어야 하기 때문이다. +exit 1