IaC IAC 04 누가 콘솔에서 손대면 — 드리프트와 테라폼이 못 보는 것
IaC · 4/5

IAC 04 누가 콘솔에서 손대면 — 드리프트와 테라폼이 못 보는 것

gabury1고친 사람 github-actions[bot]

금요일 저녁에 장애가 났다고 해 봅시다. 당직자가 급한 마음에 AWS 콘솔을 열고 서버의 보안 그룹(EC2(Elastic Compute Cloud, AWS 의 가상 서버) 앞에 붙는 방화벽 규칙 묶음)에 규칙 하나를 추가합니다. 「8080 포트, 전 세계(0.0.0.0/0)에서 들어와도 됨」. 디버깅용 관리 화면을 밖에서 잠깐 보려는 것이었습니다. 장애는 풀렸습니다. 규칙은 아무도 기억하지 못합니다.

이 서비스의 인프라는 테라폼(인프라를 코드로 적어 두면 실제를 그 코드대로 맞춰 주는 도구) 코드로 관리되고 있습니다. 코드에는 8080 이 없습니다. 월요일에 누군가 terraform plan(코드와 실제를 비교해 바꿀 것을 미리 보여 주는 명령)을 돌리면 이 규칙이 드러날까요.

같은 규칙을 코드에 두 가지 방식으로 적어 놓고 똑같이 8080 을 열어 봤습니다. 한쪽 plan 은 8080 을 지우겠다고 나섰습니다. 다른 쪽 plan 은 이렇게 답했습니다.

No changes. Your infrastructure matches the configuration.

계획을 실제로 실행하는 apply 를 해도 8080 은 그대로 열려 있었습니다. 아래에서 이 두 결과가 왜 갈리는지를 따라갑니다. 결론을 먼저 적어 두면, 「테라폼이 관리한다」는 「코드에 없는 것은 없앤다」가 아닙니다.

1. 먼저 알아 둘 말

테라폼 낱말 다섯

앞 편들(IAC 01~03)에서 푼 말을 여기 필요한 만큼만 되살립니다.

낱말 한 줄
테라폼 인프라를 코드 파일로 적어 두면 실제 클라우드를 그 코드대로 맞춰 주는 도구
리소스(resource) 코드에 적는 관리 대상 하나. resource "종류" "이름" { … } 블록 하나가 EC2 한 대, 보안 그룹 하나에 대응합니다
상태 파일 테라폼이 「내가 만든 것은 이것들이고 마지막으로 본 값은 이렇다」를 적어 두는 JSON 파일
plan 코드와 실제를 비교해 「무엇을 바꾸겠다」는 계획만 보여 주는 명령. 아무것도 바꾸지 않습니다
apply 그 계획을 실제로 실행하는 명령

코드는 HCL(HashiCorp Configuration Language)이라는 테라폼 전용 문법으로 씁니다. 이름 { … } 꼴의 덩어리를 블록이라 부릅니다. 블록 안에 이름 = 값 으로 적는 값을 인자(argument)라 부릅니다.

인자와 짝을 이루는 말이 속성(attribute)입니다. 속성은 테라폼이 AWS 에서 읽어 와 상태 파일에 적어 두는 값입니다. 코드에 인자로 적은 값은 속성으로도 읽어 옵니다. 그런데 코드에 적지 않은 값도 속성으로는 읽어 옵니다. 「코드에 적은 인자」와 「상태에 읽어 온 속성」이 늘 같은 목록은 아니라는 점이 뒤에서 중요해집니다.

구성 드리프트

코드로 인프라를 관리한다는 것은 「코드가 정답이다」라는 약속입니다. 그런데 AWS 는 그 약속을 모릅니다. 콘솔이든 aws CLI 든 권한만 있으면 누구나 설정을 바꿀 수 있습니다.

그렇게 코드 밖에서 바뀌어 코드·상태 파일에 적힌 모습과 실제 인프라가 어긋난 상태를 구성 드리프트(configuration drift)라 부릅니다. 배가 닻을 내리지 않으면 물결에 조금씩 떠밀려 가는 것과 같습니다. 한 번에 크게 움직이지 않아서 알아채기 어렵습니다.

보안 그룹 규칙을 적는 두 방식

여기서 다룰 것은 보안 그룹입니다. AWS 테라폼 프로바이더(테라폼이 AWS API 를 부를 수 있게 해 주는 플러그인)는 인바운드 규칙(밖에서 들어오는 연결을 허락하는 규칙)을 두 방식으로 적게 해 줍니다.

인라인 — 규칙을 보안 그룹 블록 안에 ingress { … } 블록으로 적습니다.

hcl
resource "aws_security_group" "app" {
  name        = "demo-sg-inline"
  description = "demo app sg (inline rules)"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

별도 리소스 — 보안 그룹은 껍데기만 만들고, 규칙은 aws_vpc_security_group_ingress_rule 이라는 다른 리소스로 하나씩 붙입니다.

hcl
resource "aws_security_group" "app" {
  name        = "demo-sg-separate"
  description = "demo app sg (separate rules)"
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.app.id
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}
  • from_port·to_port — 허락할 포트 범위. 둘 다 22 면 22번 포트 하나입니다
  • 0.0.0.0/0 — CIDR(Classless Inter-Domain Routing, IP 주소 범위를 주소/비트 수 로 적는 방식) 표기로 「모든 IPv4 주소」. 세상 누구나 들어올 수 있다는 뜻입니다
  • aws_security_group.app.id — 위에서 만든 보안 그룹의 ID 를 가리키는 참조. 그룹이 먼저 만들어지고 그 ID 가 여기에 들어갑니다

두 코드가 만드는 결과는 같습니다. 22번(SSH, Secure Shell 원격 접속)만 열린 보안 그룹 하나입니다. 이제 이 둘에 똑같은 드리프트를 일으키고 plan 의 반응을 비교합니다.

진짜 AWS 대신 AWS API 를 흉내 내는 moto 서버(5.2.3.dev0)에 붙였습니다. 명령은 전부 가짜 자격증명(AWS_ACCESS_KEY_ID=test 등)과 moto 주소를 가리키는 환경변수를 먼저 읽고 돌렸습니다. 흉내라서 진짜 AWS 와 다를 수 있는 대목은 결과 옆에 적었습니다.

2. 누군가 손을 댄다

두 방식은 폴더를 나눠 따로 적었습니다(inline/·separate/). 두 폴더는 상태 파일도 따로입니다. 인라인 쪽 폴더에는 보안 그룹 말고 EC2 인스턴스 한 대(demo-app, 유형 t4g.small, 태그 Role = "app")도 같이 뒀습니다. 인스턴스가 나오는 실험은 모두 인라인 쪽 이야기입니다. 각각 apply 해서 자원을 만든 뒤 시작했습니다.

「콘솔에서 손대기」는 aws CLI 로 흉내 냈습니다. 콘솔도 같은 AWS API 를 부르므로 테라폼이 나중에 읽어 가는 값은 같다고 봤습니다. 콘솔이 덧붙이는 설명·태그 같은 값은 재지 않았습니다. 세 가지를 바꿨습니다.

터미널
# 1) 두 보안 그룹 모두에 8080 을 전 세계로 연다
aws ec2 authorize-security-group-ingress --group-id "$SG_INLINE" \
  --protocol tcp --port 8080 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id "$SG_SEP" \
  --protocol tcp --port 8080 --cidr 0.0.0.0/0
# 2) 인스턴스 태그 Role 을 hotfix 로
aws ec2 create-tags --resources "$IID" --tags Key=Role,Value=hotfix
# 3) 인스턴스 유형을 t4g.medium 으로 (멈추고 → 바꾸고 → 켠다)
aws ec2 stop-instances --instance-ids "$IID"
aws ec2 wait instance-stopped --instance-ids "$IID"
aws ec2 modify-instance-attribute --instance-id "$IID" \
  --instance-type Value=t4g.medium
aws ec2 start-instances --instance-ids "$IID"
  • authorize-security-group-ingress — 보안 그룹에 인바운드 규칙을 추가하는 API. 콘솔에서 규칙을 추가할 때와 같은 일을 합니다
  • $SG_INLINE·$SG_SEP·$IID — apply 로 만들어진 두 보안 그룹과 인스턴스의 ID 를 담은 셸 변수입니다

바꾼 뒤 실제 상태를 AWS 에 물어보면 이렇습니다. 첫 두 줄은 보안 그룹별로 열린 포트, 셋째 줄은 인스턴스의 유형·상태·Role 태그입니다.

demo-sg-inline	22,8080
demo-sg-separate	22,8080
t4g.medium	running	hotfix

두 보안 그룹 모두 실제로는 22 와 8080 이 열려 있습니다. 코드에는 두 쪽 다 22 만 있습니다.

3. plan 은 무엇을 읽고 무엇과 비교하나

결과를 보기 전에 plan 이 하는 일을 짚어 둡니다. 공식 문서(terraform plan 명령 설명)는 plan 이 기본으로 세 단계를 밟는다고 적습니다.

  1. 이미 있는 원격 객체의 현재 상태를 읽어 상태 파일을 최신으로 맞춥니다 ("Reads the current state of any already-existing remote objects to make sure that the Terraform state is up-to-date")
  2. 지금 코드를 그 상태와 비교해 다른 곳을 찾습니다
  3. 적용하면 원격 객체가 코드와 같아질 변경을 제안합니다

첫 단계를 새로 고침(refresh)이라 부릅니다. 테라폼은 상태 파일에 적힌 리소스마다 AWS API 를 불러 「지금 실제로 어떤가」를 읽어 옵니다. 드리프트가 드러날 수 있는 것은 이 단계 덕분입니다.

flowchart TD
    S["상태 파일<br/>(내가 만든 것 목록)"] --> R["① 새로 고침<br/>목록의 리소스마다 AWS 에 현재 값을 묻는다"]
    R --> C["② 코드와 비교<br/>리소스마다 코드의 인자 ↔ 읽어 온 속성"]
    C --> P["③ 코드 쪽으로 맞추는 변경을 제안"]

이 그림에서 볼 것은 하나입니다. 새로 고침은 상태 파일에 적힌 리소스만 읽습니다. 비교도 리소스 단위입니다. 그래서 「어떤 리소스가 무엇을 책임지고 있나」가 plan 이 볼 수 있는 범위를 정합니다. 이 한 문장이 뒤의 결과를 전부 설명합니다.

4. 한쪽은 잡고, 한쪽은 「No changes」

두 폴더에서 plan 을 돌렸습니다(-no-color 는 색 코드를 빼는 옵션).

실측 — 드리프트 뒤 일반 plan (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
for d in inline separate; do
  (cd $d && terraform plan -no-color)
done

출력의 기호는 이렇게 읽습니다. ~ 는 지우지 않고 고침(update in-place), - 는 없앰, + 는 새로 넣음입니다. -> 왼쪽이 지금 실제 값, 오른쪽이 코드가 원하는 값입니다. … 는 긴 출력을 줄인 곳입니다. # (9 unchanged attributes hidden) 같은 줄은 바뀌지 않은 속성을 테라폼이 스스로 감췄다는 표시입니다.

인라인 쪽 출력의 요점은 세 드리프트가 모두 「코드 쪽으로 되돌리기」로 나왔다는 점입니다.

Terraform will perform the following actions:

  # aws_instance.app will be updated in-place
  ~ resource "aws_instance" "app" {
        id                                   = "i-c13a8675260f5dd99"
      ~ instance_type                        = "t4g.medium" -> "t4g.small"
…
      ~ tags                                 = {
            "Name" = "demo-app"
          ~ "Role" = "hotfix" -> "app"
        }
…
    }

  # aws_security_group.app will be updated in-place
  ~ resource "aws_security_group" "app" {
        id                     = "sg-48ae176cc7ec05fad"
      ~ ingress                = [
          - {
…
              - from_port        = 22
…
            },
          - {
…
              - from_port        = 8080
…
              - to_port          = 8080
                # (1 unchanged attribute hidden)
            },
          + {
…
              + from_port        = 22
…
            },
        ]
        name                   = "demo-sg-inline"
        tags                   = {}
        # (9 unchanged attributes hidden)
    }

Plan: 0 to add, 2 to change, 0 to destroy.

8080 규칙은 - 로 지울 대상이 됐습니다. 태그는 hotfix -> app, 유형은 t4g.medium -> t4g.small 로 되돌리겠다고 합니다. 마지막 Plan: 줄의 숫자는 리소스 개수입니다. 고칠 리소스가 인스턴스와 보안 그룹 두 개라서 2 to change 입니다.

22 규칙이 - 로 지웠다가 + 로 다시 넣는 모양으로 나온 것은 moto 의 흉내 차이일 수 있습니다. 적용 뒤에도 22 는 남아 있었습니다(5절).

별도 리소스 쪽의 출력은 전부 이것입니다.

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

8080 이 전 세계에 열려 있는데 plan 은 「실제 인프라와 코드를 비교했고 다른 곳이 없다」고 말합니다.

무엇을 보고 판정하길래

3절의 문장으로 돌아가면 풀립니다. 각 리소스가 무엇을 책임지는지가 다릅니다.

flowchart TD
    subgraph I["인라인 — 리소스 하나가 규칙 목록 전체를 쥔다"]
        IS["aws_security_group.app<br/>ingress = 22 만"] --> IR["실제 규칙 목록 22, 8080<br/>→ 목록이 다르다 → 8080 을 지운다"]
    end
    subgraph S["별도 리소스 — 리소스마다 규칙 하나만 쥔다"]
        SR["aws_vpc_security_group_ingress_rule.ssh<br/>22 규칙 하나"] --> SX["22 규칙이 있나? → 있다 → 할 일 없음"]
        SN["8080 규칙<br/>어느 리소스의 코드 인자와도 짝이 없다"]
    end

인라인 ingress 는 「이 보안 그룹의 규칙은 이것이 전부다」라는 선언입니다. 그래서 목록에 없는 규칙은 지울 대상이 됩니다. 별도 리소스 aws_vpc_security_group_ingress_rule.ssh 가 책임지는 것은 「22 규칙 하나가 이 모양으로 있다」뿐입니다. 보안 그룹 리소스의 코드에는 규칙에 대한 인자가 하나도 없습니다. 그러니 8080 은 어느 리소스의 인자와도 비교되지 않습니다(규칙 목록을 속성으로 읽어 오기는 합니다. 6절에서 다룹니다). 테라폼 입장에서 8080 은 드리프트가 아니라 자기와 상관없는 남의 물건입니다.

5. apply 하면 — 닫힌 쪽과 남은 쪽

인라인 쪽을 apply 해서 되돌렸습니다. -auto-approve 는 「적용할까요?」 확인 질문을 건너뛰는 옵션입니다. 별도 리소스 쪽은 No changes 였으니 apply 할 것이 없습니다.

실측 — 인라인 apply 뒤 실제 상태 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
(cd inline && terraform apply -auto-approve -no-color)
aws ec2 describe-security-groups --group-ids $SG_INLINE $SG_SEP …
aws ec2 describe-instances --instance-ids $IID …
aws_security_group.app: Modifying... [id=sg-48ae176cc7ec05fad]
aws_security_group.app: Modifications complete after 0s [id=sg-48ae176cc7ec05fad]
aws_instance.app: Modifying... [id=i-c13a8675260f5dd99]
aws_instance.app: Modifications complete after 20s [id=i-c13a8675260f5dd99]
Apply complete! Resources: 0 added, 2 changed, 0 destroyed.
demo-sg-inline	22
demo-sg-separate	22,8080
t4g.small	app

마지막 줄은 이번에는 인스턴스의 유형과 Role 태그만 물은 결과입니다. 인라인 쪽은 apply 한 번으로 8080 이 닫히고 태그·유형까지 코드대로 돌아왔습니다. 별도 리소스 쪽은 여전히 22,8080 입니다.

인스턴스 줄의 20s 는 유형을 되돌리려고 인스턴스를 멈췄다 켠 시간입니다. moto 는 이 시간을 흉내만 냅니다. 진짜 AWS 에서는 더 걸리고 그동안 서비스가 멈춥니다.

반대 방향은 잡는다

그럼 별도 리소스 쪽은 드리프트를 아예 못 보는 걸까요. 이번에는 코드에 있는 22 규칙을 손으로 지워 봤습니다.

실측 — 별도 리소스 쪽에서 코드에 있는 규칙을 지우면 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
# 22번 인바운드 규칙의 ID 를 찾아 RID 에 담는다
RID=$(aws ec2 describe-security-group-rules … --output text)
# 그 규칙을 지운다
aws ec2 revoke-security-group-ingress --group-id $SG_SEP \
  --security-group-rule-ids $RID
echo "지운 규칙 $RID"
(cd separate && terraform plan -no-color)
지운 규칙 sgr-3bcde1565d38d5bcc
Terraform will perform the following actions:
  # aws_vpc_security_group_ingress_rule.ssh will be created
  + resource "aws_vpc_security_group_ingress_rule" "ssh" {
…
    }
Plan: 1 to add, 0 to change, 0 to destroy.
Warning: AWS resource not found during refresh
…
Automatically removing from Terraform State instead of returning the error,
which may trigger resource recreation. Original error: couldn't find resource

새로 고침 단계에서 「상태 파일에 있는 22 규칙이 AWS 에 없다」를 발견했습니다. 그래서 상태에서 빼고 다시 만들겠다고 합니다. 이 plan 을 apply 하자 Apply complete! Resources: 1 added 로 22 가 되살아났습니다. 그 뒤 보안 그룹의 포트를 다시 물어본 결과입니다.

demo-sg-separate	8080,22

이어서 plan 을 다시 돌린 결과입니다.

No changes. Your infrastructure matches the configuration.

별도 리소스 쪽은 비대칭입니다. 코드에 있는 규칙이 사라지면 잡아서 되살립니다. 코드에 없는 규칙이 생기면 모릅니다. 22 를 되살린 apply 뒤에도 8080 은 열려 있습니다. plan 은 여전히 조용합니다.

보안 그룹에서 이 비대칭은 방향이 나쁩니다. 규칙이 사라지는 드리프트는 대개 서비스가 끊겨서 바로 티가 납니다. 규칙이 생기는 드리프트는 아무 증상이 없습니다. 금요일 밤에 연 8080 이 그 경우입니다. 매주 plan 을 돌려 「No changes」를 보면 「인프라는 코드와 같다」고 믿게 됩니다. plan 이 조용하다는 사실이 오히려 열린 포트를 가려 주는 셈입니다.

6. 드리프트 직후로 돌아가 plan -refresh-only 를 돌리면

plan 에는 새로 고침만 하는 모드가 따로 있습니다. 공식 문서는 -refresh-only 모드를 이렇게 적습니다. 「테라폼 밖에서 원격 객체에 생긴 변화에 맞춰 상태 파일과 출력 값을 갱신하는 것만을 목표로 하는 계획을 만든다」("creates a plan whose goal is only to update the Terraform state and any root module output values to match changes made to remote objects outside of Terraform").

일반 plan 이 「실제를 코드 쪽으로」 옮기는 계획이라면, refresh-only 는 「상태 파일을 실제 쪽으로」 옮기는 계획입니다. AWS 는 건드리지 않습니다. 이 절의 실측은 시간을 거슬러 올라갑니다. 2절의 드리프트 직후, 5절의 인라인 apply 보다 먼저 두 폴더에서 돌린 것입니다. 리소스마다 찍히는 Refreshing state... 줄은 걸러 냈습니다.

실측 — 드리프트 뒤 plan -refresh-only (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
for d in inline separate; do
  (cd $d && terraform plan -refresh-only -no-color)
done

인라인 쪽 출력은 화살표 방향이 4절과 반대입니다.

Note: Objects have changed outside of Terraform
…
  # aws_instance.app has changed
  ~ resource "aws_instance" "app" {
        id                                   = "i-c13a8675260f5dd99"
      ~ instance_type                        = "t4g.small" -> "t4g.medium"
…
      ~ tags                                 = {
            "Name" = "demo-app"
          ~ "Role" = "app" -> "hotfix"
        }
…
    }

  # aws_security_group.app has changed
  ~ resource "aws_security_group" "app" {
        id                     = "sg-48ae176cc7ec05fad"
      ~ ingress                = [
          + {
…
              + from_port        = 8080
…
            },
            # (1 unchanged element hidden)
        ]
…

4절에서는 hotfix -> app 이었는데 여기서는 app -> hotfix 입니다. 「상태 파일에 적힌 옛 값 → 실제로 읽어 온 값」이기 때문입니다.

별도 리소스 쪽에서는 일반 plan 이 침묵했던 8080 이 나타납니다.

Note: Objects have changed outside of Terraform
…
  # aws_security_group.app has changed
  ~ resource "aws_security_group" "app" {
        id                     = "sg-4768b863bf66aaff7"
      ~ ingress                = [
          + {
…
              + from_port        = 22
…
            },
          + {
…
              + from_port        = 8080
…
            },
        ]
…

This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing any remote
objects.

보이는데 왜 고치지 않나

보안 그룹 리소스는 코드에 ingress 인자를 안 적어도 「지금 붙어 있는 규칙 목록」을 ingress 속성으로 읽어 와 상태에 들고 있습니다. 1절에서 가른 인자와 속성이 여기서 갈립니다. 보안 그룹을 처음 만든 순간 그 목록은 비어 있었습니다. 22 는 그 뒤 별도 리소스로 붙었습니다. 그래서 새로 고침으로 읽어 오면 22 까지 새로 생긴 것처럼 + 로 나옵니다.

그런데 코드의 보안 그룹 블록에는 ingress 인자가 없습니다. 코드가 원하는 값이 없으니 일반 plan 의 ② 비교 단계에서 맞출 대상도 없습니다. 테라폼은 8080 을 봤지만 할 일로 여기지 않습니다. 「못 본다」보다 「봐도 자기 일이 아니다」가 정확한 말입니다.

그래서 refresh-only 는 드리프트를 찾는 도구로 쓸 수 있습니다. 출력 끝 문단이 말하듯 이 계획을 apply 하면 AWS 는 그대로 두고 상태 파일만 실제 값으로 고쳐 적습니다. 「콘솔에서 바꾼 게 맞다, 그걸 받아들이겠다」일 때 쓰는 길입니다. 다만 인라인 규칙이나 인스턴스 유형처럼 코드에 인자로 적힌 값이라면, 받아들인 뒤 코드도 같이 고쳐야 합니다. 그러지 않으면 다음 일반 plan 이 다시 되돌리려 듭니다. 별도 리소스 쪽 8080 은 대응하는 인자가 없어서 받아들인 뒤에도 plan 이 조용합니다.

7. 두 방식, 공식 문서는 무엇을 권하나

여기까지만 보면 인라인이 더 안전해 보입니다. 공식 문서는 반대 방향을 권합니다. AWS 프로바이더의 aws_security_group 문서 머리에 이런 주의가 있습니다.

Avoid using the ingress and egress arguments of the aws_security_group resource to configure in-line rules, as they struggle with managing multiple CIDR blocks, and, due to the historical lack of unique IDs, tags and descriptions. To avoid these problems, use the current best practice of the aws_vpc_security_group_egress_rule and aws_vpc_security_group_ingress_rule resources with one CIDR block per rule.

요약하면, 인라인 규칙은 CIDR 여러 개를 다루는 데 약합니다. 규칙마다 고유 ID 가 없던 역사 탓에 태그·설명도 다루기 어렵습니다. 그래서 규칙 하나에 CIDR 하나씩 별도 리소스로 쓰는 것이 「현재의 모범 사례(best practice)」라고 합니다. aws_vpc_security_group_ingress_rule 문서는 여기에 경고를 하나 더 붙입니다. 이 별도 리소스를 인라인 규칙과 한 보안 그룹에 섞어 쓰지 말라는 것입니다. 섞었을 때 생길 수 있는 일로 규칙 충돌, 끝나지 않는 차이(perpetual differences), 규칙 덮어쓰기를 듭니다.

인라인 쪽에도 문서가 밝힌 주의가 하나 있습니다. 코드에서 ingress 블록을 전부 지워도 테라폼은 규칙을 없애지 않는다고 합니다. 모두 없애려면 ingress = [] 로 빈 목록을 적어야 합니다. 이번에는 재지 않았습니다.

문서가 권고의 근거로 든 것은 규칙을 다루기 편한가입니다. 이번 실측이 보여 준 것은 손으로 추가한 규칙을 누가 책임지나입니다. 두 축을 나란히 놓고 봅니다.

인라인 ingress 별도 리소스 aws_vpc_security_group_ingress_rule
공식 문서의 평가 피하라(CIDR 여럿·태그·설명에 약함) 현재의 모범 사례
리소스가 책임지는 범위 그 그룹의 규칙 목록 전체 규칙 하나
손으로 추가한 8080 plan 이 잡음 → apply 로 닫힘 plan No changes → apply 뒤에도 열림
손으로 지운 코드 속 규칙 (재지 않음) plan 이 잡음 → apply 로 되살림
plan -refresh-only 에 8080 보임 보임(고칠 대상은 아님)

그럼 어떻게 적어야 할까요. 권장은 이렇습니다. 공식 문서의 권고대로 규칙은 별도 리소스로 적습니다. 한 보안 그룹에 인라인 규칙과 섞지 않습니다. 대신 이번 실측이 보여 준 빈틈을 따로 메웁니다. 손으로 추가한 규칙은 일반 plan 이 잡지 못하므로 「plan 이 조용하다 = 보안 그룹이 코드와 같다」가 성립하지 않습니다. 그래서 추가된 규칙은 plan -refresh-only 를 돌려 보안 그룹의 ingress 속성에 코드에 없는 규칙이 나타나는지 따로 살핍니다. AWS Config 같은 감시 도구를 쓰는 방법도 있지만 여기서는 다루지 않습니다.

8. ignore_changes — 일부러 눈을 감기

드리프트 중에는 되돌리고 싶지 않은 것도 있습니다. 태그가 대표적입니다. 운영 중에 비용 추적 도구나 당직자가 태그를 붙이는데, 테라폼이 plan 마다 그걸 지우려 들면 성가십니다.

리소스 블록 안에 lifecycle 블록을 두면 테라폼이 그 리소스를 다루는 방식을 조절할 수 있습니다. 그 가운데 ignore_changes 가 「이 속성은 바뀌어도 모른 척하라」입니다. 공식 문서는 이렇게 적습니다. 목록에 적은 속성은 만들 때(create)는 고려하지만, 고칠 때(update)를 계획할 때는 무시한다 ("Terraform considers the arguments corresponding to the given attribute names when planning a create operation, but are ignored when planning an update operation").

인라인 폴더의 인스턴스에 이것을 넣었습니다.

hcl
resource "aws_instance" "app" {
  instance_type = "t4g.small"
  tags = { Name = "demo-app", Role = "app" }
  # … ami 등 나머지 인자

  lifecycle {
    ignore_changes = [tags]
  }
}

그리고 세 가지를 차례로 시험했습니다. 밖에서 태그를 바꾸기, 코드에서 태그를 바꾸기, 밖에서 유형을 바꾸기입니다.

실측 — ignore_changes = [tags] 뒤 세 가지 변경 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

먼저 밖에서 태그를 바꾸고 새 태그도 붙인 뒤 plan 을 돌렸습니다.

터미널
aws ec2 create-tags --resources $IID \
  --tags Key=Role,Value=hotfix Key=Owner,Value=oncall
terraform plan -no-color
No changes. Your infrastructure matches the configuration.

코드에서 태그를 Role = "app" → Role = "web" 으로 바꾼 뒤의 plan 도 똑같이 No changes. 한 줄이었습니다.

마지막으로 밖에서 인스턴스 유형을 다시 t4g.medium 으로 바꾸고 plan 을 돌렸습니다.

Terraform will perform the following actions:
  # aws_instance.app will be updated in-place
  ~ resource "aws_instance" "app" {
        id                                   = "i-c13a8675260f5dd99"
      ~ instance_type                        = "t4g.medium" -> "t4g.small"
…
        tags                                 = {
            "Name"  = "demo-app"
            "Owner" = "oncall"
            "Role"  = "hotfix"
        }
…
    }
Plan: 0 to add, 1 to change, 0 to destroy.

두 번째 결과가 함정입니다. ignore_changes 는 「밖에서 바꾼 것을 무시」하는 장치로 알려져 있습니다. 실제로는 그 속성의 변경을 양쪽 모두 무시합니다. 코드에 web 이라고 고쳐 적어도 적용되지 않습니다. 문서의 문장 그대로입니다. 고치는(update) 계획에서는 그 속성을 아예 보지 않기 때문입니다.

목록에 없는 인스턴스 유형은 평소대로 잡혔습니다. 셋째 출력의 tags 줄에 ~ 가 없다는 점도 봐 둘 만합니다. hotfix 와 새 Owner 가 실제 값 그대로 찍혀 있지만 바꿀 대상에서는 빠졌습니다.

그래서 ignore_changes 에 넣는 순간 그 속성은 코드가 아니라 바깥이 주인이 됩니다. 인라인 보안 그룹의 ingress 를 여기 넣으면, 문서의 문장대로라면 인라인 쪽에도 「손으로 추가한 규칙이 plan 에 안 잡히는 빈틈」을 스스로 만드는 셈입니다(재지 않았습니다).

한 장 요약

질문 답
plan 은 드리프트를 어떻게 아나 새로 고침에서 상태 파일의 리소스마다 AWS 의 현재 속성을 읽고, 코드의 인자와 비교한다
무엇을 못 보나 어느 리소스의 인자도 책임지지 않는 것. 별도 리소스로 규칙을 적은 보안 그룹에 손으로 추가한 8080 은 apply 뒤에도 열려 있었다. -refresh-only 에는 보였다
그래서 어떻게 적나 공식 권고대로 별도 리소스로 적고 인라인과 섞지 않는다. 손으로 추가된 규칙은 -refresh-only 등으로 따로 살핀다
ignore_changes 적은 속성은 update 계획에서 통째로 무시한다. 밖의 변경도, 코드의 변경(Role = "web")도 적용되지 않았다

관련 항목

이 편이 다룬 개념

구성 드리프트 · 상태 파일 · 새로 고침 (테라폼) · ignore_changes · lifecycle 블록

드리프트를 만드는 쪽과 막는 쪽

콘솔 수정 · 최소 권한 · AWS IAM · 감사 로그 · AWS CloudTrail · AWS Config

보안 그룹과 그 주변

보안 그룹 · 방화벽 · 포트 · CIDR · 인바운드 규칙

같은 약속을 다른 방식으로 지키는 것

코드형 인프라 · 선언적 설정 · 불변 인프라 · GitOps · CloudFormation · 앤서블

드리프트가 쌓인 끝

스노우플레이크 서버 · 멱등성 · 테라폼 · HCL

IaC 시리즈의 다른 편

IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드 · IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일 · IAC 03 이미 있는 것을 코드로 가져오기 — import · IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금